mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-02 22:40:51 +00:00
Compare commits
9 Commits
issue_577
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2df252fd7 | ||
|
|
433ba4c435 | ||
|
|
ff6e0a26fc | ||
|
|
a02ea41446 | ||
|
|
c63f818789 | ||
|
|
6026e2372d | ||
|
|
cae7123c39 | ||
|
|
7faf122e93 | ||
|
|
d7435a9748 |
@@ -15079,9 +15079,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- planned-maintenance:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- planned-maintenance:list
|
||||
summary: List downtime schedules
|
||||
tags:
|
||||
- downtimeschedules
|
||||
@@ -15136,9 +15136,13 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- planned-maintenance:create
|
||||
- planned-maintenance:attach
|
||||
- rule:attach
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
- planned-maintenance:create
|
||||
- planned-maintenance:attach
|
||||
- rule:attach
|
||||
summary: Create downtime schedule
|
||||
tags:
|
||||
- downtimeschedules
|
||||
@@ -15183,9 +15187,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- planned-maintenance:delete
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
- planned-maintenance:delete
|
||||
summary: Delete downtime schedule
|
||||
tags:
|
||||
- downtimeschedules
|
||||
@@ -15241,9 +15245,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- planned-maintenance:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- planned-maintenance:read
|
||||
summary: Get downtime schedule by ID
|
||||
tags:
|
||||
- downtimeschedules
|
||||
@@ -15298,9 +15302,17 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- planned-maintenance:update
|
||||
- planned-maintenance:attach
|
||||
- planned-maintenance:detach
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
- planned-maintenance:update
|
||||
- planned-maintenance:attach
|
||||
- planned-maintenance:detach
|
||||
- rule:attach
|
||||
- rule:detach
|
||||
summary: Update downtime schedule
|
||||
tags:
|
||||
- downtimeschedules
|
||||
|
||||
@@ -53,7 +53,6 @@ export enum QueryParams {
|
||||
variables = 'variables',
|
||||
version = 'version',
|
||||
source = 'source',
|
||||
showClassicCreateAlertsPage = 'showClassicCreateAlertsPage',
|
||||
isTestAlert = 'isTestAlert',
|
||||
yAxisUnit = 'yAxisUnit',
|
||||
ruleName = 'ruleName',
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
|
||||
@@ -591,7 +590,6 @@ export const listViewInitialTraceQuery: Query = {
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
offset: 0,
|
||||
pageSize: 10,
|
||||
selectColumns: defaultTraceSelectedColumns,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -26,7 +26,6 @@ export const REACT_QUERY_KEY = {
|
||||
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
|
||||
CLONE_ALERT_RULE: 'CLONE_ALERT_RULE',
|
||||
GET_HOST_LIST: 'GET_HOST_LIST',
|
||||
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
|
||||
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
|
||||
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
|
||||
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
|
||||
import CreateAlertPage from 'pages/CreateAlert';
|
||||
import { act, fireEvent, render } from 'tests/test-utils';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { ALERT_TYPE_TO_TITLE, ALERT_TYPE_URL_MAP } from './constants';
|
||||
|
||||
jest
|
||||
.spyOn(usePrefillAlertConditions, 'usePrefillAlertConditions')
|
||||
.mockReturnValue({
|
||||
matchType: '3',
|
||||
op: '1',
|
||||
target: 100,
|
||||
targetUnit: 'rpm',
|
||||
});
|
||||
|
||||
let mockWindowOpen: jest.Mock;
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
function findLinkForAlertType(
|
||||
links: HTMLElement[],
|
||||
alertType: AlertTypes,
|
||||
): HTMLElement {
|
||||
const link = links.find(
|
||||
(el) =>
|
||||
el.closest('[data-testid]')?.getAttribute('data-testid') ===
|
||||
`alert-type-card-${alertType}`,
|
||||
);
|
||||
expect(link).toBeTruthy();
|
||||
return link as HTMLElement;
|
||||
}
|
||||
|
||||
function clickLinkAndVerifyRedirect(
|
||||
link: HTMLElement,
|
||||
expectedUrl: string,
|
||||
): void {
|
||||
fireEvent.click(link);
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(expectedUrl, '_blank');
|
||||
}
|
||||
describe('Alert rule documentation redirection', () => {
|
||||
let renderResult: ReturnType<typeof render>;
|
||||
|
||||
beforeAll(() => {
|
||||
mockWindowOpen = jest.fn();
|
||||
window.open = mockWindowOpen;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
renderResult = render(
|
||||
<CreateAlertPage />,
|
||||
{},
|
||||
{
|
||||
initialRoute: ROUTES.ALERTS_NEW,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render alert type cards', () => {
|
||||
const { getByText, getAllByText } = renderResult;
|
||||
|
||||
// Check for the heading
|
||||
expect(getByText('choose_alert_type')).toBeInTheDocument();
|
||||
|
||||
// Check for alert type titles and descriptions
|
||||
Object.values(AlertTypes).forEach((alertType) => {
|
||||
const title = ALERT_TYPE_TO_TITLE[alertType];
|
||||
expect(getByText(title)).toBeInTheDocument();
|
||||
expect(getByText(`${title}_desc`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const clickHereLinks = getAllByText(
|
||||
'Click here to see how to create a sample alert.',
|
||||
);
|
||||
|
||||
expect(clickHereLinks).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should redirect to correct documentation for each alert type', () => {
|
||||
const { getAllByText } = renderResult;
|
||||
|
||||
const clickHereLinks = getAllByText(
|
||||
'Click here to see how to create a sample alert.',
|
||||
);
|
||||
const alertTypeCount = Object.keys(AlertTypes).length;
|
||||
|
||||
expect(clickHereLinks).toHaveLength(alertTypeCount);
|
||||
|
||||
Object.values(AlertTypes).forEach((alertType) => {
|
||||
const linkForAlertType = findLinkForAlertType(clickHereLinks, alertType);
|
||||
const expectedUrl = ALERT_TYPE_URL_MAP[alertType];
|
||||
|
||||
clickLinkAndVerifyRedirect(linkForAlertType, expectedUrl.selection);
|
||||
});
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledTimes(alertTypeCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Create alert page redirection', () => {
|
||||
Object.values(AlertTypes)
|
||||
.filter((type) => type !== AlertTypes.ANOMALY_BASED_ALERT)
|
||||
.forEach((alertType) => {
|
||||
it(`should redirect to create alert page for ${alertType} and "Check an example alert" should redirect to the correct documentation`, () => {
|
||||
const { getByRole } = render(
|
||||
<CreateAlertPage />,
|
||||
{},
|
||||
{
|
||||
initialRoute: `${ROUTES.ALERTS_NEW}?alertType=${alertType}&showClassicCreateAlertsPage=true`,
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(
|
||||
getByRole('button', {
|
||||
name: /alert setup guide/i,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
ALERT_TYPE_URL_MAP[alertType].creation,
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
|
||||
import CreateAlertPage from 'pages/CreateAlert';
|
||||
import { act, fireEvent, render } from 'tests/test-utils';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { ALERT_TYPE_URL_MAP } from './constants';
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest
|
||||
.spyOn(usePrefillAlertConditions, 'usePrefillAlertConditions')
|
||||
.mockReturnValue({
|
||||
matchType: '3',
|
||||
op: '1',
|
||||
target: 100,
|
||||
targetUnit: 'rpm',
|
||||
});
|
||||
|
||||
describe('Anomaly Alert Documentation Redirection', () => {
|
||||
let mockWindowOpen: jest.Mock;
|
||||
|
||||
beforeAll(() => {
|
||||
mockWindowOpen = jest.fn();
|
||||
window.open = mockWindowOpen;
|
||||
});
|
||||
|
||||
it('should handle anomaly alert documentation redirection correctly', () => {
|
||||
// The setup guide button only exists in the classic form.
|
||||
const { getByRole } = render(<CreateAlertPage />, undefined, {
|
||||
initialRoute: `${ROUTES.ALERTS_NEW}?ruleType=anomaly_rule&showClassicCreateAlertsPage=true`,
|
||||
});
|
||||
|
||||
const alertType = AlertTypes.ANOMALY_BASED_ALERT;
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(
|
||||
getByRole('button', {
|
||||
name: /alert setup guide/i,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
ALERT_TYPE_URL_MAP[alertType].creation,
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,25 +23,6 @@ jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('container/FormAlertRules', () => ({
|
||||
__esModule: true,
|
||||
default: function MockFormAlertRules({
|
||||
alertType,
|
||||
}: {
|
||||
alertType: AlertTypes;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<h1>Form Alert Rules</h1>
|
||||
<p>{alertType}</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
AlertDetectionTypes: {
|
||||
THRESHOLD_ALERT: 'threshold_rule',
|
||||
ANOMALY_DETECTION_ALERT: 'anomaly_rule',
|
||||
},
|
||||
}));
|
||||
jest.mock('container/CreateAlertV2', () => ({
|
||||
__esModule: true,
|
||||
default: function MockCreateAlertV2({
|
||||
@@ -76,7 +57,6 @@ const mockGetUrlQuery = jest.fn();
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockDeleteUrlQuery = jest.fn();
|
||||
|
||||
const FORM_ALERT_RULES_TEXT = 'Form Alert Rules';
|
||||
const CREATE_ALERT_V2_TEXT = 'Create Alert V2';
|
||||
|
||||
describe('CreateAlertRule', () => {
|
||||
@@ -95,20 +75,6 @@ describe('CreateAlertRule', () => {
|
||||
useAppContextSpy.mockReturnValue(getAppContextMockState());
|
||||
});
|
||||
|
||||
it('should render classic flow when showClassicCreateAlertsPage is true', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.showClassicCreateAlertsPage) {
|
||||
return 'true';
|
||||
}
|
||||
if (key === QueryParams.alertType) {
|
||||
return AlertTypes.METRICS_BASED_ALERT;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
render(<CreateAlertRule />);
|
||||
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render new flow when alertType is provided', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.alertType) {
|
||||
@@ -124,7 +90,6 @@ describe('CreateAlertRule', () => {
|
||||
mockGetUrlQuery.mockReturnValue(null);
|
||||
useCompositeQueryParamSpy.mockReturnValue(null);
|
||||
render(<CreateAlertRule />);
|
||||
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -149,9 +114,6 @@ describe('CreateAlertRule', () => {
|
||||
|
||||
it('should render new flow with the anomaly alert type when ruleType is anomaly_rule', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.showClassicCreateAlertsPage) {
|
||||
return 'false';
|
||||
}
|
||||
if (key === QueryParams.ruleType) {
|
||||
return 'anomaly_rule';
|
||||
}
|
||||
@@ -160,7 +122,6 @@ describe('CreateAlertRule', () => {
|
||||
render(<CreateAlertRule />);
|
||||
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(AlertTypes.ANOMALY_BASED_ALERT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use alertType from URL when provided', () => {
|
||||
@@ -284,27 +245,5 @@ describe('CreateAlertRule', () => {
|
||||
);
|
||||
expect(mockSafeNavigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should navigate even when showClassicCreateAlertsPage flag is present', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.showClassicCreateAlertsPage) {
|
||||
return 'true';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
render(<CreateAlertRule />);
|
||||
fireEvent.click(screen.getByText('metric_based_alert'));
|
||||
|
||||
expect(mockSetUrlQuery).toHaveBeenCalledWith(
|
||||
QueryParams.ruleType,
|
||||
'threshold_rule',
|
||||
);
|
||||
expect(mockSetUrlQuery).toHaveBeenCalledWith(
|
||||
QueryParams.alertType,
|
||||
AlertTypes.METRICS_BASED_ALERT,
|
||||
);
|
||||
expect(mockSafeNavigate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryPromQLData,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import {
|
||||
AlertDef,
|
||||
@@ -63,7 +63,7 @@ export const alertDefaults: AlertDef = {
|
||||
export const anamolyAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
|
||||
ruleType: RuletypesRuleTypeDTO.anomaly_rule,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
@@ -201,14 +201,6 @@ export const exceptionAlertDefaults: AlertDef = {
|
||||
alert: '',
|
||||
};
|
||||
|
||||
export const ALERTS_VALUES_MAP: Record<AlertTypes, AlertDef> = {
|
||||
[AlertTypes.ANOMALY_BASED_ALERT]: anamolyAlertDefaults,
|
||||
[AlertTypes.METRICS_BASED_ALERT]: alertDefaults,
|
||||
[AlertTypes.LOGS_BASED_ALERT]: logAlertDefaults,
|
||||
[AlertTypes.TRACES_BASED_ALERT]: traceAlertDefaults,
|
||||
[AlertTypes.EXCEPTIONS_BASED_ALERT]: exceptionAlertDefaults,
|
||||
};
|
||||
|
||||
export const ALERT_TYPE_BREADCRUMB_TITLE: Record<AlertTypes, string> = {
|
||||
[AlertTypes.ANOMALY_BASED_ALERT]: 'Anomaly-Based Alert',
|
||||
[AlertTypes.METRICS_BASED_ALERT]: 'Metric-Based Alert',
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Form, Tabs, TabsProps } from 'antd';
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
|
||||
import AlertBreadcrumb from 'components/AlertBreadcrumb';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import CreateAlertV2 from 'container/CreateAlertV2';
|
||||
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
@@ -16,25 +15,20 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { AlertListTabs } from 'pages/AlertList/types';
|
||||
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
|
||||
import { ALERT_TYPE_VS_SOURCE_MAPPING } from './config';
|
||||
import { ALERTS_VALUES_MAP, ALERT_TYPE_BREADCRUMB_TITLE } from './defaults';
|
||||
import { ALERT_TYPE_BREADCRUMB_TITLE } from './defaults';
|
||||
import SelectAlertType from './SelectAlertType';
|
||||
|
||||
import './CreateAlertRule.styles.scss';
|
||||
|
||||
function CreateRules(): JSX.Element {
|
||||
const [formInstance] = Form.useForm();
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const queryParams = useUrlQuery();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const ruleTypeFromURL = queryParams.get(QueryParams.ruleType);
|
||||
const alertTypeFromURL = queryParams.get(QueryParams.alertType);
|
||||
const version = queryParams.get(QueryParams.version);
|
||||
const showClassicCreateAlertsPageFlag =
|
||||
queryParams.get(QueryParams.showClassicCreateAlertsPage) === 'true';
|
||||
|
||||
const isTypeSelectionMode =
|
||||
!alertTypeFromURL && !ruleTypeFromURL && !compositeQuery;
|
||||
@@ -46,7 +40,7 @@ function CreateRules(): JSX.Element {
|
||||
}, [isTypeSelectionMode]);
|
||||
|
||||
const alertType = useMemo(() => {
|
||||
if (ruleTypeFromURL === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
|
||||
if (ruleTypeFromURL === RuletypesRuleTypeDTO.anomaly_rule) {
|
||||
return AlertTypes.ANOMALY_BASED_ALERT;
|
||||
}
|
||||
if (!alertTypeFromURL) {
|
||||
@@ -59,14 +53,6 @@ function CreateRules(): JSX.Element {
|
||||
return alertTypeFromURL as AlertTypes;
|
||||
}, [alertTypeFromURL, ruleTypeFromURL, compositeQuery?.builder.queryData]);
|
||||
|
||||
const initialAlertValue: AlertDef = useMemo(
|
||||
() => ({
|
||||
...ALERTS_VALUES_MAP[alertType],
|
||||
version: version || ENTITY_VERSION_V5,
|
||||
}),
|
||||
[alertType, version],
|
||||
);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(tab: string): void => {
|
||||
queryParams.set('tab', tab);
|
||||
@@ -80,13 +66,10 @@ function CreateRules(): JSX.Element {
|
||||
const handleSelectType = useCallback(
|
||||
(type: AlertTypes, newTab?: boolean): void => {
|
||||
if (type === AlertTypes.ANOMALY_BASED_ALERT) {
|
||||
queryParams.set(
|
||||
QueryParams.ruleType,
|
||||
AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
|
||||
);
|
||||
queryParams.set(QueryParams.ruleType, RuletypesRuleTypeDTO.anomaly_rule);
|
||||
queryParams.set(QueryParams.alertType, AlertTypes.METRICS_BASED_ALERT);
|
||||
} else {
|
||||
queryParams.set(QueryParams.ruleType, AlertDetectionTypes.THRESHOLD_ALERT);
|
||||
queryParams.set(QueryParams.ruleType, RuletypesRuleTypeDTO.threshold_rule);
|
||||
queryParams.set(QueryParams.alertType, type);
|
||||
}
|
||||
|
||||
@@ -99,26 +82,8 @@ function CreateRules(): JSX.Element {
|
||||
if (isTypeSelectionMode) {
|
||||
return <SelectAlertType onSelect={handleSelectType} />;
|
||||
}
|
||||
|
||||
if (showClassicCreateAlertsPageFlag) {
|
||||
return (
|
||||
<FormAlertRules
|
||||
alertType={alertType}
|
||||
formInstance={formInstance}
|
||||
initialValue={initialAlertValue}
|
||||
ruleId=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <CreateAlertV2 alertType={alertType} />;
|
||||
}, [
|
||||
isTypeSelectionMode,
|
||||
handleSelectType,
|
||||
showClassicCreateAlertsPageFlag,
|
||||
alertType,
|
||||
formInstance,
|
||||
initialAlertValue,
|
||||
]);
|
||||
}, [isTypeSelectionMode, handleSelectType, alertType]);
|
||||
|
||||
const items: TabsProps['items'] = [
|
||||
{
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import classNames from 'classnames';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { RotateCcw } from '@signozhq/icons';
|
||||
import { useAlertRuleOptional } from 'providers/Alert';
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
|
||||
@@ -22,8 +15,6 @@ function CreateAlertHeader(): JSX.Element {
|
||||
const alertRuleContext = useAlertRuleOptional();
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const groupByLabels = useMemo(() => {
|
||||
const labels = new Array<string>();
|
||||
@@ -46,14 +37,6 @@ function CreateAlertHeader(): JSX.Element {
|
||||
[groupByLabels],
|
||||
);
|
||||
|
||||
const handleSwitchToClassicExperience = useCallback(() => {
|
||||
logEvent('Alert: Switch to classic experience button clicked', {});
|
||||
|
||||
urlQuery.set(QueryParams.showClassicCreateAlertsPage, 'true');
|
||||
const url = `${ROUTES.ALERTS_NEW}?${urlQuery.toString()}`;
|
||||
safeNavigate(url, { replace: true });
|
||||
}, [safeNavigate, urlQuery]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('alert-header', { 'edit-alert-header': isEditMode })}
|
||||
@@ -61,15 +44,6 @@ function CreateAlertHeader(): JSX.Element {
|
||||
{!isEditMode && (
|
||||
<div className="alert-header__tab-bar">
|
||||
<div className="alert-header__tab">New Alert Rule</div>
|
||||
<Button
|
||||
prefix={<RotateCcw size={12} />}
|
||||
onClick={handleSwitchToClassicExperience}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Switch to Classic Experience
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="alert-header__content">
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { defaultPostableAlertRuleV2 } from 'container/CreateAlertV2/constants';
|
||||
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
|
||||
import * as useSafeNavigateHook from 'hooks/useSafeNavigate';
|
||||
import { mockAppLocation } from 'tests/router-mocks';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
@@ -11,11 +8,6 @@ import * as rulesHook from '../../../../api/generated/services/rules';
|
||||
import { CreateAlertProvider } from '../../context';
|
||||
import CreateAlertHeader from '../CreateAlertHeader';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.spyOn(useSafeNavigateHook, 'useSafeNavigate').mockReturnValue({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
});
|
||||
|
||||
jest.spyOn(rulesHook, 'useCreateRule').mockReturnValue({
|
||||
mutate: jest.fn(),
|
||||
isLoading: false,
|
||||
@@ -101,37 +93,4 @@ describe('CreateAlertHeader', () => {
|
||||
screen.getByPlaceholderText(ENTER_ALERT_RULE_NAME_PLACEHOLDER),
|
||||
).toHaveValue('TEST_ALERT');
|
||||
});
|
||||
|
||||
it('should navigate to classic experience when button is clicked', () => {
|
||||
renderCreateAlertHeader();
|
||||
const switchToClassicExperienceButton = screen.getByText(
|
||||
'Switch to Classic Experience',
|
||||
);
|
||||
expect(switchToClassicExperienceButton).toBeInTheDocument();
|
||||
fireEvent.click(switchToClassicExperienceButton);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set(QueryParams.showClassicCreateAlertsPage, 'true');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`${ROUTES.ALERTS_NEW}?${params.toString()}`,
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should not render "switch to classic experience" button when isEditMode is true', () => {
|
||||
render(
|
||||
<CreateAlertProvider
|
||||
isEditMode
|
||||
initialAlertType={AlertTypes.METRICS_BASED_ALERT}
|
||||
initialAlertState={getCreateAlertLocalStateFromAlertDef(
|
||||
defaultPostableAlertRuleV2,
|
||||
)}
|
||||
>
|
||||
<CreateAlertHeader />
|
||||
</CreateAlertProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText('Switch to Classic Experience'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -556,8 +556,6 @@ describe('Footer utils', () => {
|
||||
},
|
||||
ruleType: 'threshold_rule',
|
||||
schemaVersion: 'v2alpha1',
|
||||
source: 'http://localhost/',
|
||||
version: 'v5',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
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 { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import {
|
||||
@@ -266,7 +266,7 @@ export function buildCreateThresholdAlertRulePayload(
|
||||
// Evaluation
|
||||
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
|
||||
|
||||
let ruleType: string = AlertDetectionTypes.THRESHOLD_ALERT;
|
||||
let ruleType: string = RuletypesRuleTypeDTO.threshold_rule;
|
||||
if (query.queryType === EQueryType.PROM) {
|
||||
ruleType = 'promql_rule';
|
||||
}
|
||||
@@ -292,9 +292,7 @@ export function buildCreateThresholdAlertRulePayload(
|
||||
summary: notificationSettings.description,
|
||||
},
|
||||
notificationSettings: notificationSettingsProps,
|
||||
version: 'v5',
|
||||
schemaVersion: 'v2alpha1',
|
||||
source: window?.location.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -321,7 +319,7 @@ export function buildCreateAnomalyAlertRulePayload(
|
||||
|
||||
return {
|
||||
...payload,
|
||||
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
|
||||
ruleType: RuletypesRuleTypeDTO.anomaly_rule,
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
condition: {
|
||||
...payload.condition,
|
||||
|
||||
@@ -3,12 +3,12 @@ import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialClickHouseData,
|
||||
initialQueryPromQLData,
|
||||
} from 'constants/queryBuilder';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import store from 'store';
|
||||
import { TestRouter } from 'tests/router';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
@@ -237,7 +237,7 @@ describe('QuerySection', () => {
|
||||
expect.any(Object),
|
||||
{
|
||||
[QueryParams.alertType]: AlertTypes.LOGS_BASED_ALERT,
|
||||
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
[QueryParams.ruleType]: RuletypesRuleTypeDTO.threshold_rule,
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
@@ -252,7 +252,7 @@ describe('QuerySection', () => {
|
||||
expect.any(Object),
|
||||
{
|
||||
[QueryParams.alertType]: AlertTypes.TRACES_BASED_ALERT,
|
||||
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
[QueryParams.ruleType]: RuletypesRuleTypeDTO.threshold_rule,
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
@@ -376,7 +376,7 @@ describe('QuerySection', () => {
|
||||
|
||||
expect(result[1]).toStrictEqual({
|
||||
[QueryParams.alertType]: AlertTypes.METRICS_BASED_ALERT,
|
||||
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
[QueryParams.ruleType]: RuletypesRuleTypeDTO.threshold_rule,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
|
||||
@@ -18,8 +18,8 @@ export function buildAlertDefForChartPreview({
|
||||
...initialAlertDef,
|
||||
ruleType:
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT
|
||||
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
|
||||
: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
? RuletypesRuleTypeDTO.anomaly_rule
|
||||
: RuletypesRuleTypeDTO.threshold_rule,
|
||||
condition: {
|
||||
...initialAlertDef.condition,
|
||||
targetUnit: thresholdState.thresholds?.[0].unit,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryPromQLData,
|
||||
@@ -39,7 +38,6 @@ const defaultEvaluation: PostableAlertRuleV2['evaluation'] = {
|
||||
|
||||
export const defaultPostableAlertRuleV2: PostableAlertRuleV2 = {
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
schemaVersion: NEW_ALERT_SCHEMA_VERSION,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import {
|
||||
useCreateRule,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
useUpdateRuleByID,
|
||||
} from 'api/generated/services/rules';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
@@ -186,8 +186,8 @@ export function CreateAlertProvider(
|
||||
[QueryParams.alertType]: value,
|
||||
[QueryParams.ruleType]:
|
||||
value === AlertTypes.ANOMALY_BASED_ALERT
|
||||
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
|
||||
: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
? RuletypesRuleTypeDTO.anomaly_rule
|
||||
: RuletypesRuleTypeDTO.threshold_rule,
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Spin } from 'antd';
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
import { v4 } from 'uuid';
|
||||
@@ -251,8 +251,7 @@ export {
|
||||
export function getThresholdStateFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2,
|
||||
): AlertThresholdState {
|
||||
const isAnomalyRule =
|
||||
alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
|
||||
const isAnomalyRule = alertDef.ruleType === RuletypesRuleTypeDTO.anomaly_rule;
|
||||
return {
|
||||
...INITIAL_ALERT_THRESHOLD_STATE,
|
||||
thresholds:
|
||||
@@ -287,7 +286,7 @@ export function getThresholdStateFromAlertDef(
|
||||
export function getAlertTypeFromAlertDef(
|
||||
alertDef: PostableAlertRuleV2 | undefined,
|
||||
): AlertTypes {
|
||||
if (alertDef?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
|
||||
if (alertDef?.ruleType === RuletypesRuleTypeDTO.anomaly_rule) {
|
||||
return AlertTypes.ANOMALY_BASED_ALERT;
|
||||
}
|
||||
return alertDef?.alertType as AlertTypes;
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Form } from 'antd';
|
||||
import EditAlertV2 from 'container/EditAlertV2';
|
||||
import FormAlertRules from 'container/FormAlertRules';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import {
|
||||
NEW_ALERT_SCHEMA_VERSION,
|
||||
PostableAlertRuleV2,
|
||||
} from 'types/api/alerts/alertTypesV2';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
|
||||
function EditRules({
|
||||
initialValue,
|
||||
ruleId,
|
||||
initialV2AlertValue,
|
||||
}: EditRulesProps): JSX.Element {
|
||||
const [formInstance] = Form.useForm();
|
||||
|
||||
if (
|
||||
initialV2AlertValue !== null &&
|
||||
initialV2AlertValue.schemaVersion === NEW_ALERT_SCHEMA_VERSION
|
||||
) {
|
||||
return (
|
||||
<EditAlertV2
|
||||
initialAlert={initialV2AlertValue}
|
||||
alertType={initialValue.alertType as AlertTypes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormAlertRules
|
||||
alertType={
|
||||
initialValue.alertType
|
||||
? (initialValue.alertType as AlertTypes)
|
||||
: AlertTypes.METRICS_BASED_ALERT
|
||||
}
|
||||
formInstance={formInstance}
|
||||
initialValue={initialValue}
|
||||
ruleId={ruleId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditRulesProps {
|
||||
initialValue: AlertDef;
|
||||
ruleId: string;
|
||||
initialV2AlertValue: PostableAlertRuleV2 | null;
|
||||
}
|
||||
|
||||
export default EditRules;
|
||||
@@ -1,268 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button, Flex, Form, Select, Tooltip } from 'antd';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { AlertDef, Labels } from 'types/api/alerts/def';
|
||||
import APIError from 'types/api/error';
|
||||
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import ChannelSelect from './ChannelSelect';
|
||||
import LabelSelect from './labels';
|
||||
import {
|
||||
FormContainer,
|
||||
FormItemMedium,
|
||||
InputSmall,
|
||||
SeveritySelect,
|
||||
StepHeading,
|
||||
TextareaMedium,
|
||||
} from './styles';
|
||||
|
||||
import './FormAlertRules.styles.scss';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface BasicInfoProps {
|
||||
isNewRule: boolean;
|
||||
alertDef: AlertDef;
|
||||
setAlertDef: (a: AlertDef) => void;
|
||||
}
|
||||
|
||||
function BasicInfo({
|
||||
isNewRule,
|
||||
alertDef,
|
||||
setAlertDef,
|
||||
}: BasicInfoProps): JSX.Element {
|
||||
const { t } = useTranslation('alerts');
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
data: channels,
|
||||
error,
|
||||
isError,
|
||||
refetch,
|
||||
} = useChannelOptions();
|
||||
|
||||
const { canCreate: addNewChannelPermission } =
|
||||
useNotificationChannelCollectionPermissions();
|
||||
|
||||
const [shouldBroadCastToAllChannels, setShouldBroadCastToAllChannels] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const hasPreferredChannels =
|
||||
(alertDef.preferredChannels && alertDef.preferredChannels.length > 0) ||
|
||||
isNewRule;
|
||||
|
||||
setShouldBroadCastToAllChannels(!hasPreferredChannels);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleBroadcastToAllChannels = (shouldBroadcast: boolean): void => {
|
||||
setShouldBroadCastToAllChannels(shouldBroadcast);
|
||||
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
broadcastToAll: shouldBroadcast,
|
||||
});
|
||||
};
|
||||
|
||||
const noChannels = channels.length === 0;
|
||||
const handleCreateNewChannels = useCallback(() => {
|
||||
logEvent('Alert: Create notification channel button clicked', {
|
||||
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
|
||||
ruleId: isNewRule ? 0 : alertDef?.id,
|
||||
});
|
||||
openInNewTab(ROUTES.CHANNELS_NEW);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const hasLoggedEvent = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isNewRule && !hasLoggedEvent.current) {
|
||||
logEvent('Alert: New alert creation page visited', {
|
||||
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
|
||||
numberOfChannels: channels.length,
|
||||
});
|
||||
hasLoggedEvent.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading]);
|
||||
|
||||
const refetchChannels = async (): Promise<void> => {
|
||||
await refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<StepHeading> {t('alert_form_step4')} </StepHeading>
|
||||
<FormContainer>
|
||||
<Form.Item
|
||||
label={t('field_severity')}
|
||||
labelAlign="left"
|
||||
name={['labels', 'severity']}
|
||||
>
|
||||
<SeveritySelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue="critical"
|
||||
data-testid="alert-severity-select"
|
||||
onChange={(value: unknown | string): void => {
|
||||
const s = (value as string) || 'critical';
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
labels: {
|
||||
...alertDef.labels,
|
||||
severity: s,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Option value="critical">{t('option_critical')}</Option>
|
||||
<Option value="error">{t('option_error')}</Option>
|
||||
<Option value="warning">{t('option_warning')}</Option>
|
||||
<Option value="info">{t('option_info')}</Option>
|
||||
</SeveritySelect>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
required
|
||||
name="alert"
|
||||
labelAlign="left"
|
||||
label={t('field_alert_name')}
|
||||
rules={[
|
||||
{ required: true, message: requireErrorMessage(t('field_alert_name')) },
|
||||
]}
|
||||
>
|
||||
<InputSmall
|
||||
data-testid="alert-name-input-v1"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
alert: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('field_alert_desc')}
|
||||
labelAlign="left"
|
||||
name={['annotations', 'description']}
|
||||
>
|
||||
<TextareaMedium
|
||||
data-testid="alert-description-input"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
annotations: {
|
||||
...alertDef.annotations,
|
||||
description: e.target.value,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<FormItemMedium label={t('field_labels')}>
|
||||
<LabelSelect
|
||||
onSetLabels={(l: Labels): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
labels: {
|
||||
...l,
|
||||
},
|
||||
});
|
||||
}}
|
||||
initialValues={alertDef.labels}
|
||||
/>
|
||||
</FormItemMedium>
|
||||
|
||||
<FormItemMedium
|
||||
name="alert_all_configured_channels"
|
||||
label="Alert all the configured channels"
|
||||
>
|
||||
<Switch
|
||||
color="primary"
|
||||
textPlacement="right"
|
||||
disabledTooltip={
|
||||
isLoading
|
||||
? 'Wait for the channels to load'
|
||||
: 'No channels. Ask an admin to create a notification channel'
|
||||
}
|
||||
value={shouldBroadCastToAllChannels}
|
||||
onChange={handleBroadcastToAllChannels}
|
||||
disabled={noChannels || !!isLoading}
|
||||
testId="alert-broadcast-to-all-channels"
|
||||
/>
|
||||
</FormItemMedium>
|
||||
|
||||
{!shouldBroadCastToAllChannels && (
|
||||
<Tooltip
|
||||
title={
|
||||
noChannels && !addNewChannelPermission
|
||||
? 'No channels. Ask an admin to create a notification channel'
|
||||
: undefined
|
||||
}
|
||||
placement="right"
|
||||
>
|
||||
<FormItemMedium
|
||||
label="Notification Channels"
|
||||
name="notification_channels"
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: requireErrorMessage(t('field_alert_name')) },
|
||||
]}
|
||||
>
|
||||
<ChannelSelect
|
||||
onDropdownOpen={refetchChannels}
|
||||
disabled={shouldBroadCastToAllChannels}
|
||||
currentValue={alertDef.preferredChannels}
|
||||
handleCreateNewChannels={handleCreateNewChannels}
|
||||
channels={channels}
|
||||
isLoading={isLoading}
|
||||
hasError={isError}
|
||||
error={error as APIError}
|
||||
onSelectChannels={(preferredChannels): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
preferredChannels,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormItemMedium>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{noChannels && (
|
||||
<Tooltip
|
||||
title={
|
||||
!addNewChannelPermission
|
||||
? 'Ask an admin to create a notification channel'
|
||||
: undefined
|
||||
}
|
||||
placement="right"
|
||||
>
|
||||
<Button
|
||||
onClick={handleCreateNewChannels}
|
||||
className="create-notification-btn"
|
||||
disabled={!addNewChannelPermission}
|
||||
>
|
||||
<Flex align="center" justify="center">
|
||||
<Plus size="md" />
|
||||
Create a notification channel
|
||||
</Flex>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</FormContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BasicInfo;
|
||||
@@ -1,116 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { StyledCreateChannelOption, StyledSelect } from './styles';
|
||||
|
||||
export interface ChannelSelectProps {
|
||||
disabled?: boolean;
|
||||
currentValue?: string[];
|
||||
onSelectChannels: (s: string[]) => void;
|
||||
onDropdownOpen: () => void;
|
||||
isLoading: boolean;
|
||||
channels: ChannelOption[];
|
||||
hasError: boolean;
|
||||
error: APIError;
|
||||
handleCreateNewChannels: () => void;
|
||||
}
|
||||
|
||||
function ChannelSelect({
|
||||
disabled,
|
||||
currentValue,
|
||||
onSelectChannels,
|
||||
onDropdownOpen,
|
||||
channels,
|
||||
isLoading,
|
||||
hasError,
|
||||
error,
|
||||
handleCreateNewChannels,
|
||||
}: ChannelSelectProps): JSX.Element | null {
|
||||
// init namespace for translations
|
||||
const { t } = useTranslation('alerts');
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const handleChange = (value: string[]): void => {
|
||||
if (value.includes('add-new-channel')) {
|
||||
handleCreateNewChannels();
|
||||
return;
|
||||
}
|
||||
onSelectChannels(value);
|
||||
};
|
||||
|
||||
if (hasError) {
|
||||
notifications.error({
|
||||
message: error?.getErrorCode?.() || 'Error',
|
||||
description: error?.getErrorMessage?.() || 'Something went wrong',
|
||||
});
|
||||
}
|
||||
|
||||
const { canCreate: addNewChannelPermission } =
|
||||
useNotificationChannelCollectionPermissions();
|
||||
|
||||
const renderOptions = (): ReactNode[] => {
|
||||
const children: ReactNode[] = [];
|
||||
|
||||
if (!isLoading && addNewChannelPermission) {
|
||||
children.push(
|
||||
<Select.Option key="add-new-channel" value="add-new-channel">
|
||||
<StyledCreateChannelOption>
|
||||
<Plus size="md" />
|
||||
Create a new channel
|
||||
</StyledCreateChannelOption>
|
||||
</Select.Option>,
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || channels.length === 0) {
|
||||
return children;
|
||||
}
|
||||
|
||||
channels.forEach((o) => {
|
||||
children.push(
|
||||
<Select.Option key={o.id} value={o.name}>
|
||||
{o.name}
|
||||
</Select.Option>,
|
||||
);
|
||||
});
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledSelect
|
||||
disabled={disabled}
|
||||
status={hasError ? 'error' : ''}
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('placeholder_channel_select')}
|
||||
data-testid="alert-channel-select"
|
||||
value={currentValue}
|
||||
notFoundContent={isLoading && <Spin size="small" />}
|
||||
onDropdownVisibleChange={(open): void => {
|
||||
if (open) {
|
||||
onDropdownOpen();
|
||||
}
|
||||
}}
|
||||
onChange={(value): void => {
|
||||
handleChange(value as string[]);
|
||||
}}
|
||||
optionLabelProp="label"
|
||||
>
|
||||
{renderOptions()}
|
||||
</StyledSelect>
|
||||
);
|
||||
}
|
||||
|
||||
ChannelSelect.defaultProps = {
|
||||
disabled: false,
|
||||
currentValue: [],
|
||||
};
|
||||
export default ChannelSelect;
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Select } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const StyledSelect = styled(Select)`
|
||||
border-radius: 4px;
|
||||
`;
|
||||
|
||||
export const StyledCreateChannelOption = styled.div`
|
||||
color: var(--accent-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`;
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
@@ -45,7 +46,6 @@ import { getGraphType } from 'utils/getGraphType';
|
||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import { AlertDetectionTypes } from '..';
|
||||
import ChartContent from './ChartContent';
|
||||
import { ChartContainer } from './styles';
|
||||
import { getThresholds } from './utils';
|
||||
@@ -279,7 +279,7 @@ function ChartPreview({
|
||||
const hasResultData = !!queryResponse?.data?.payload?.data?.result?.length;
|
||||
|
||||
const isAnomalyDetectionAlert =
|
||||
alertDef?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
|
||||
alertDef?.ruleType === RuletypesRuleTypeDTO.anomaly_rule;
|
||||
|
||||
const chartDataAvailable =
|
||||
chartData &&
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
.create-alert-modal {
|
||||
.ant-modal-content {
|
||||
background-color: var(--l3-background);
|
||||
.ant-modal-confirm-title {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.ant-modal-confirm-content {
|
||||
.ant-typography {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-confirm-btns {
|
||||
button:nth-of-type(1) {
|
||||
background-color: var(--l1-border);
|
||||
border: none;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-alert-rules-container {
|
||||
&.create-mode {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.triggered-alerts-container {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.alert-rules-list-container {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.planned-downtime-container {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.steps-container {
|
||||
width: 80%;
|
||||
|
||||
.alert-query-section-container {
|
||||
--query-builder-v2-color: var(--l2-foreground);
|
||||
--query-builder-v2-background-color: var(--l3-background);
|
||||
--query-builder-v2-border-color: var(--l3-border);
|
||||
--query-builder-v2-selection-background-color: var(--l2-background);
|
||||
--query-builder-v2-chip-decorator-background-color: var(--l2-background);
|
||||
--query-builder-v2-placeholder-color: var(--l3-foreground);
|
||||
--query-builder-v2-selected-background-color: var(--l3-foreground);
|
||||
--query-search-background-color: var(--l3-background);
|
||||
--query-search-background-color-selection: var(--l3-background);
|
||||
--input-with-label-border-color: var(--l3-border);
|
||||
--input-with-label-background-color: var(--l3-background);
|
||||
--input-with-label-color: var(--l2-foreground);
|
||||
--query-builder-v2-toggle-group-background-color: var(--l3-background);
|
||||
--query-builder-v2-toggle-group-border-color: var(--l3-border);
|
||||
--query-builder-v2-toggle-group-active-background-color: var(--l2-background);
|
||||
--query-builder-v2-toggle-group-background-color-hover: var(--l3-background);
|
||||
--input-height: 36px;
|
||||
--input-hover-background: var(--l3-background);
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.qb-chart-preview-container {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.overview-header {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.alert-type-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.alert-type-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-preview-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
|
||||
.ant-card {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.detection-method-container {
|
||||
margin: 24px 0;
|
||||
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: 0;
|
||||
|
||||
.ant-tabs-tab {
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detection-method-description {
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-help-btns {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
|
||||
.doc-redirection-btn {
|
||||
color: var(--bg-aqua-500) !important;
|
||||
border-color: var(--bg-aqua-500) !important;
|
||||
}
|
||||
|
||||
.facing-issue-btn {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.create-notification-btn {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.rule-definition {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rule-unit-selector {
|
||||
width: 150px;
|
||||
}
|
||||
@@ -1,525 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Form,
|
||||
InputNumber,
|
||||
InputNumberProps,
|
||||
Select,
|
||||
SelectProps,
|
||||
Space,
|
||||
} from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import {
|
||||
getCategoryByOptionId,
|
||||
getCategorySelectOptionByName,
|
||||
} from 'container/CreateAlertV2/AlertCondition/utils';
|
||||
import {
|
||||
AlertDef,
|
||||
defaultAlgorithm,
|
||||
defaultCompareOp,
|
||||
defaultEvalWindow,
|
||||
defaultFrequency,
|
||||
defaultMatchType,
|
||||
defaultSeasonality,
|
||||
} from 'types/api/alerts/def';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { AlertDetectionTypes } from '.';
|
||||
import {
|
||||
FormContainer,
|
||||
InlineSelect,
|
||||
StepHeading,
|
||||
VerticalLine,
|
||||
} from './styles';
|
||||
|
||||
import './RuleOptions.styles.scss';
|
||||
|
||||
function RuleOptions({
|
||||
alertDef,
|
||||
setAlertDef,
|
||||
queryCategory,
|
||||
queryOptions,
|
||||
yAxisUnit,
|
||||
}: RuleOptionsProps): JSX.Element {
|
||||
// init namespace for translations
|
||||
const { t } = useTranslation('alerts');
|
||||
|
||||
const { ruleType } = alertDef;
|
||||
|
||||
const handleMatchOptChange = (value: string | unknown): void => {
|
||||
const m = (value as string) || alertDef.condition?.matchType;
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
matchType: m,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeSelectedQueryName = (value: string | unknown): void => {
|
||||
if (typeof value !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
selectedQueryName: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderCompareOps = (): JSX.Element => (
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue={defaultCompareOp}
|
||||
value={alertDef.condition?.op}
|
||||
style={{ minWidth: '120px' }}
|
||||
data-testid="alert-threshold-op-select"
|
||||
onChange={(value: string | unknown): void => {
|
||||
const newOp = (value as string) || '';
|
||||
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
op: newOp,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Select.Option value="1">{t('option_above')}</Select.Option>
|
||||
<Select.Option value="2">{t('option_below')}</Select.Option>
|
||||
|
||||
{/* hide equal and not eqaul in case of analmoy based alert */}
|
||||
|
||||
{ruleType !== 'anomaly_rule' && (
|
||||
<>
|
||||
<Select.Option value="3">{t('option_equal')}</Select.Option>
|
||||
<Select.Option value="4">{t('option_notequal')}</Select.Option>
|
||||
</>
|
||||
)}
|
||||
{/* the value 5 and 6 are reserved for above or equal and below or equal */}
|
||||
{ruleType === 'anomaly_rule' && (
|
||||
<Select.Option value="7">{t('option_above_below')}</Select.Option>
|
||||
)}
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
const renderMatchOpts = (): JSX.Element => (
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue={defaultMatchType}
|
||||
style={{ minWidth: '130px' }}
|
||||
value={alertDef.condition?.matchType}
|
||||
data-testid="alert-threshold-match-type-select-v1"
|
||||
onChange={(value: string | unknown): void => handleMatchOptChange(value)}
|
||||
>
|
||||
<Select.Option value="1">{t('option_atleastonce')}</Select.Option>
|
||||
<Select.Option value="2">{t('option_allthetimes')}</Select.Option>
|
||||
|
||||
{ruleType !== 'anomaly_rule' && (
|
||||
<>
|
||||
<Select.Option value="3">{t('option_onaverage')}</Select.Option>
|
||||
<Select.Option value="4">{t('option_intotal')}</Select.Option>
|
||||
<Select.Option value="5">{t('option_last')}</Select.Option>
|
||||
</>
|
||||
)}
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
const onChangeEvalWindow = (value: string | unknown): void => {
|
||||
const ew = (value as string) || alertDef.evalWindow;
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
evalWindow: ew,
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeAlgorithm = (value: string | unknown): void => {
|
||||
const alg = (value as string) || alertDef.condition.algorithm;
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
algorithm: alg,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeSeasonality = (value: string | unknown): void => {
|
||||
const seasonality = (value as string) || alertDef.condition.seasonality;
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
seasonality,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeDeviation = (value: number): void => {
|
||||
const target = value || alertDef.condition.target || 3;
|
||||
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: { ...alertDef.condition, target: Number(target) },
|
||||
});
|
||||
};
|
||||
|
||||
const renderEvalWindows = (): JSX.Element => (
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue={defaultEvalWindow}
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
<Select.Option value="15m0s">{t('option_15min')}</Select.Option>
|
||||
<Select.Option value="1h0m0s">{t('option_60min')}</Select.Option>
|
||||
<Select.Option value="4h0m0s">{t('option_4hours')}</Select.Option>
|
||||
<Select.Option value="24h0m0s">{t('option_24hours')}</Select.Option>
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
const renderPromEvalWindows = (): JSX.Element => (
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue={defaultEvalWindow}
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
<Select.Option value="15m0s">{t('option_15min')}</Select.Option>
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
const renderAlgorithms = (): JSX.Element => (
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue={defaultAlgorithm}
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.condition.algorithm}
|
||||
onChange={onChangeAlgorithm}
|
||||
>
|
||||
<Select.Option value="standard">Standard</Select.Option>
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
const renderDeviationOpts = (): JSX.Element => (
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue={3}
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.condition.target}
|
||||
onChange={(value: number | unknown): void => {
|
||||
if (typeof value === 'number') {
|
||||
onChangeDeviation(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Option value={1}>1</Select.Option>
|
||||
<Select.Option value={2}>2</Select.Option>
|
||||
<Select.Option value={3}>3</Select.Option>
|
||||
<Select.Option value={4}>4</Select.Option>
|
||||
<Select.Option value={5}>5</Select.Option>
|
||||
<Select.Option value={6}>6</Select.Option>
|
||||
<Select.Option value={7}>7</Select.Option>
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
const renderSeasonality = (): JSX.Element => (
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue={defaultSeasonality}
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.condition.seasonality}
|
||||
onChange={onChangeSeasonality}
|
||||
>
|
||||
<Select.Option value="hourly">Hourly</Select.Option>
|
||||
<Select.Option value="daily">Daily</Select.Option>
|
||||
<Select.Option value="weekly">Weekly</Select.Option>
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
const renderThresholdRuleOpts = (): JSX.Element => (
|
||||
<Form.Item>
|
||||
<Typography.Text>
|
||||
{t('text_condition1')}
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
allowClear
|
||||
showSearch
|
||||
options={queryOptions}
|
||||
placeholder={t('selected_query_placeholder')}
|
||||
value={alertDef.condition.selectedQueryName}
|
||||
onChange={onChangeSelectedQueryName}
|
||||
/>
|
||||
<Typography.Text>is</Typography.Text>
|
||||
{renderCompareOps()} {t('text_condition2')} {renderMatchOpts()}{' '}
|
||||
{t('text_condition3')} {renderEvalWindows()}
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
const renderPromRuleOptions = (): JSX.Element => (
|
||||
<Form.Item>
|
||||
<Typography.Text>
|
||||
{t('text_condition1')}
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
allowClear
|
||||
showSearch
|
||||
options={queryOptions}
|
||||
placeholder={t('selected_query_placeholder')}
|
||||
value={alertDef.condition.selectedQueryName}
|
||||
onChange={onChangeSelectedQueryName}
|
||||
/>
|
||||
<Typography.Text>is</Typography.Text>
|
||||
{renderCompareOps()} {t('text_condition2')} {renderMatchOpts()}
|
||||
{t('text_condition3')} {renderPromEvalWindows()}
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
const onChange: InputNumberProps['onChange'] = (value): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
op: alertDef.condition?.op || defaultCompareOp,
|
||||
matchType: alertDef.condition?.matchType || defaultMatchType,
|
||||
target: Number(value) || 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeAlertUnit: SelectProps['onChange'] = (value) => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
targetUnit: value as string,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeFrequency = (value: string | unknown): void => {
|
||||
const freq = (value as string) || alertDef.frequency;
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
frequency: freq,
|
||||
});
|
||||
};
|
||||
|
||||
const renderAnomalyRuleOpts = (): JSX.Element => (
|
||||
<Form.Item>
|
||||
<Typography.Text className="rule-definition">
|
||||
{t('text_condition1_anomaly')}
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
allowClear
|
||||
showSearch
|
||||
options={queryOptions}
|
||||
placeholder={t('selected_query_placeholder')}
|
||||
value={alertDef.condition.selectedQueryName}
|
||||
onChange={onChangeSelectedQueryName}
|
||||
/>
|
||||
{t('text_condition3')} {renderEvalWindows()}
|
||||
<Typography.Text>is</Typography.Text>
|
||||
{renderDeviationOpts()}
|
||||
<Typography.Text>deviations</Typography.Text>
|
||||
{renderCompareOps()}
|
||||
<Typography.Text>the predicted data</Typography.Text>
|
||||
{renderMatchOpts()}
|
||||
using the {renderAlgorithms()} algorithm with {renderSeasonality()}{' '}
|
||||
seasonality
|
||||
</Typography.Text>
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
const renderFrequency = (): JSX.Element => (
|
||||
<InlineSelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue={defaultFrequency}
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.frequency}
|
||||
onChange={onChangeFrequency}
|
||||
>
|
||||
<Select.Option value="1m0s">{t('option_1min')}</Select.Option>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
<Select.Option value="15m0s">{t('option_15min')}</Select.Option>
|
||||
<Select.Option value="30m0s">{t('option_30min')}</Select.Option>
|
||||
<Select.Option value="1h0m0s">{t('option_60min')}</Select.Option>
|
||||
<Select.Option value="3h0m0s">{t('option_3hours')}</Select.Option>
|
||||
<Select.Option value="6h0m0s">{t('option_6hours')}</Select.Option>
|
||||
<Select.Option value="12h0m0s">{t('option_12hours')}</Select.Option>
|
||||
<Select.Option value="24h0m0s">{t('option_24hours')}</Select.Option>
|
||||
</InlineSelect>
|
||||
);
|
||||
|
||||
const selectedCategory = getCategoryByOptionId(yAxisUnit);
|
||||
|
||||
const categorySelectOptions = getCategorySelectOptionByName(selectedCategory);
|
||||
|
||||
const step3Label = alertDef.alertType === 'METRIC_BASED_ALERT' ? '3' : '2';
|
||||
|
||||
return (
|
||||
<>
|
||||
<StepHeading>{t('alert_form_step3', { step: step3Label })}</StepHeading>
|
||||
<FormContainer>
|
||||
{queryCategory === EQueryType.PROM && renderPromRuleOptions()}
|
||||
{queryCategory !== EQueryType.PROM &&
|
||||
ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT && (
|
||||
<>{renderAnomalyRuleOpts()}</>
|
||||
)}
|
||||
|
||||
{queryCategory !== EQueryType.PROM &&
|
||||
ruleType === AlertDetectionTypes.THRESHOLD_ALERT &&
|
||||
renderThresholdRuleOpts()}
|
||||
|
||||
<Space direction="vertical" size="large">
|
||||
{ruleType !== AlertDetectionTypes.ANOMALY_DETECTION_ALERT && (
|
||||
<Space direction="horizontal" align="center">
|
||||
<Form.Item noStyle>
|
||||
<InputNumber
|
||||
addonBefore={t('field_threshold')}
|
||||
value={alertDef?.condition?.target}
|
||||
onChange={onChange}
|
||||
type="number"
|
||||
data-testid="alert-threshold-target-input"
|
||||
onWheel={(e): void => e.currentTarget.blur()}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item noStyle>
|
||||
<Select
|
||||
className="rule-unit-selector"
|
||||
getPopupContainer={popupContainer}
|
||||
allowClear
|
||||
showSearch
|
||||
options={categorySelectOptions}
|
||||
placeholder={t('field_unit')}
|
||||
value={alertDef.condition.targetUnit}
|
||||
onChange={onChangeAlertUnit}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Collapse>
|
||||
<Collapse.Panel header={t('More options')} key="1">
|
||||
<Space direction="vertical" size="large">
|
||||
<VerticalLine>
|
||||
<Space direction="horizontal" align="center">
|
||||
<Typography.Text>{t('text_alert_frequency')}</Typography.Text>
|
||||
{renderFrequency()}
|
||||
</Space>
|
||||
</VerticalLine>
|
||||
|
||||
<VerticalLine>
|
||||
<Space direction="horizontal" align="center">
|
||||
<Form.Item noStyle name={['condition', 'alertOnAbsent']}>
|
||||
<Checkbox
|
||||
checked={alertDef?.condition?.alertOnAbsent}
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
alertOnAbsent: e.target.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Typography.Text>{t('text_alert_on_absent')}</Typography.Text>
|
||||
|
||||
<Form.Item noStyle name={['condition', 'absentFor']}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={alertDef?.condition?.absentFor}
|
||||
onChange={(value): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
absentFor: Number(value) || 0,
|
||||
},
|
||||
});
|
||||
}}
|
||||
type="number"
|
||||
onWheel={(e): void => e.currentTarget.blur()}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Typography.Text>{t('text_for')}</Typography.Text>
|
||||
</Space>
|
||||
</VerticalLine>
|
||||
|
||||
<VerticalLine>
|
||||
<Space direction="horizontal" align="center">
|
||||
<Form.Item noStyle name={['condition', 'requireMinPoints']}>
|
||||
<Checkbox
|
||||
checked={alertDef?.condition?.requireMinPoints}
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
requireMinPoints: e.target.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Typography.Text>{t('text_require_min_points')}</Typography.Text>
|
||||
|
||||
<Form.Item noStyle name={['condition', 'requiredNumPoints']}>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={alertDef?.condition?.requiredNumPoints}
|
||||
onChange={(value): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
condition: {
|
||||
...alertDef.condition,
|
||||
requiredNumPoints: Number(value) || 0,
|
||||
},
|
||||
});
|
||||
}}
|
||||
type="number"
|
||||
onWheel={(e): void => e.currentTarget.blur()}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Typography.Text>{t('text_num_points')}</Typography.Text>
|
||||
</Space>
|
||||
</VerticalLine>
|
||||
</Space>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Space>
|
||||
</FormContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface RuleOptionsProps {
|
||||
alertDef: AlertDef;
|
||||
setAlertDef: (a: AlertDef) => void;
|
||||
queryCategory: EQueryType;
|
||||
queryOptions: DefaultOptionType[];
|
||||
yAxisUnit: string;
|
||||
}
|
||||
export default RuleOptions;
|
||||
@@ -1,125 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { usePrefillAlertConditions } from '../usePrefillAlertConditions';
|
||||
|
||||
const TEST_MAPPINGS = {
|
||||
op: {
|
||||
'>': '1',
|
||||
'<': '2',
|
||||
'=': '3',
|
||||
},
|
||||
matchType: {
|
||||
avg: '3',
|
||||
sum: '4',
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('lib/router/useAppLocation', () => {
|
||||
const mockThreshold1 = {
|
||||
index: '0d11f426-a02e-48da-867c-b79c6ef1ff06',
|
||||
isEditEnabled: false,
|
||||
keyIndex: 1,
|
||||
selectedGraph: 'graph',
|
||||
thresholdColor: 'Orange',
|
||||
thresholdFormat: 'Text',
|
||||
thresholdLabel: 'Caution',
|
||||
thresholdOperator: '>',
|
||||
thresholdTableOptions: 'A',
|
||||
thresholdUnit: 'rpm',
|
||||
thresholdValue: 800,
|
||||
};
|
||||
const mockThreshold2 = {
|
||||
index: 'edbe8ef2-fa54-4cb9-b343-7afe883bb714',
|
||||
isEditEnabled: false,
|
||||
keyIndex: 0,
|
||||
selectedGraph: 'graph',
|
||||
thresholdColor: 'Red',
|
||||
thresholdFormat: 'Text',
|
||||
thresholdLabel: 'Danger',
|
||||
thresholdOperator: '<',
|
||||
thresholdTableOptions: 'A',
|
||||
thresholdUnit: 'rpm',
|
||||
thresholdValue: 900,
|
||||
};
|
||||
return {
|
||||
useAppLocation: jest.fn().mockReturnValue({
|
||||
state: {
|
||||
thresholds: [mockThreshold1, mockThreshold2],
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mockStagedQuery = {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('usePrefillAlertConditions', () => {
|
||||
it('returns the correct matchType for a single query', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePrefillAlertConditions(mockStagedQuery as any),
|
||||
);
|
||||
expect(result.current.matchType).toBe(TEST_MAPPINGS.matchType.avg);
|
||||
});
|
||||
|
||||
it('returns null matchType for a single query with unsupported time aggregation', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePrefillAlertConditions({
|
||||
builder: { queryData: [{ reduceTo: 'p90' }] },
|
||||
} as any),
|
||||
);
|
||||
expect(result.current.matchType).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the correct matchType for multiple queries with same time aggregation', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePrefillAlertConditions({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
{
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
expect(result.current.matchType).toBe(TEST_MAPPINGS.matchType.avg);
|
||||
});
|
||||
|
||||
it('returns null matchType for multiple queries with different time aggregation', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePrefillAlertConditions({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
{
|
||||
reduceTo: ReduceOperators.SUM,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any),
|
||||
);
|
||||
expect(result.current.matchType).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the correct op, target, targetUnit from the higher priority threshold for multiple thresholds', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePrefillAlertConditions(mockStagedQuery as any),
|
||||
);
|
||||
expect(result.current.op).toBe(TEST_MAPPINGS.op['<']);
|
||||
expect(result.current.target).toBe(900);
|
||||
expect(result.current.targetUnit).toBe('rpm');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,210 +0,0 @@
|
||||
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CircleAlert, CircleX } from '@signozhq/icons';
|
||||
import { Button, Input, message, Modal } from 'antd';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { map } from 'lodash-es';
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Pill } from '@signozhq/ui/pill';
|
||||
import { QueryChipContainer, QueryChipItem, SearchContainer } from './styles';
|
||||
import { ILabelRecord } from './types';
|
||||
import { createQuery, flattenLabels, prepareLabels } from './utils';
|
||||
|
||||
type LabelStep = 'Idle' | 'LabelKey' | 'LabelValue';
|
||||
type LabelEvent = 'NEXT' | 'onBlur' | 'RESET';
|
||||
|
||||
interface LabelSelectProps {
|
||||
onSetLabels: (q: Labels) => void;
|
||||
initialValues: Labels | undefined;
|
||||
}
|
||||
|
||||
function LabelSelect({
|
||||
onSetLabels,
|
||||
initialValues,
|
||||
}: LabelSelectProps): JSX.Element | null {
|
||||
const { t } = useTranslation('alerts');
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const [currentVal, setCurrentVal] = useState('');
|
||||
const [staging, setStaging] = useState<string[]>([]);
|
||||
const [queries, setQueries] = useState<ILabelRecord[]>(
|
||||
initialValues ? flattenLabels(initialValues) : [],
|
||||
);
|
||||
const [step, setStep] = useState<LabelStep>('Idle');
|
||||
|
||||
const dispatchChanges = (updatedRecs: ILabelRecord[]): void => {
|
||||
onSetLabels(prepareLabels(updatedRecs, initialValues));
|
||||
setQueries(updatedRecs);
|
||||
};
|
||||
|
||||
const onSelectLabelValue = (): void => {
|
||||
if (currentVal !== '') {
|
||||
setStaging((prevState) => [...prevState, currentVal]);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
setCurrentVal('');
|
||||
};
|
||||
|
||||
const onValidateQuery = (): void => {
|
||||
if (currentVal === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const generatedQuery = createQuery([...staging, currentVal]);
|
||||
|
||||
if (generatedQuery) {
|
||||
dispatchChanges([...queries, generatedQuery]);
|
||||
setStaging([]);
|
||||
setCurrentVal('');
|
||||
setStep('Idle');
|
||||
}
|
||||
};
|
||||
|
||||
const send = (event: LabelEvent): void => {
|
||||
if (event === 'RESET') {
|
||||
setStep('Idle');
|
||||
return;
|
||||
}
|
||||
if (event === 'NEXT') {
|
||||
if (step === 'Idle') {
|
||||
setStep('LabelKey');
|
||||
} else if (step === 'LabelKey') {
|
||||
onSelectLabelValue();
|
||||
setStep('LabelValue');
|
||||
} else if (step === 'LabelValue') {
|
||||
onValidateQuery();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event === 'onBlur') {
|
||||
if (step === 'LabelKey') {
|
||||
onSelectLabelValue();
|
||||
setStep('LabelValue');
|
||||
} else if (step === 'LabelValue') {
|
||||
onValidateQuery();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = (): void => {
|
||||
if (step === 'Idle') {
|
||||
send('NEXT');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = useCallback((): void => {
|
||||
if (staging.length === 1 && staging[0] !== undefined) {
|
||||
send('onBlur');
|
||||
}
|
||||
}, [staging]);
|
||||
|
||||
useEffect(() => {
|
||||
handleBlur();
|
||||
}, [handleBlur]);
|
||||
|
||||
const handleLabelChange = (event: ChangeEvent<HTMLInputElement>): void => {
|
||||
// Remove the colon if it's the last character.
|
||||
// As the colon is used to separate the key and value in the query.
|
||||
setCurrentVal(
|
||||
event.target?.value.endsWith(':')
|
||||
? event.target?.value.slice(0, -1)
|
||||
: event.target?.value,
|
||||
);
|
||||
};
|
||||
|
||||
const handleClose = (key: string): void => {
|
||||
dispatchChanges(queries.filter((queryData) => queryData.key !== key));
|
||||
};
|
||||
|
||||
const handleClearAll = (): void => {
|
||||
Modal.confirm({
|
||||
title: 'Confirm',
|
||||
icon: <CircleAlert size="md" />,
|
||||
content: t('remove_label_confirm'),
|
||||
onOk() {
|
||||
send('RESET');
|
||||
dispatchChanges([]);
|
||||
setStaging([]);
|
||||
message.success(t('remove_label_success'));
|
||||
},
|
||||
okText: t('button_yes'),
|
||||
cancelText: t('button_no'),
|
||||
});
|
||||
};
|
||||
const renderPlaceholder = useCallback((): string => {
|
||||
if (step === 'LabelKey') {
|
||||
return 'Enter a label key then press ENTER.';
|
||||
}
|
||||
if (step === 'LabelValue') {
|
||||
return `Enter a value for label key(${staging[0]}) then press ENTER.`;
|
||||
}
|
||||
return t('placeholder_label_key_pair');
|
||||
}, [t, step, staging]);
|
||||
return (
|
||||
<SearchContainer isDarkMode={isDarkMode} disabled={false}>
|
||||
<div style={{ display: 'inline-flex', flexWrap: 'wrap' }}>
|
||||
{queries.length > 0 &&
|
||||
map(queries, (query): JSX.Element => {
|
||||
const isClosable =
|
||||
query.key !== 'severity' && query.key !== 'description';
|
||||
return (
|
||||
<QueryChipContainer key={query.key}>
|
||||
{isClosable ? (
|
||||
<Pill.Closeable
|
||||
closeAriaLabel={`Remove ${query.key}`}
|
||||
onClose={(): void => handleClose(query.key)}
|
||||
>
|
||||
{query.key}: {query.value}
|
||||
</Pill.Closeable>
|
||||
) : (
|
||||
<Badge textTransform="none" variant="solid" color="secondary">
|
||||
{query.key}: {query.value}
|
||||
</Badge>
|
||||
)}
|
||||
</QueryChipContainer>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{map(staging, (item) => (
|
||||
<QueryChipItem key={uuid()}>{item}</QueryChipItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<Input
|
||||
data-testid="alert-labels-input-v1"
|
||||
placeholder={renderPlaceholder()}
|
||||
onChange={handleLabelChange}
|
||||
onKeyUp={(e): void => {
|
||||
if (e.key === 'Enter' || e.code === 'Enter' || e.key === ':') {
|
||||
send('NEXT');
|
||||
}
|
||||
if (step === 'Idle') {
|
||||
send('NEXT');
|
||||
}
|
||||
}}
|
||||
bordered={false}
|
||||
value={currentVal as never}
|
||||
style={{ flex: 1 }}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
{queries.length > 0 || staging.length > 0 || currentVal ? (
|
||||
<Button
|
||||
onClick={handleClearAll}
|
||||
icon={<CircleX size="md" />}
|
||||
type="text"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</SearchContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default LabelSelect;
|
||||
@@ -1,41 +0,0 @@
|
||||
import { grey } from '@ant-design/colors';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface SearchContainerProps {
|
||||
isDarkMode: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const SearchContainer = styled.div<SearchContainerProps>`
|
||||
border-radius: 4px;
|
||||
background: ${({ isDarkMode }): string => (isDarkMode ? '#000' : '#fff')};
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.2rem;
|
||||
border: 1px solid #ccc5;
|
||||
${({ disabled }): string => (disabled ? `cursor: not-allowed;` : '')}
|
||||
`;
|
||||
|
||||
export const QueryChipContainer = styled.span`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 0.5rem;
|
||||
&:hover {
|
||||
& > * {
|
||||
background: ${grey.primary}44;
|
||||
}
|
||||
}
|
||||
[data-slot='badge'] {
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const QueryChipItem = styled(Badge).attrs({
|
||||
color: 'secondary' as const,
|
||||
variant: 'solid' as const,
|
||||
textTransform: 'none' as const,
|
||||
})`
|
||||
margin-right: 0.1rem;
|
||||
`;
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface ILabelRecord {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface IOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
|
||||
import { ILabelRecord } from './types';
|
||||
|
||||
const hiddenLabels = ['severity', 'description'];
|
||||
|
||||
export const createQuery = (
|
||||
selectedItems: Array<string | string[]> = [],
|
||||
): ILabelRecord | null => {
|
||||
if (selectedItems.length === 2) {
|
||||
return {
|
||||
key: selectedItems[0] as string,
|
||||
value: selectedItems[1] as string,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const flattenLabels = (labels: Labels): ILabelRecord[] => {
|
||||
const recs: ILabelRecord[] = [];
|
||||
|
||||
Object.keys(labels).forEach((key) => {
|
||||
if (!hiddenLabels.includes(key)) {
|
||||
recs.push({
|
||||
key,
|
||||
value: labels[key] || '',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return recs;
|
||||
};
|
||||
|
||||
export const prepareLabels = (
|
||||
recs: ILabelRecord[],
|
||||
alertLabels: Labels | undefined,
|
||||
): Labels => {
|
||||
const labels: Labels = {};
|
||||
|
||||
recs.forEach((rec) => {
|
||||
if (!hiddenLabels.includes(rec.key)) {
|
||||
labels[rec.key] = rec.value;
|
||||
}
|
||||
});
|
||||
if (alertLabels) {
|
||||
Object.keys(alertLabels).forEach((key) => {
|
||||
if (hiddenLabels.includes(key)) {
|
||||
labels[key] = alertLabels[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return labels;
|
||||
};
|
||||
@@ -1,80 +1,11 @@
|
||||
import { Button, Card, Col, Form, Input, Select } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Card } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Item } = Form;
|
||||
|
||||
export const StyledLeftContainer = styled(Col)`
|
||||
&&& {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const MainFormContainer = styled(Form)``;
|
||||
|
||||
export const ButtonContainer = styled.div`
|
||||
&&& {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ActionButton = styled(Button)`
|
||||
margin-right: 1rem;
|
||||
`;
|
||||
|
||||
export const QueryButton = styled(Button)`
|
||||
&&& {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const QueryContainer = styled(Card)`
|
||||
&&& {
|
||||
margin-top: 1rem;
|
||||
min-height: 23.5%;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Container = styled.div`
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const StepHeading = styled.p`
|
||||
margin-top: 1rem;
|
||||
font-weight: bold;
|
||||
`;
|
||||
|
||||
export const InlineSelect = styled(Select)`
|
||||
display: inline-block;
|
||||
width: 10% !important;
|
||||
margin-left: 0.3em;
|
||||
margin-right: 0.3em;
|
||||
`;
|
||||
|
||||
export const SeveritySelect = styled(Select)`
|
||||
width: 25% !important;
|
||||
`;
|
||||
|
||||
export const VerticalLine = styled.div`
|
||||
border-left: 2px solid #e8e8e8; /* Adjust color and thickness as desired */
|
||||
padding-left: 20px; /* Adjust spacing to content as needed */
|
||||
margin-left: 20px; /* Adjust margin as desired */
|
||||
height: 100%; /* Adjust based on your layout needs */
|
||||
`;
|
||||
|
||||
export const InputSmall = styled(Input)`
|
||||
width: 40% !important;
|
||||
`;
|
||||
|
||||
export const FormContainer = styled(Card)`
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
@@ -85,19 +16,3 @@ export const FormContainer = styled(Card)`
|
||||
padding: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TextareaMedium = styled(TextArea)`
|
||||
width: 70%;
|
||||
`;
|
||||
|
||||
export const FormItemMedium = styled(Item)`
|
||||
width: 70%;
|
||||
`;
|
||||
|
||||
export const ChannelSelectTip = styled(Typography.Text)`
|
||||
color: hsla(0, 0%, 100%, 0.3);
|
||||
`;
|
||||
|
||||
export const StepContainer = styled.div`
|
||||
margin-top: 2rem;
|
||||
`;
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { ThresholdProps } from 'types/api/widgets/threshold';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
const THRESHOLD_COLORS_SORTING_ORDER = ['Red', 'Orange', 'Green', 'Blue'];
|
||||
|
||||
export const usePrefillAlertConditions = (
|
||||
stagedQuery: Query | null,
|
||||
): {
|
||||
matchType: string | null;
|
||||
op: string | null;
|
||||
target: number | undefined;
|
||||
targetUnit: string | undefined;
|
||||
} => {
|
||||
const location = useAppLocation();
|
||||
|
||||
// Extract and set match type
|
||||
const reduceTo = useMemo(() => {
|
||||
if (!stagedQuery) {
|
||||
return null;
|
||||
}
|
||||
const isSameTimeAggregation = stagedQuery.builder.queryData.every(
|
||||
(queryData) =>
|
||||
queryData?.reduceTo === stagedQuery.builder.queryData[0]?.reduceTo,
|
||||
);
|
||||
return isSameTimeAggregation
|
||||
? stagedQuery.builder.queryData[0]?.reduceTo
|
||||
: null;
|
||||
}, [stagedQuery]);
|
||||
|
||||
const matchType = useMemo(() => {
|
||||
switch (reduceTo) {
|
||||
case 'avg':
|
||||
return '3';
|
||||
case 'sum':
|
||||
return '4';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [reduceTo]);
|
||||
|
||||
// Extract and set threshold operator, value and unit
|
||||
const threshold = useMemo(() => {
|
||||
const { thresholds } = (location.state as {
|
||||
thresholds: ThresholdProps[];
|
||||
}) || {
|
||||
thresholds: null,
|
||||
};
|
||||
if (!thresholds || thresholds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sortedThresholds = thresholds.sort((a, b) => {
|
||||
const aIndex = THRESHOLD_COLORS_SORTING_ORDER.indexOf(
|
||||
a.thresholdColor || '',
|
||||
);
|
||||
const bIndex = THRESHOLD_COLORS_SORTING_ORDER.indexOf(
|
||||
b.thresholdColor || '',
|
||||
);
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
return sortedThresholds[0];
|
||||
}, [location.state]);
|
||||
|
||||
const thresholdOperator = useMemo(() => {
|
||||
const op = threshold?.thresholdOperator;
|
||||
switch (op) {
|
||||
case '>':
|
||||
case '>=':
|
||||
return '1';
|
||||
case '<':
|
||||
case '<=':
|
||||
return '2';
|
||||
case '=':
|
||||
return '3';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [threshold]);
|
||||
|
||||
const thresholdUnit = useMemo(() => threshold?.thresholdUnit, [threshold]);
|
||||
|
||||
const thresholdValue = useMemo(() => threshold?.thresholdValue, [threshold]);
|
||||
|
||||
return {
|
||||
matchType,
|
||||
op: thresholdOperator,
|
||||
target: thresholdValue,
|
||||
targetUnit: thresholdUnit,
|
||||
};
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
// Write a test for getUpdatedStepInterval function in src/container/FormAlertRules/utils.ts
|
||||
|
||||
import { getUpdatedStepInterval } from './utils';
|
||||
|
||||
describe('getUpdatedStepInterval', () => {
|
||||
it('should return 60', () => {
|
||||
const result = getUpdatedStepInterval('5m0s');
|
||||
expect(result).toBe(60);
|
||||
});
|
||||
it('should return 60 for 10m0s', () => {
|
||||
const result = getUpdatedStepInterval('10m0s');
|
||||
expect(result).toBe(60);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,4 @@
|
||||
import { SelectProps } from 'antd';
|
||||
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
||||
import getStep from 'lib/getStep';
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
@@ -10,48 +7,6 @@ import {
|
||||
IPromQLQuery,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
// toChartInterval converts eval window to chart selection time interval
|
||||
export const toChartInterval = (evalWindow: string | undefined): Time => {
|
||||
switch (evalWindow) {
|
||||
case '1m0s':
|
||||
return '1m';
|
||||
case '5m0s':
|
||||
return '5m';
|
||||
case '10m0s':
|
||||
return '10m';
|
||||
case '15m0s':
|
||||
return '15m';
|
||||
case '30m0s':
|
||||
return '30m';
|
||||
case '1h0m0s':
|
||||
return '1h';
|
||||
case '3h0m0s':
|
||||
return '3h';
|
||||
case '4h0m0s':
|
||||
return '4h';
|
||||
case '6h0m0s':
|
||||
return '6h';
|
||||
case '12h0m0s':
|
||||
return '12h';
|
||||
case '24h0m0s':
|
||||
return '1d';
|
||||
default:
|
||||
return '5m';
|
||||
}
|
||||
};
|
||||
|
||||
export const getUpdatedStepInterval = (evalWindow?: string): number => {
|
||||
const { start, end } = getStartEndRangeTime({
|
||||
type: 'GLOBAL_TIME',
|
||||
interval: toChartInterval(evalWindow),
|
||||
});
|
||||
return getStep({
|
||||
start,
|
||||
end,
|
||||
inputFormat: 'ns',
|
||||
});
|
||||
};
|
||||
|
||||
export const getSelectedQueryOptions = (
|
||||
queries:
|
||||
| Array<
|
||||
|
||||
@@ -4,8 +4,7 @@ import {
|
||||
InframonitoringtypesClusterRecordDTO,
|
||||
InframonitoringtypesNodeConditionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
|
||||
|
||||
import ColumnHeader from '../Base/ColumnHeader';
|
||||
|
||||
@@ -12,9 +12,11 @@ import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
|
||||
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
|
||||
import { formatBytes, getPodStatusItems } from '../commonUtils';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import { TextNoData, ValidateColumnValueWrapper } from '../components';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { NODE_CONDITION_COLORS, NODE_CONDITION_LABELS } from './utils';
|
||||
import { Workflow } from '@signozhq/icons';
|
||||
|
||||
|
||||
@@ -146,13 +146,114 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
|
||||
await expect(screen.findByText('promql/*')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hardcodes the key and does not let it be edited', async () => {
|
||||
it('defaults the key to signoz.workspace.key.id', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const keyInput = screen.getByTestId('wizard-key-input-logs-read');
|
||||
expect(keyInput).toHaveValue('signoz.workspace.key.id');
|
||||
expect(keyInput).toBeDisabled();
|
||||
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
|
||||
'signoz.workspace.key.id',
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites the selector when another key is picked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId('wizard-value-input-logs-read'),
|
||||
'checkout',
|
||||
);
|
||||
await user.click(screen.getByTestId('wizard-key-select-logs-read'));
|
||||
await user.click(
|
||||
await screen.findByTestId(
|
||||
'wizard-key-option-resource.service.name-logs-read',
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
|
||||
'builder_query/resource.service.name/checkout',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('builder_query/resource.service.name/checkout'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('folds the resource prefix of the workspace key from a typed selector', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(
|
||||
selectorInput,
|
||||
'builder_query/resource.signoz.workspace.key.id/key-a',
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
|
||||
'signoz.workspace.key.id',
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '2');
|
||||
|
||||
expect(selectorInput).toHaveValue(
|
||||
'builder_query/signoz.workspace.key.id/key-a2',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts the resource-prefixed workspace key in the hint', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(
|
||||
selectorInput,
|
||||
'builder_query/resource.signoz.workspace.key.id/key-a',
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent(
|
||||
'Allow signoz.workspace.key.id=key-a for Builder Query queries.',
|
||||
);
|
||||
});
|
||||
|
||||
it('selects the key from a typed selector', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(
|
||||
selectorInput,
|
||||
'builder_query/resource.deployment.environment.name/prod',
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
|
||||
'resource.deployment.environment.name',
|
||||
);
|
||||
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue(
|
||||
'prod',
|
||||
);
|
||||
});
|
||||
|
||||
it('hints when a typed selector uses an unsupported key', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'builder_query/host.name/web-1');
|
||||
|
||||
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
|
||||
'signoz.workspace.key.id',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent('"host.name" is not a supported key.');
|
||||
});
|
||||
|
||||
it('hides Key field for query types that do not support key scoping', async () => {
|
||||
@@ -163,7 +264,7 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
|
||||
await user.click(await screen.findByText('ClickHouse SQL'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('wizard-key-input-logs-read'),
|
||||
screen.queryByTestId('wizard-key-select-logs-read'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -288,38 +389,18 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
|
||||
expect(screen.getByRole('checkbox', { name: 'Any value' })).toBeChecked();
|
||||
});
|
||||
|
||||
it('keeps the key input hardcoded when the selector uses another key', async () => {
|
||||
it('restores the selected key in the selector once the value changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'builder_query/service.name/frontend');
|
||||
|
||||
expect(screen.getByTestId('wizard-key-input-logs-read')).toHaveValue(
|
||||
'signoz.workspace.key.id',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('wizard-selector-hint-logs-read'),
|
||||
).toHaveTextContent('Allow service.name=frontend for Builder Query queries.');
|
||||
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('restores the hardcoded key in the selector once the value changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogsWizard(user);
|
||||
|
||||
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
|
||||
await user.clear(selectorInput);
|
||||
await user.type(selectorInput, 'builder_query/service.name/frontend');
|
||||
await user.type(selectorInput, 'builder_query/host.name/web-1');
|
||||
|
||||
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '2');
|
||||
|
||||
expect(selectorInput).toHaveValue(
|
||||
'builder_query/signoz.workspace.key.id/frontend2',
|
||||
'builder_query/signoz.workspace.key.id/web-12',
|
||||
);
|
||||
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
|
||||
@@ -30,17 +30,26 @@ export const QUERY_TYPES: readonly QueryTypeOption[] = [
|
||||
|
||||
export const DEFAULT_QUERY_TYPE: QueryTypeId = 'builder_query';
|
||||
|
||||
export const SUPPORTED_GRANT_KEY = 'signoz.workspace.key.id';
|
||||
// mirrors telemetryGrantKeys in pkg/types/telemetrytypes/selector.go
|
||||
export const SUPPORTED_GRANT_KEYS: readonly string[] = [
|
||||
'signoz.workspace.key.id',
|
||||
'resource.service.name',
|
||||
'resource.deployment.environment.name',
|
||||
];
|
||||
|
||||
export const DEFAULT_GRANT_KEY = 'signoz.workspace.key.id';
|
||||
|
||||
export const ANY_RESOURCE_VALUE = '*';
|
||||
|
||||
export interface SelectorDraft {
|
||||
queryType: QueryTypeId;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ParsedSelector {
|
||||
queryType?: QueryTypeId;
|
||||
key?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
ANY_RESOURCE_VALUE,
|
||||
QUERY_TYPES,
|
||||
SUPPORTED_GRANT_KEY,
|
||||
SUPPORTED_GRANT_KEYS,
|
||||
} from './TelemetrySelectorWizard.constants';
|
||||
import { isQueryTypeAvailable } from './TelemetrySelectorWizard.utils';
|
||||
import useTelemetrySelectorWizard from './useTelemetrySelectorWizard';
|
||||
@@ -38,6 +38,7 @@ function TelemetrySelectorWizard({
|
||||
open,
|
||||
queryType,
|
||||
selectedQueryType,
|
||||
grantKey,
|
||||
value,
|
||||
selector,
|
||||
isAnyResource,
|
||||
@@ -46,6 +47,7 @@ function TelemetrySelectorWizard({
|
||||
canAdd,
|
||||
handleOpenChange,
|
||||
handleQueryTypeChange,
|
||||
handleKeyChange,
|
||||
handleValueChange,
|
||||
handleAnyResourceChange,
|
||||
handleSelectorChange,
|
||||
@@ -130,12 +132,22 @@ function TelemetrySelectorWizard({
|
||||
<Typography as="label" weight="medium">
|
||||
Key
|
||||
</Typography>
|
||||
<Input
|
||||
value={SUPPORTED_GRANT_KEY}
|
||||
readOnly
|
||||
disabled
|
||||
testId={`wizard-key-input-${testId}`}
|
||||
/>
|
||||
<Select value={grantKey} onChange={handleKeyChange}>
|
||||
<SelectTrigger data-testid={`wizard-key-select-${testId}`}>
|
||||
<SelectValue>{grantKey}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent withPortal={false} className={styles.selectContent}>
|
||||
{SUPPORTED_GRANT_KEYS.map((supportedKey) => (
|
||||
<SelectItem
|
||||
key={supportedKey}
|
||||
value={supportedKey}
|
||||
testId={`wizard-key-option-${supportedKey}-${testId}`}
|
||||
>
|
||||
{supportedKey}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@ import { AuthZResource } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
import {
|
||||
ANY_RESOURCE_VALUE,
|
||||
DEFAULT_GRANT_KEY,
|
||||
ParsedSelector,
|
||||
QUERY_TYPES,
|
||||
QueryTypeId,
|
||||
QueryTypeOption,
|
||||
SelectorDraft,
|
||||
SelectorValidation,
|
||||
SUPPORTED_GRANT_KEY,
|
||||
SUPPORTED_GRANT_KEYS,
|
||||
} from './TelemetrySelectorWizard.constants';
|
||||
|
||||
const METRIC_RESOURCES: ReadonlySet<AuthZResource> = new Set<AuthZResource>([
|
||||
@@ -37,6 +38,20 @@ export function isAnyResourceValue(value: string): boolean {
|
||||
return value.trim() === ANY_RESOURCE_VALUE;
|
||||
}
|
||||
|
||||
export function isSupportedGrantKey(key: string): boolean {
|
||||
return SUPPORTED_GRANT_KEYS.includes(key);
|
||||
}
|
||||
|
||||
const RESOURCE_PREFIX = 'resource.';
|
||||
|
||||
// the backend folds resource.<key> to <key> when <key> is itself supported
|
||||
function foldGrantKey(key: string): string {
|
||||
const stripped = key.startsWith(RESOURCE_PREFIX)
|
||||
? key.slice(RESOURCE_PREFIX.length)
|
||||
: key;
|
||||
return isSupportedGrantKey(stripped) ? stripped : key;
|
||||
}
|
||||
|
||||
function splitSelector(selector: string): string[] {
|
||||
const parts = selector.split('/');
|
||||
|
||||
@@ -47,14 +62,18 @@ function splitSelector(selector: string): string[] {
|
||||
return [parts[0], parts[1], parts.slice(2).join('/')];
|
||||
}
|
||||
|
||||
export function buildSelector({ queryType, value }: SelectorDraft): string {
|
||||
export function buildSelector({
|
||||
queryType,
|
||||
key,
|
||||
value,
|
||||
}: SelectorDraft): string {
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (!supportsKeyScoping(queryType) || !trimmedValue) {
|
||||
return `${queryType}/${ANY_RESOURCE_VALUE}`;
|
||||
}
|
||||
|
||||
return `${queryType}/${SUPPORTED_GRANT_KEY}/${trimmedValue}`;
|
||||
return `${queryType}/${key}/${trimmedValue}`;
|
||||
}
|
||||
|
||||
export function parseSelector(selector: string): ParsedSelector {
|
||||
@@ -62,6 +81,7 @@ export function parseSelector(selector: string): ParsedSelector {
|
||||
|
||||
return {
|
||||
queryType: getQueryTypeOption(parts[0])?.id,
|
||||
key: parts.length >= 3 ? foldGrantKey(parts[1]) : undefined,
|
||||
value: parts.length >= 3 ? parts[2] : '',
|
||||
};
|
||||
}
|
||||
@@ -101,7 +121,9 @@ export function validateSelector(selector: string): SelectorValidation {
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Use <query-type>/${ANY_RESOURCE_VALUE} or <query-type>/${SUPPORTED_GRANT_KEY}/<value>.`,
|
||||
message: `Use <query-type>/${ANY_RESOURCE_VALUE} or <query-type>/<key>/<value> with one of: ${SUPPORTED_GRANT_KEYS.join(
|
||||
', ',
|
||||
)}.`,
|
||||
isError: false, // intentionally not an error
|
||||
};
|
||||
}
|
||||
@@ -112,7 +134,8 @@ export function validateSelector(selector: string): SelectorValidation {
|
||||
};
|
||||
}
|
||||
|
||||
const [, key, value] = parts;
|
||||
const [, rawKey, value] = parts;
|
||||
const key = foldGrantKey(rawKey);
|
||||
|
||||
if (value === ANY_RESOURCE_VALUE) {
|
||||
return {
|
||||
@@ -128,6 +151,15 @@ export function validateSelector(selector: string): SelectorValidation {
|
||||
};
|
||||
}
|
||||
|
||||
if (!isSupportedGrantKey(key)) {
|
||||
return {
|
||||
message: `"${key}" is not a supported key. Use one of: ${SUPPORTED_GRANT_KEYS.join(
|
||||
', ',
|
||||
)}.`,
|
||||
isError: false, // intentionally not an error
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Allow ${key}=${value} for ${option.label} queries.`,
|
||||
isError: false,
|
||||
@@ -135,5 +167,5 @@ export function validateSelector(selector: string): SelectorValidation {
|
||||
}
|
||||
|
||||
export function getDefaultSelector(queryType: QueryTypeId): string {
|
||||
return buildSelector({ queryType, value: '' });
|
||||
return buildSelector({ queryType, key: DEFAULT_GRANT_KEY, value: '' });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
ANY_RESOURCE_VALUE,
|
||||
DEFAULT_GRANT_KEY,
|
||||
DEFAULT_QUERY_TYPE,
|
||||
QueryTypeId,
|
||||
QueryTypeOption,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
getDefaultSelector,
|
||||
getQueryTypeOption,
|
||||
isAnyResourceValue,
|
||||
isSupportedGrantKey,
|
||||
parseSelector,
|
||||
validateSelector,
|
||||
} from './TelemetrySelectorWizard.utils';
|
||||
@@ -24,6 +26,7 @@ interface UseTelemetrySelectorWizardResult {
|
||||
open: boolean;
|
||||
queryType: QueryTypeId;
|
||||
selectedQueryType: QueryTypeOption | undefined;
|
||||
grantKey: string;
|
||||
value: string;
|
||||
selector: string;
|
||||
isAnyResource: boolean;
|
||||
@@ -32,6 +35,7 @@ interface UseTelemetrySelectorWizardResult {
|
||||
canAdd: boolean;
|
||||
handleOpenChange: (nextOpen: boolean) => void;
|
||||
handleQueryTypeChange: (value: string | string[]) => void;
|
||||
handleKeyChange: (value: string | string[]) => void;
|
||||
handleValueChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleAnyResourceChange: (checked: boolean) => void;
|
||||
handleSelectorChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
@@ -44,6 +48,7 @@ function useTelemetrySelectorWizard({
|
||||
}: UseTelemetrySelectorWizardParams): UseTelemetrySelectorWizardResult {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [queryType, setQueryType] = useState<QueryTypeId>(DEFAULT_QUERY_TYPE);
|
||||
const [grantKey, setGrantKey] = useState(DEFAULT_GRANT_KEY);
|
||||
const [value, setValue] = useState('');
|
||||
const [selector, setSelector] = useState(() =>
|
||||
getDefaultSelector(DEFAULT_QUERY_TYPE),
|
||||
@@ -58,10 +63,17 @@ function useTelemetrySelectorWizard({
|
||||
const validation = useMemo(() => validateSelector(selector), [selector]);
|
||||
|
||||
const applyDraft = useCallback(
|
||||
(nextQueryType: QueryTypeId, nextValue: string): void => {
|
||||
(nextQueryType: QueryTypeId, nextKey: string, nextValue: string): void => {
|
||||
setQueryType(nextQueryType);
|
||||
setGrantKey(nextKey);
|
||||
setValue(nextValue);
|
||||
setSelector(buildSelector({ queryType: nextQueryType, value: nextValue }));
|
||||
setSelector(
|
||||
buildSelector({
|
||||
queryType: nextQueryType,
|
||||
key: nextKey,
|
||||
value: nextValue,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -71,23 +83,30 @@ function useTelemetrySelectorWizard({
|
||||
const selected = (Array.isArray(next) ? next[0] : next) as QueryTypeId;
|
||||
const keepsValue = getQueryTypeOption(selected)?.supportsKeyScoping ?? false;
|
||||
|
||||
applyDraft(selected, keepsValue ? value : '');
|
||||
applyDraft(selected, grantKey, keepsValue ? value : '');
|
||||
},
|
||||
[applyDraft, value],
|
||||
[applyDraft, grantKey, value],
|
||||
);
|
||||
|
||||
const handleKeyChange = useCallback(
|
||||
(next: string | string[]): void => {
|
||||
applyDraft(queryType, Array.isArray(next) ? next[0] : next, value);
|
||||
},
|
||||
[applyDraft, queryType, value],
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
applyDraft(queryType, event.target.value);
|
||||
applyDraft(queryType, grantKey, event.target.value);
|
||||
},
|
||||
[applyDraft, queryType],
|
||||
[applyDraft, queryType, grantKey],
|
||||
);
|
||||
|
||||
const handleAnyResourceChange = useCallback(
|
||||
(checked: boolean): void => {
|
||||
applyDraft(queryType, checked ? ANY_RESOURCE_VALUE : '');
|
||||
applyDraft(queryType, grantKey, checked ? ANY_RESOURCE_VALUE : '');
|
||||
},
|
||||
[applyDraft, queryType],
|
||||
[applyDraft, queryType, grantKey],
|
||||
);
|
||||
|
||||
const handleSelectorChange = useCallback(
|
||||
@@ -99,6 +118,9 @@ function useTelemetrySelectorWizard({
|
||||
if (parsed.queryType) {
|
||||
setQueryType(parsed.queryType);
|
||||
}
|
||||
if (parsed.key && isSupportedGrantKey(parsed.key)) {
|
||||
setGrantKey(parsed.key);
|
||||
}
|
||||
setValue(parsed.value);
|
||||
},
|
||||
[],
|
||||
@@ -109,6 +131,7 @@ function useTelemetrySelectorWizard({
|
||||
|
||||
if (!nextOpen) {
|
||||
setQueryType(DEFAULT_QUERY_TYPE);
|
||||
setGrantKey(DEFAULT_GRANT_KEY);
|
||||
setValue('');
|
||||
setSelector(getDefaultSelector(DEFAULT_QUERY_TYPE));
|
||||
}
|
||||
@@ -138,6 +161,7 @@ function useTelemetrySelectorWizard({
|
||||
open,
|
||||
queryType,
|
||||
selectedQueryType,
|
||||
grantKey,
|
||||
value,
|
||||
selector,
|
||||
isAnyResource: isAnyResourceValue(value),
|
||||
@@ -146,6 +170,7 @@ function useTelemetrySelectorWizard({
|
||||
canAdd: !validation.isError,
|
||||
handleOpenChange,
|
||||
handleQueryTypeChange,
|
||||
handleKeyChange,
|
||||
handleValueChange,
|
||||
handleAnyResourceChange,
|
||||
handleSelectorChange,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormTraceOperatorValues,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import ListView from './index';
|
||||
|
||||
@@ -24,7 +30,7 @@ jest.mock('react-redux', () => {
|
||||
});
|
||||
|
||||
// List columns come from the options menu (server-synced preferences). Pin them
|
||||
// so the query fires and the expected columns render, independent of that API.
|
||||
// so the expected columns render, independent of that API.
|
||||
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
|
||||
__esModule: true,
|
||||
default: (): unknown => ({
|
||||
@@ -35,6 +41,8 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
|
||||
{ name: 'duration_nano', fieldContext: 'span' },
|
||||
{ name: 'http_method', fieldContext: 'span' },
|
||||
{ name: 'response_status_code', fieldContext: 'span' },
|
||||
{ name: 'http.route', fieldContext: 'attribute' },
|
||||
{ name: 'cache.hit', fieldContext: 'attribute' },
|
||||
],
|
||||
},
|
||||
config: { addColumn: { onRemove: jest.fn() } },
|
||||
@@ -48,19 +56,19 @@ const listRows = [
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:58.735245Z',
|
||||
data: {
|
||||
'service.name': 'frontend',
|
||||
name: 'HTTP GET',
|
||||
duration_nano: 55306000,
|
||||
http_method: 'GET',
|
||||
response_status_code: '200',
|
||||
span_id: '772c4d29dd9076ac',
|
||||
trace_id: '0000000000000000344ded1387b08a7e',
|
||||
attributes: { 'http.route': '/api/cart', 'cache.hit': false },
|
||||
resource: { 'service.name': 'frontend' },
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:59.949129915Z',
|
||||
data: {
|
||||
'service.name': 'demo-app',
|
||||
name: 'authenticate_check_db',
|
||||
duration_nano: 790949390,
|
||||
// empty status fields to assert the "-" cell
|
||||
@@ -68,6 +76,8 @@ const listRows = [
|
||||
response_status_code: '',
|
||||
span_id: '5704353737b6778e',
|
||||
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
|
||||
attributes: {},
|
||||
resource: { 'service.name': 'demo-app' },
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -84,7 +94,9 @@ const mockSuccess = (rows: unknown[] = listRows): void => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderListView = (): ReturnType<typeof render> =>
|
||||
const renderListView = (
|
||||
stagedQuery: Query = initialQueriesMap.traces,
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
|
||||
<ListView
|
||||
@@ -98,8 +110,8 @@ const renderListView = (): ReturnType<typeof render> =>
|
||||
initialRoute: '/traces-explorer',
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
stagedQuery: initialQueriesMap.traces,
|
||||
currentQuery: initialQueriesMap.traces,
|
||||
stagedQuery,
|
||||
currentQuery: stagedQuery,
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
} as any,
|
||||
},
|
||||
@@ -118,6 +130,10 @@ describe('Traces ListView - Data Loaded', () => {
|
||||
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('authenticate_check_db')).toBeInTheDocument();
|
||||
|
||||
// attribute column reads from the attributes map
|
||||
expect(screen.getByText('/api/cart')).toBeInTheDocument();
|
||||
expect(screen.getByText('false')).toBeInTheDocument();
|
||||
|
||||
// duration_nano renders in milliseconds
|
||||
expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/);
|
||||
|
||||
@@ -130,4 +146,37 @@ describe('Traces ListView - Data Loaded', () => {
|
||||
// empty status fields render "-"
|
||||
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('requests every span field instead of the selected columns', async () => {
|
||||
let requestBody: QueryRangeRequestV5 | undefined;
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
|
||||
requestBody = await req.json<QueryRangeRequestV5>();
|
||||
return res(ctx.status(200), ctx.json(listResponse(listRows)));
|
||||
}),
|
||||
);
|
||||
const selectColumns = [{ name: 'http.route', fieldContext: 'attribute' }];
|
||||
const { builder } = initialQueriesMap.traces;
|
||||
renderListView({
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...builder,
|
||||
queryData: [{ ...builder.queryData[0], selectColumns }],
|
||||
queryTraceOperator: [
|
||||
{
|
||||
...initialQueryBuilderFormTraceOperatorValues,
|
||||
expression: 'A',
|
||||
selectColumns,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query);
|
||||
|
||||
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
|
||||
const queries = requestBody?.compositeQuery.queries ?? [];
|
||||
expect(queries).toHaveLength(2);
|
||||
queries.forEach(({ spec }) => {
|
||||
expect(spec).not.toHaveProperty('selectFields');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,23 +100,20 @@ function ListView({
|
||||
const paginationConfig =
|
||||
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// Stable sorted-name signature for the queryKey.
|
||||
// - Drag updates selectColumns; raw queryKey would churn on reorder.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch.
|
||||
// - Sorted-name signature: stable on reorder, changes on add/remove.
|
||||
const selectColumnsSignature = useMemo(
|
||||
() =>
|
||||
(options?.selectColumns ?? [])
|
||||
.map((c) => c.name)
|
||||
.sort()
|
||||
.join(','),
|
||||
[options?.selectColumns],
|
||||
);
|
||||
// Empty selectFields makes the API return every span field.
|
||||
const requestQuery = useMemo(() => {
|
||||
const query = getListViewQuery(
|
||||
stagedQuery || initialQueriesMap.traces,
|
||||
orderBy,
|
||||
);
|
||||
query.builder.queryData.forEach((queryData) => {
|
||||
queryData.selectColumns = [];
|
||||
});
|
||||
query.builder.queryTraceOperator?.forEach((queryTraceOperator) => {
|
||||
queryTraceOperator.selectColumns = [];
|
||||
});
|
||||
return query;
|
||||
}, [stagedQuery, orderBy]);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
@@ -127,14 +124,12 @@ function ListView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
],
|
||||
[
|
||||
stagedQuery,
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
maxTime,
|
||||
minTime,
|
||||
],
|
||||
@@ -155,7 +150,6 @@ function ListView({
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
selectColumns: options?.selectColumns,
|
||||
},
|
||||
},
|
||||
// ENTITY_VERSION_V4,
|
||||
@@ -164,10 +158,7 @@ function ListView({
|
||||
queryKey,
|
||||
enabled:
|
||||
// don't make api call while the time range state in redux is loading
|
||||
!timeRangeUpdateLoading &&
|
||||
!!stagedQuery &&
|
||||
panelType === PANEL_TYPES.LIST &&
|
||||
!!options?.selectColumns?.length,
|
||||
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
|
||||
import { TIMESTAMP_FIELD_NAMES } from './constants';
|
||||
import FieldCell from './FieldCell';
|
||||
@@ -16,7 +17,10 @@ export function getFieldColumn(
|
||||
return {
|
||||
id: buildCompositeKey(name, fieldContext, fieldDataType),
|
||||
header: name,
|
||||
accessorFn: (row): unknown => row[name],
|
||||
// Span rows nest attribute/resource fields in maps; trace rows are flat.
|
||||
accessorFn: (row): unknown =>
|
||||
row[name] ??
|
||||
getSpanAttribute(row as Parameters<typeof getSpanAttribute>[0], name),
|
||||
enableMove: !isTimestamp,
|
||||
enableRemove: !isTimestamp,
|
||||
canBeHidden: !isTimestamp,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import classNames from 'classnames';
|
||||
import AlertBreadcrumb from 'components/AlertBreadcrumb';
|
||||
import { Filters } from 'components/AlertDetailsFilters/Filters';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
|
||||
import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
|
||||
|
||||
import AlertHeader from './AlertHeader/AlertHeader';
|
||||
@@ -79,8 +77,6 @@ function AlertDetails(): JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const isV2Alert = alertRuleDetails?.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
|
||||
|
||||
// Show spinner until we have alert data loaded
|
||||
if (isLoading && !alertRuleDetails) {
|
||||
return <Spinner />;
|
||||
@@ -94,9 +90,9 @@ function AlertDetails(): JSX.Element {
|
||||
initialAlertState={initialAlertState}
|
||||
>
|
||||
<div
|
||||
className={classNames('alert-details', { 'alert-details-v2': isV2Alert })}
|
||||
className="alert-details alert-details-v2"
|
||||
data-testid="alert-details-root"
|
||||
data-schema-version={isV2Alert ? NEW_ALERT_SCHEMA_VERSION : 'v1'}
|
||||
data-schema-version={alertRuleDetails?.schemaVersion}
|
||||
>
|
||||
<AlertBreadcrumb
|
||||
className="alert-details__breadcrumb"
|
||||
|
||||
@@ -5,20 +5,16 @@ import { Divider } from '@signozhq/ui/divider';
|
||||
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Copy, Ellipsis, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import { Copy, Ellipsis, Trash2 } from '@signozhq/icons';
|
||||
import {
|
||||
useAlertRuleClone,
|
||||
useAlertRuleDelete,
|
||||
useAlertRuleStatusToggle,
|
||||
useAlertRuleUpdate,
|
||||
} from 'pages/AlertDetails/hooks';
|
||||
import CopyToClipboard from 'periscope/components/CopyToClipboard';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
|
||||
import { AlertHeaderProps } from '../AlertHeader';
|
||||
import RenameModal from './RenameModal';
|
||||
|
||||
import './ActionButtons.styles.scss';
|
||||
|
||||
@@ -29,45 +25,14 @@ function AlertActionButtons({
|
||||
ruleId: string;
|
||||
alertDetails: AlertHeaderProps['alertDetails'];
|
||||
}): JSX.Element {
|
||||
const { alertRuleState, setAlertRuleState, alertRuleName, setAlertRuleName } =
|
||||
useAlertRule();
|
||||
const [intermediateName, setIntermediateName] = useState<string>(
|
||||
alertDetails.alert,
|
||||
);
|
||||
const [isRenameAlertOpen, setIsRenameAlertOpen] = useState<boolean>(false);
|
||||
const { alertRuleState, setAlertRuleState } = useAlertRule();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { handleAlertStateToggle } = useAlertRuleStatusToggle({ ruleId });
|
||||
const { handleAlertClone } = useAlertRuleClone({ ruleId });
|
||||
const { handleAlertDelete } = useAlertRuleDelete({ ruleId });
|
||||
const { handleAlertUpdate, isLoading } = useAlertRuleUpdate({
|
||||
alertDetails: alertDetails as unknown as AlertDef,
|
||||
setAlertRuleName,
|
||||
intermediateName,
|
||||
});
|
||||
|
||||
const handleRename = useCallback(() => {
|
||||
setIsRenameAlertOpen(true);
|
||||
}, []);
|
||||
|
||||
const onNameChangeHandler = useCallback(() => {
|
||||
handleAlertUpdate();
|
||||
setIsRenameAlertOpen(false);
|
||||
}, [handleAlertUpdate]);
|
||||
|
||||
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
|
||||
|
||||
const menuItems: DropdownItemType[] = [];
|
||||
if (!isV2Alert) {
|
||||
menuItems.push({
|
||||
type: 'item',
|
||||
value: 'rename-rule',
|
||||
label: 'Rename',
|
||||
prefix: <PenLine size={16} color={Color.BG_VANILLA_400} />,
|
||||
onClick: handleRename,
|
||||
});
|
||||
}
|
||||
menuItems.push(
|
||||
const menuItems: DropdownItemType[] = [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'clone-rule',
|
||||
@@ -83,7 +48,7 @@ function AlertActionButtons({
|
||||
onClick: handleAlertDelete,
|
||||
danger: true,
|
||||
},
|
||||
);
|
||||
];
|
||||
|
||||
// state for immediate UI feedback rather than waiting for onSuccess of handleAlertStateTiggle to updating the alertRuleState
|
||||
const [isAlertRuleDisabled, setIsAlertRuleDisabled] = useState<
|
||||
@@ -97,12 +62,6 @@ function AlertActionButtons({
|
||||
}
|
||||
}, [setAlertRuleState, alertRuleState, alertDetails.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (alertRuleName !== undefined) {
|
||||
setIntermediateName(alertRuleName);
|
||||
}
|
||||
}, [alertRuleName]);
|
||||
|
||||
// on unmount remove the alert state
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => (): void => setAlertRuleState(undefined), []);
|
||||
@@ -113,50 +72,39 @@ function AlertActionButtons({
|
||||
}, [handleAlertStateToggle]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="alert-action-buttons">
|
||||
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch
|
||||
color="primary"
|
||||
textPlacement="right"
|
||||
onChange={toggleAlertRule}
|
||||
value={!isAlertRuleDisabled}
|
||||
testId="alert-actions-toggle"
|
||||
<div className="alert-action-buttons">
|
||||
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch
|
||||
color="primary"
|
||||
textPlacement="right"
|
||||
onChange={toggleAlertRule}
|
||||
value={!isAlertRuleDisabled}
|
||||
testId="alert-actions-toggle"
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
|
||||
<Divider type="vertical" className="alert-action-buttons__divider" />
|
||||
|
||||
<Tooltip title="More options">
|
||||
<span className="dropdown-trigger-wrapper">
|
||||
<Dropdown items={menuItems} nativeButton align="end" side="bottom">
|
||||
<Button
|
||||
type="text"
|
||||
data-testid="alert-actions-menu"
|
||||
icon={
|
||||
<Ellipsis
|
||||
size={16}
|
||||
color={isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
|
||||
<Divider type="vertical" className="alert-action-buttons__divider" />
|
||||
|
||||
<Tooltip title="More options">
|
||||
<span className="dropdown-trigger-wrapper">
|
||||
<Dropdown items={menuItems} nativeButton align="end" side="bottom">
|
||||
<Button
|
||||
type="text"
|
||||
data-testid="alert-actions-menu"
|
||||
icon={
|
||||
<Ellipsis
|
||||
size={16}
|
||||
color={isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Dropdown>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<RenameModal
|
||||
isOpen={isRenameAlertOpen}
|
||||
setIsOpen={setIsRenameAlertOpen}
|
||||
isLoading={isLoading}
|
||||
onNameChangeHandler={onNameChangeHandler}
|
||||
intermediateName={intermediateName}
|
||||
setIntermediateName={setIntermediateName}
|
||||
/>
|
||||
</>
|
||||
</Dropdown>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
.rename-alert {
|
||||
.ant-modal-content {
|
||||
width: 384px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: 0px;
|
||||
|
||||
.ant-modal-header {
|
||||
height: 52px;
|
||||
padding: 16px;
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
margin-bottom: 0px;
|
||||
.ant-modal-title {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
width: 349px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 16px;
|
||||
|
||||
.alert-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.name-text {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
|
||||
.alert-name-input {
|
||||
display: flex;
|
||||
padding: 6px 6px 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
align-self: stretch;
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
padding: 16px;
|
||||
margin-top: 0px;
|
||||
.alert-rename {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: 12px;
|
||||
|
||||
.cancel-btn {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--l1-background);
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.rename-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--primary-background);
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Button, Input, InputRef, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
|
||||
import './RenameModal.styles.scss';
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
onNameChangeHandler: () => void;
|
||||
isLoading: boolean;
|
||||
intermediateName: string;
|
||||
setIntermediateName: (name: string) => void;
|
||||
};
|
||||
|
||||
function RenameModal({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
onNameChangeHandler,
|
||||
isLoading,
|
||||
intermediateName,
|
||||
setIntermediateName,
|
||||
}: Props): JSX.Element {
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleClose = useCallback((): void => setIsOpen(false), [setIsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (isOpen) {
|
||||
if (e.key === 'Enter') {
|
||||
onNameChangeHandler();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return (): void => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onNameChangeHandler, handleClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
title="Rename Alert"
|
||||
onOk={onNameChangeHandler}
|
||||
onCancel={handleClose}
|
||||
rootClassName="rename-alert"
|
||||
footer={
|
||||
<div className="alert-rename">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Check size={14} />}
|
||||
className="rename-btn"
|
||||
onClick={onNameChangeHandler}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Rename Alert
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<X size={14} />}
|
||||
className="cancel-btn"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="alert-content">
|
||||
<Typography.Text className="name-text">Enter a new name</Typography.Text>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
data-testid="alert-name"
|
||||
className="alert-name-input"
|
||||
value={intermediateName}
|
||||
onChange={(e): void => setIntermediateName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default RenameModal;
|
||||
@@ -7,35 +7,4 @@
|
||||
.alert-header {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__info-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 54px;
|
||||
flex: 1;
|
||||
|
||||
.top-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.alert-title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.alert-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.08px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.bottom-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import CreateAlertV2Header from 'container/CreateAlertV2/CreateAlertHeader';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import {
|
||||
NEW_ALERT_SCHEMA_VERSION,
|
||||
PostableAlertRuleV2,
|
||||
} from 'types/api/alerts/alertTypesV2';
|
||||
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
|
||||
|
||||
import AlertActionButtons from './ActionButtons/ActionButtons';
|
||||
import AlertLabels from './AlertLabels/AlertLabels';
|
||||
import AlertSeverity from './AlertSeverity/AlertSeverity';
|
||||
import AlertState from './AlertState/AlertState';
|
||||
|
||||
import './AlertHeader.styles.scss';
|
||||
|
||||
@@ -19,8 +12,8 @@ export type AlertHeaderProps = {
|
||||
alertDetails: RuletypesRuleDTO | PostableAlertRuleV2;
|
||||
};
|
||||
function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
const { state, alert: alertName, labels } = alertDetails;
|
||||
const { alertRuleState, alertRuleName, setAlertRuleName } = useAlertRule();
|
||||
const { alert: alertName } = alertDetails;
|
||||
const { alertRuleName, setAlertRuleName } = useAlertRule();
|
||||
|
||||
useEffect(() => {
|
||||
if (alertRuleName === undefined && alertName) {
|
||||
@@ -30,50 +23,9 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
|
||||
useEffect(() => (): void => setAlertRuleName(undefined), [setAlertRuleName]);
|
||||
|
||||
const displayName = alertRuleName ?? alertName;
|
||||
|
||||
const labelsWithoutSeverity = useMemo(() => {
|
||||
if (labels) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(labels).filter(([key]) => key !== 'severity'),
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}, [labels]);
|
||||
|
||||
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
|
||||
|
||||
const CreateAlertV1Header = (
|
||||
<div className="alert-info__info-wrapper">
|
||||
<div className="top-section">
|
||||
<div className="alert-title-wrapper">
|
||||
<AlertState
|
||||
state={alertRuleState ?? state ?? ''}
|
||||
testId="alert-header-state"
|
||||
/>
|
||||
<div className="alert-title" data-testid="alert-header-title">
|
||||
<LineClampedText text={displayName || ''} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bottom-section">
|
||||
{labels?.severity && (
|
||||
<AlertSeverity severity={labels.severity} testId="alert-header-severity" />
|
||||
)}
|
||||
|
||||
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
|
||||
{/* <AlertStatus
|
||||
status="firing"
|
||||
timestamp={dayjs().subtract(1, 'd').valueOf()}
|
||||
/> */}
|
||||
<AlertLabels labels={labelsWithoutSeverity} testId="alert-header-labels" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="alert-info">
|
||||
{isV2Alert ? <CreateAlertV2Header /> : CreateAlertV1Header}
|
||||
<CreateAlertV2Header />
|
||||
<div className="alert-info__action-buttons">
|
||||
<AlertActionButtons
|
||||
alertDetails={alertDetails}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
@mixin severity-styles($background, $text-color) {
|
||||
.alert-severity__icon {
|
||||
background: $background;
|
||||
}
|
||||
.alert-severity__text {
|
||||
color: $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-severity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
overflow: hidden;
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
border-radius: 3.5px;
|
||||
}
|
||||
&__text {
|
||||
color: var(--text-sakura-400);
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&--critical,
|
||||
&--error {
|
||||
@include severity-styles(
|
||||
color-mix(in srgb, var(--bg-sakura-400) 20%, transparent),
|
||||
var(--text-sakura-400)
|
||||
);
|
||||
}
|
||||
&--warning {
|
||||
@include severity-styles(
|
||||
color-mix(in srgb, var(--bg-amber-400) 20%, transparent),
|
||||
var(--text-amber-400)
|
||||
);
|
||||
}
|
||||
&--info {
|
||||
@include severity-styles(
|
||||
color-mix(in srgb, var(--bg-robin-400) 20%, transparent),
|
||||
var(--text-robin-400)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import SeverityCriticalIcon from 'assets/AlertHistory/SeverityCriticalIcon';
|
||||
import SeverityErrorIcon from 'assets/AlertHistory/SeverityErrorIcon';
|
||||
import SeverityInfoIcon from 'assets/AlertHistory/SeverityInfoIcon';
|
||||
import SeverityWarningIcon from 'assets/AlertHistory/SeverityWarningIcon';
|
||||
|
||||
import './AlertSeverity.styles.scss';
|
||||
|
||||
const severityConfig: Record<string, Record<string, string | JSX.Element>> = {
|
||||
critical: {
|
||||
text: 'Critical',
|
||||
className: 'alert-severity--critical',
|
||||
icon: <SeverityCriticalIcon />,
|
||||
},
|
||||
error: {
|
||||
text: 'Error',
|
||||
className: 'alert-severity--error',
|
||||
icon: <SeverityErrorIcon />,
|
||||
},
|
||||
warning: {
|
||||
text: 'Warning',
|
||||
className: 'alert-severity--warning',
|
||||
icon: <SeverityWarningIcon />,
|
||||
},
|
||||
info: {
|
||||
text: 'Info',
|
||||
className: 'alert-severity--info',
|
||||
icon: <SeverityInfoIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
export default function AlertSeverity({
|
||||
severity,
|
||||
testId,
|
||||
}: {
|
||||
severity: string;
|
||||
testId?: string;
|
||||
}): JSX.Element {
|
||||
const severityDetails = useMemo(() => {
|
||||
if (severityConfig[severity]) {
|
||||
return severityConfig[severity];
|
||||
}
|
||||
|
||||
Sentry.captureEvent({
|
||||
message: `Received unknown severity on Alert Details: ${severity}`,
|
||||
level: 'error',
|
||||
});
|
||||
|
||||
return {
|
||||
text: severity,
|
||||
className: 'alert-severity--info',
|
||||
icon: <SeverityInfoIcon />,
|
||||
};
|
||||
}, [severity]);
|
||||
return (
|
||||
<div
|
||||
className={`alert-severity ${severityDetails.className}`}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className="alert-severity__icon">{severityDetails.icon}</div>
|
||||
<div className="alert-severity__text">{severityDetails.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AlertSeverity.defaultProps = {
|
||||
testId: undefined,
|
||||
};
|
||||
@@ -7,11 +7,9 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
cloneRuleByID,
|
||||
deleteRuleByID,
|
||||
getGetRuleByIDQueryKey,
|
||||
getGetRuleHistoryTimelineQueryOptions,
|
||||
invalidateGetRuleByID,
|
||||
invalidateListRules,
|
||||
updateRuleByID,
|
||||
useGetRuleByID,
|
||||
useGetRuleHistoryOverallStatus,
|
||||
useGetRuleHistoryStats,
|
||||
@@ -52,8 +50,7 @@ import EditRules from 'pages/EditRules';
|
||||
import BetaTag from 'periscope/components/BetaTag/BetaTag';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
|
||||
import { AlertDef, AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
|
||||
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
|
||||
import APIError from 'types/api/error';
|
||||
import { nanoToMilli } from 'utils/timeUtils';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -518,60 +515,6 @@ export const useAlertRuleClone = ({
|
||||
return { handleAlertClone };
|
||||
};
|
||||
|
||||
export const useAlertRuleUpdate = ({
|
||||
alertDetails,
|
||||
setAlertRuleName,
|
||||
intermediateName,
|
||||
}: {
|
||||
alertDetails: AlertDef;
|
||||
setAlertRuleName: (name: string | undefined) => void;
|
||||
intermediateName: string;
|
||||
}): {
|
||||
handleAlertUpdate: () => void;
|
||||
isLoading: boolean;
|
||||
} => {
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: updateAlertRule, isLoading } = useMutation(
|
||||
[REACT_QUERY_KEY.UPDATE_ALERT_RULE, alertDetails.id],
|
||||
(args: { data: AlertDef; id: string }) =>
|
||||
updateRuleByID({ id: args.id }, toPostableRuleDTOFromAlertDef(args.data)),
|
||||
{
|
||||
onMutate: () => setAlertRuleName(intermediateName),
|
||||
onSuccess: () => {
|
||||
const ruleId = alertDetails.id || '';
|
||||
const ruleQueryKey = getGetRuleByIDQueryKey({ id: ruleId });
|
||||
const existingRule = queryClient.getQueryData<GetRuleByID200>(ruleQueryKey);
|
||||
if (existingRule) {
|
||||
queryClient.setQueryData<GetRuleByID200>(ruleQueryKey, {
|
||||
...existingRule,
|
||||
data: { ...existingRule.data, alert: intermediateName },
|
||||
});
|
||||
}
|
||||
void invalidateListRules(queryClient);
|
||||
notifications.success({ message: 'Alert renamed successfully' });
|
||||
},
|
||||
onError: (error) => {
|
||||
setAlertRuleName(alertDetails.alert);
|
||||
showErrorModal(
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleAlertUpdate = (): void => {
|
||||
updateAlertRule({
|
||||
data: { ...alertDetails, alert: intermediateName },
|
||||
id: alertDetails.id || '',
|
||||
});
|
||||
};
|
||||
|
||||
return { handleAlertUpdate, isLoading };
|
||||
};
|
||||
|
||||
export const useAlertRuleDelete = ({
|
||||
ruleId,
|
||||
}: {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
@@ -23,11 +23,9 @@ import {
|
||||
|
||||
import {
|
||||
alertRuleByIdResponse,
|
||||
ALERT_SCHEMAS,
|
||||
notificationChannelsResponse,
|
||||
CHANNEL_MAX,
|
||||
FIRST_RULE_NAME,
|
||||
type AlertSchema,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
|
||||
const STORY_RULE_ID = 'rule-1';
|
||||
@@ -77,13 +75,6 @@ export const alertHistoryMocks = defineStoryMocks({
|
||||
value: 30,
|
||||
max: 60,
|
||||
}),
|
||||
alertSchema: choiceControl<AlertSchema>('Alert schema', {
|
||||
group: TIMELINE,
|
||||
description:
|
||||
'Which form the Overview tab opens the rule in. The history tab only shows it in the breadcrumb and the header.',
|
||||
options: ALERT_SCHEMAS,
|
||||
value: 'v2',
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
@@ -142,7 +133,6 @@ export const alertHistoryMocks = defineStoryMocks({
|
||||
alertRuleByIdResponse(String(req.params.id), {
|
||||
severity: 'mixed',
|
||||
state: 'mixed',
|
||||
schema: values.alertSchema,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,13 +11,11 @@ import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
alertRuleByIdResponse,
|
||||
ALERT_SCHEMAS,
|
||||
notificationChannelsResponse,
|
||||
CHANNEL_MAX,
|
||||
RULE_KINDS,
|
||||
RULE_STATE_CHOICES,
|
||||
SEVERITY_CHOICES,
|
||||
type AlertSchema,
|
||||
type RuleKind,
|
||||
type RuleStateChoice,
|
||||
type SeverityChoice,
|
||||
@@ -41,13 +39,6 @@ type TestNotification = (typeof TEST_NOTIFICATIONS)[number];
|
||||
|
||||
export const alertOverviewMocks = defineStoryMocks({
|
||||
controls: {
|
||||
alertSchema: choiceControl<AlertSchema>('Alert schema', {
|
||||
group: RULE,
|
||||
description:
|
||||
'`v2` opens the stepper the new alert form uses; `classic` is the single-form page rules written before it still open in.',
|
||||
options: ALERT_SCHEMAS,
|
||||
value: 'v2',
|
||||
}),
|
||||
ruleKind: choiceControl<RuleKind>('Rule kind', {
|
||||
group: RULE,
|
||||
description:
|
||||
@@ -94,7 +85,6 @@ export const alertOverviewMocks = defineStoryMocks({
|
||||
alertRuleByIdResponse(String(req.params.id), {
|
||||
severity: values.ruleSeverity,
|
||||
state: values.ruleState,
|
||||
schema: values.alertSchema,
|
||||
kind: values.ruleKind,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -39,11 +39,6 @@ type Story = StoryObj<AlertOverviewArgs>;
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** A rule written before the current schema, which opens in the classic form. */
|
||||
export const ClassicSchema: Story = {
|
||||
args: { alertSchema: 'classic' },
|
||||
};
|
||||
|
||||
/**
|
||||
* An anomaly rule in the current schema: the condition reads in deviations from
|
||||
* the forecast, and the preview draws the band the rule compares against.
|
||||
@@ -68,11 +63,6 @@ export const AnomalyRuleForecastBand: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** An anomaly rule written before the current schema, still in the classic form. */
|
||||
export const ClassicAnomalyRule: Story = {
|
||||
args: { ruleKind: 'anomaly', alertSchema: 'classic' },
|
||||
};
|
||||
|
||||
/** A rule someone turned off: the toggle in the header is what turns it back on. */
|
||||
export const Disabled: Story = {
|
||||
args: { ruleState: 'disabled' },
|
||||
|
||||
@@ -70,10 +70,6 @@ export const RULE_STATE_CHOICES = ['mixed', ...RULE_STATES] as const;
|
||||
|
||||
export type RuleStateChoice = (typeof RULE_STATE_CHOICES)[number];
|
||||
|
||||
export const ALERT_SCHEMAS = ['v2', 'classic'] as const;
|
||||
|
||||
export type AlertSchema = (typeof ALERT_SCHEMAS)[number];
|
||||
|
||||
export const RULE_KINDS = ['threshold', 'anomaly'] as const;
|
||||
|
||||
export type RuleKind = (typeof RULE_KINDS)[number];
|
||||
@@ -355,7 +351,6 @@ const ruleName = (index: number): string => {
|
||||
export interface RuleShape {
|
||||
severity: SeverityChoice;
|
||||
state: RuleStateChoice;
|
||||
schema?: AlertSchema;
|
||||
kind?: RuleKind;
|
||||
}
|
||||
|
||||
@@ -396,10 +391,7 @@ const buildRule = (index: number, shape: RuleShape): RuletypesRuleDTO => {
|
||||
updatedAt: ago((index + 1) * HOUR),
|
||||
createdBy: 'ada@signoz.io',
|
||||
updatedBy: 'grace@signoz.io',
|
||||
// classic rows predate schemaVersion, which the spec now requires
|
||||
schemaVersion: (shape.schema === 'classic'
|
||||
? undefined
|
||||
: RuletypesRuleDTOSchemaVersion.v2alpha1) as RuletypesRuleDTOSchemaVersion,
|
||||
schemaVersion: RuletypesRuleDTOSchemaVersion.v2alpha1,
|
||||
version: 'v5',
|
||||
source: 'http://localhost/alerts',
|
||||
preferredChannels: ['ops-slack'],
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { rest } from 'msw';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
@@ -28,8 +28,7 @@ import {
|
||||
/**
|
||||
* Which alert the page is building. The page reads this off the URL, so the
|
||||
* control is a route rather than a response: with no type at all it stays on
|
||||
* the picker, `classic-form` opts back into the classic form, and everything else
|
||||
* opens the current one.
|
||||
* the picker and everything else opens the form.
|
||||
*/
|
||||
const ALERT_MODES = [
|
||||
'select-type',
|
||||
@@ -38,7 +37,6 @@ const ALERT_MODES = [
|
||||
'traces',
|
||||
'exceptions',
|
||||
'anomaly',
|
||||
'classic-form',
|
||||
] as const;
|
||||
|
||||
type AlertMode = (typeof ALERT_MODES)[number];
|
||||
@@ -49,7 +47,6 @@ const ALERT_TYPE_BY_MODE: Partial<Record<AlertMode, AlertTypes>> = {
|
||||
traces: AlertTypes.TRACES_BASED_ALERT,
|
||||
exceptions: AlertTypes.EXCEPTIONS_BASED_ALERT,
|
||||
anomaly: AlertTypes.METRICS_BASED_ALERT,
|
||||
'classic-form': AlertTypes.METRICS_BASED_ALERT,
|
||||
};
|
||||
|
||||
const routeFor = (mode: AlertMode): string => {
|
||||
@@ -63,15 +60,11 @@ const routeFor = (mode: AlertMode): string => {
|
||||
[QueryParams.alertType]: alertType,
|
||||
[QueryParams.ruleType]:
|
||||
mode === 'anomaly'
|
||||
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
|
||||
: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
? RuletypesRuleTypeDTO.anomaly_rule
|
||||
: RuletypesRuleTypeDTO.threshold_rule,
|
||||
[QueryParams.relativeTime]: '6h',
|
||||
});
|
||||
|
||||
if (mode === 'classic-form') {
|
||||
params.set(QueryParams.showClassicCreateAlertsPage, 'true');
|
||||
}
|
||||
|
||||
return `${ROUTES.ALERTS_NEW}?${params.toString()}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -81,11 +81,6 @@ export const AnomalyAlertRoutingPolicies: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** The classic form, which `showClassicCreateAlertsPage` opts back into. */
|
||||
export const ClassicForm: Story = {
|
||||
args: { alertMode: 'classic-form' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The match-type tooltip on the threshold sentence: a paragraph on what an
|
||||
* aggregated data point is, a worked example over five of them, and the docs
|
||||
|
||||
@@ -11,19 +11,12 @@ import { AxiosError } from 'axios';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import EditRulesContainer from 'container/EditRules';
|
||||
import EditAlertV2 from 'container/EditAlertV2';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import {
|
||||
NEW_ALERT_SCHEMA_VERSION,
|
||||
PostableAlertRuleV2,
|
||||
} from 'types/api/alerts/alertTypesV2';
|
||||
import {
|
||||
fromRuleDTOToAlertDef,
|
||||
fromRuleDTOToPostableRuleV2,
|
||||
} from 'types/api/alerts/convert';
|
||||
import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
|
||||
|
||||
import './EditRules.styles.scss';
|
||||
|
||||
@@ -96,17 +89,13 @@ function EditRules(): JSX.Element {
|
||||
return <Spinner tip="Loading Rules..." />;
|
||||
}
|
||||
|
||||
let initialV2AlertValue: PostableAlertRuleV2 | null = null;
|
||||
if (ruleData.schemaVersion === NEW_ALERT_SCHEMA_VERSION) {
|
||||
initialV2AlertValue = fromRuleDTOToPostableRuleV2(ruleData);
|
||||
}
|
||||
const initialAlert = fromRuleDTOToPostableRuleV2(ruleData);
|
||||
|
||||
return (
|
||||
<div className="edit-rules-container">
|
||||
<EditRulesContainer
|
||||
ruleId={ruleId || ''}
|
||||
initialValue={fromRuleDTOToAlertDef(ruleData)}
|
||||
initialV2AlertValue={initialV2AlertValue}
|
||||
<EditAlertV2
|
||||
initialAlert={initialAlert}
|
||||
alertType={initialAlert.alertType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import ROUTES from 'constants/routes';
|
||||
import { rest } from 'msw';
|
||||
import { userEvent, within } from 'storybook/test';
|
||||
|
||||
import { choiceControl, countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
alertFieldKeysResponse,
|
||||
alertFieldValuesResponse,
|
||||
alertMetricMetadataResponse,
|
||||
alertMetricsResponse,
|
||||
alertPreviewSeries,
|
||||
} from '../../AlertList/stories/__story_mockdata__/alertQuery';
|
||||
import {
|
||||
alertRuleByIdResponse,
|
||||
ALERT_SCHEMAS,
|
||||
notificationChannelsResponse,
|
||||
CHANNEL_MAX,
|
||||
type AlertSchema,
|
||||
} from '../../AlertList/stories/__story_mockdata__/alerts';
|
||||
|
||||
const STORY_RULE_ID = 'rule-1';
|
||||
|
||||
const RULE = 'Edit rule · rule';
|
||||
|
||||
export const editRulesMocks = defineStoryMocks({
|
||||
controls: {
|
||||
alertSchema: choiceControl<AlertSchema>('Alert schema', {
|
||||
group: RULE,
|
||||
description:
|
||||
'`classic` is the single-form page this route was built for. `v2` throws: the new form reads `CreateAlertProvider`, which only `pages/AlertDetails` mounts, so this route crashes on any rule saved on the current schema.',
|
||||
options: ALERT_SCHEMAS,
|
||||
value: 'classic',
|
||||
}),
|
||||
channels: countControl('Notification channels', {
|
||||
group: RULE,
|
||||
value: 5,
|
||||
max: CHANNEL_MAX,
|
||||
}),
|
||||
previewSeries: countControl('Preview series', {
|
||||
group: RULE,
|
||||
description: 'Lines the chart above the condition draws.',
|
||||
value: 3,
|
||||
max: 6,
|
||||
}),
|
||||
},
|
||||
handlers: (values, response) => [
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules/:id',
|
||||
response.json((req) =>
|
||||
alertRuleByIdResponse(String(req.params.id), {
|
||||
severity: 'critical',
|
||||
state: 'firing',
|
||||
schema: values.alertSchema,
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.put('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.post('http://localhost/api/v2/rules/test', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: { alertCount: 2, message: 'Rule tested against the last 6 hours' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/notification_channels',
|
||||
response.json(() => notificationChannelsResponse(values.channels)),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v5/query_range',
|
||||
response.json(async (req) => alertPreviewSeries(values.previewSeries, req)),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics',
|
||||
response.json((req) =>
|
||||
alertMetricsResponse(req.url.searchParams.get('searchText') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/metrics/metadata',
|
||||
response.json((req) =>
|
||||
alertMetricMetadataResponse(req.url.searchParams.get('metricName') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json((req) =>
|
||||
alertFieldKeysResponse(req.url.searchParams.get('searchText') ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
alertFieldValuesResponse(
|
||||
req.url.searchParams.get('name') ?? '',
|
||||
req.url.searchParams.get('searchText') ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
config: () => ({
|
||||
route: `${ROUTES.EDIT_ALERTS}?ruleId=${STORY_RULE_ID}&relativeTime=6h`,
|
||||
}),
|
||||
});
|
||||
|
||||
/** The page fetches the rule before it renders the form, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const LABEL_INPUT = 'alert-labels-input-v1';
|
||||
|
||||
/** The labels field, scrolled into view once the rule has loaded. */
|
||||
export const findLabelInput = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<HTMLElement> => {
|
||||
const input = await within(canvasElement).findByTestId(
|
||||
LABEL_INPUT,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
input.scrollIntoView({ block: 'center' });
|
||||
return input;
|
||||
};
|
||||
|
||||
export const removeLabel = async (
|
||||
canvasElement: HTMLElement,
|
||||
key: string,
|
||||
): Promise<void> => {
|
||||
await userEvent.click(
|
||||
within(canvasElement).getByRole('button', { name: `Remove ${key}` }),
|
||||
);
|
||||
};
|
||||
@@ -1,137 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
editRulesMocks,
|
||||
findLabelInput,
|
||||
removeLabel,
|
||||
} from './EditRules.stories.mocks';
|
||||
|
||||
import EditRules from '../index';
|
||||
|
||||
type EditRulesArgs = PageStoryArgs<typeof editRulesMocks>;
|
||||
|
||||
const pageStory = storyMocks(editRulesMocks, { layout: 'app' });
|
||||
|
||||
/**
|
||||
* An existing rule in the builder that created it, loaded from
|
||||
* `/api/v2/rules/:id`.
|
||||
*
|
||||
* Route: `/alerts/edit?ruleId=...`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Alerts/Edit',
|
||||
tags: ['play'],
|
||||
component: EditRules,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
} satisfies Meta<EditRulesArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<EditRulesArgs>;
|
||||
|
||||
/**
|
||||
* The alert form on its own route, without the alert-details tabs around it.
|
||||
* Metrics Explorer and the assistant link here when they send someone to a rule.
|
||||
* The rule is on the classic schema, which is the only one this route renders:
|
||||
* see the Alert schema control for what a current-schema rule does here.
|
||||
*/
|
||||
export const Default: Story = {};
|
||||
|
||||
/** The rule id in the URL does not resolve, so the page offers the way back. */
|
||||
export const RuleNotFound: Story = {
|
||||
args: { dataState: 'error' },
|
||||
// The mocked rule request intentionally fails; the resulting console error is
|
||||
// the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* The preview chart's legend, one tooltip per series carrying the full label a
|
||||
* truncated legend entry falls back to. The Preview series control is turned up
|
||||
* to its maximum so the legend wraps to a second row, which is where a label
|
||||
* gets clipped.
|
||||
*/
|
||||
export const Tooltips: Story = {
|
||||
args: { tooltipsOpen: true, previewSeries: 6 },
|
||||
};
|
||||
|
||||
/** The rule's own labels, each with its remove button, above the empty input. */
|
||||
export const Labels: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await findLabelInput(canvasElement);
|
||||
const canvas = within(canvasElement);
|
||||
await canvas.findByText('team: platform');
|
||||
await canvas.findByText('env: prod');
|
||||
},
|
||||
};
|
||||
|
||||
/** A key typed and confirmed: it waits as its own chip while the input asks for its value. */
|
||||
export const LabelKeyEntered: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const input = await findLabelInput(canvasElement);
|
||||
await userEvent.type(input, 'region{enter}');
|
||||
await within(canvasElement).findByPlaceholderText(
|
||||
'Enter a value for label key(region) then press ENTER.',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** A key and its value confirmed, added after the rule's own labels. */
|
||||
export const LabelAdded: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const input = await findLabelInput(canvasElement);
|
||||
await userEvent.type(input, 'region{enter}');
|
||||
await userEvent.type(input, 'us-east-1{enter}');
|
||||
await within(canvasElement).findByText('region: us-east-1');
|
||||
},
|
||||
};
|
||||
|
||||
/** One of the rule's labels removed, the other left in place. */
|
||||
export const LabelRemoved: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await findLabelInput(canvasElement);
|
||||
await within(canvasElement).findByText('team: platform');
|
||||
await removeLabel(canvasElement, 'team');
|
||||
await waitFor(() =>
|
||||
expect(within(canvasElement).queryByText('team: platform')).toBeNull(),
|
||||
);
|
||||
await expect(within(canvasElement).getByText('env: prod')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** Every label removed: the input is back to its prompt and the clear-all button is gone. */
|
||||
export const NoLabels: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const input = await findLabelInput(canvasElement);
|
||||
await within(canvasElement).findByText('team: platform');
|
||||
await removeLabel(canvasElement, 'team');
|
||||
await removeLabel(canvasElement, 'env');
|
||||
await within(canvasElement).findByPlaceholderText(
|
||||
'Click here to enter a label (key value pairs)',
|
||||
);
|
||||
await expect(input.parentElement?.querySelector('button')).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/** The confirmation the clear-all button opens before it drops every label. */
|
||||
export const ClearLabelsConfirm: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const input = await findLabelInput(canvasElement);
|
||||
await within(canvasElement).findByText('team: platform');
|
||||
const clearAll = input.parentElement?.querySelector('button');
|
||||
|
||||
if (!clearAll) {
|
||||
throw new Error('Clear-all labels button did not render');
|
||||
}
|
||||
|
||||
await userEvent.click(clearAll);
|
||||
await screen.findByText(
|
||||
'This action will remove all the labels. Do you want to proceed?',
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import ROUTES from 'constants/routes';
|
||||
import { rest } from 'msw';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { RoleType } from 'types/roles';
|
||||
|
||||
import { choiceControl } from '@/storybook/controls/controls';
|
||||
@@ -116,6 +116,21 @@ export const openLogsCard = async (
|
||||
return header.parentElement as HTMLElement;
|
||||
};
|
||||
|
||||
/** Opens the telemetry selector wizard from the Logs read verb and returns its dialog. */
|
||||
export const openSelectorWizard = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<HTMLElement> => {
|
||||
const card = await openLogsCard(canvasElement);
|
||||
const [onlySelected] = await within(card).findAllByText('Only selected');
|
||||
|
||||
await userEvent.click(onlySelected);
|
||||
await userEvent.click(
|
||||
await within(card).findByRole('button', { name: 'Wizard' }),
|
||||
);
|
||||
|
||||
return screen.findByRole('dialog', { name: 'Selector Wizard' });
|
||||
};
|
||||
|
||||
/** Clicks save once the permission check behind the button has answered. */
|
||||
export const save = async (canvasElement: HTMLElement): Promise<void> => {
|
||||
const button = await waitFor(
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
openLogsCard,
|
||||
openSelectorWizard,
|
||||
roleEditorMocks,
|
||||
save,
|
||||
} from './RoleEditor.stories.mocks';
|
||||
@@ -93,14 +94,46 @@ export const PermissionEditorScope: Story = {
|
||||
/** The selector wizard, opened from a Logs verb scoped to named objects. */
|
||||
export const TelemetrySelectorWizard: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const card = await openLogsCard(canvasElement);
|
||||
const [onlySelected] = await within(card).findAllByText('Only selected');
|
||||
await openSelectorWizard(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
await userEvent.click(onlySelected);
|
||||
await userEvent.click(
|
||||
await within(card).findByRole('button', { name: 'Wizard' }),
|
||||
/** The Key dropdown open, showing the three supported telemetry grant keys. */
|
||||
export const TelemetryKeyDropdown: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openSelectorWizard(canvasElement);
|
||||
|
||||
await userEvent.click(screen.getByTestId('wizard-key-select-logs-read'));
|
||||
await screen.findByTestId(
|
||||
'wizard-key-option-signoz.workspace.key.id-logs-read',
|
||||
);
|
||||
await screen.findByRole('dialog', { name: 'Selector Wizard' });
|
||||
await screen.findByTestId(
|
||||
'wizard-key-option-resource.service.name-logs-read',
|
||||
);
|
||||
await screen.findByTestId(
|
||||
'wizard-key-option-resource.deployment.environment.name-logs-read',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** A service-name key picked with a value: the selector rebuilds to match. */
|
||||
export const TelemetryKeyScoped: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await openSelectorWizard(canvasElement);
|
||||
|
||||
await userEvent.click(screen.getByTestId('wizard-key-select-logs-read'));
|
||||
await userEvent.click(
|
||||
await screen.findByTestId(
|
||||
'wizard-key-option-resource.service.name-logs-read',
|
||||
),
|
||||
);
|
||||
await userEvent.type(
|
||||
screen.getByTestId('wizard-value-input-logs-read'),
|
||||
'checkout',
|
||||
);
|
||||
await expect(
|
||||
screen.getByTestId('wizard-selector-input-logs-read'),
|
||||
).toHaveValue('builder_query/resource.service.name/checkout');
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -190,10 +190,12 @@ function SpanDetailsContent({
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
clusterName: getSpanAttribute(selectedSpan, 'k8s.cluster.name') || '',
|
||||
podName: getSpanAttribute(selectedSpan, 'k8s.pod.name') || '',
|
||||
nodeName: getSpanAttribute(selectedSpan, 'k8s.node.name') || '',
|
||||
hostName: getSpanAttribute(selectedSpan, 'host.name') || '',
|
||||
clusterName: String(
|
||||
getSpanAttribute(selectedSpan, 'k8s.cluster.name') ?? '',
|
||||
),
|
||||
podName: String(getSpanAttribute(selectedSpan, 'k8s.pod.name') ?? ''),
|
||||
nodeName: String(getSpanAttribute(selectedSpan, 'k8s.node.name') ?? ''),
|
||||
hostName: String(getSpanAttribute(selectedSpan, 'host.name') ?? ''),
|
||||
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
|
||||
};
|
||||
}, [selectedSpan]);
|
||||
|
||||
@@ -88,5 +88,17 @@ describe('Presentation / Styling Utils', () => {
|
||||
);
|
||||
expect(value).toBe('host-1');
|
||||
});
|
||||
|
||||
it('groups 0 and false under their own value, not "unknown"', () => {
|
||||
expect(
|
||||
getFlamegraphSpanGroupValue({ attributes: { 'host.name': 0 } }, HOST_FIELD),
|
||||
).toBe('0');
|
||||
expect(
|
||||
getFlamegraphSpanGroupValue(
|
||||
{ attributes: { 'host.name': false } },
|
||||
HOST_FIELD,
|
||||
),
|
||||
).toBe('false');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +80,7 @@ export function getFlamegraphRowMetrics(
|
||||
export function getFlamegraphServiceName(
|
||||
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
|
||||
): string {
|
||||
return getSpanAttribute(span, 'service.name') || '';
|
||||
return String(getSpanAttribute(span, 'service.name') ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,7 +92,7 @@ export function getFlamegraphSpanGroupValue(
|
||||
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
|
||||
field: TelemetryFieldKey,
|
||||
): string {
|
||||
return getSpanAttribute(span, field.name) || 'unknown';
|
||||
return String(getSpanAttribute(span, field.name) ?? 'unknown');
|
||||
}
|
||||
|
||||
interface GetSpanColorArgs {
|
||||
|
||||
@@ -27,6 +27,14 @@ describe('getAvailableColorByFieldNames', () => {
|
||||
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['host.name']);
|
||||
});
|
||||
|
||||
it('offers a field whose only values are 0 or false', () => {
|
||||
const spans = [
|
||||
span({ attributes: { 'host.name': 0 } }),
|
||||
span({ attributes: { 'host.name': false } }),
|
||||
];
|
||||
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['host.name']);
|
||||
});
|
||||
|
||||
it('does not offer fields no span carries', () => {
|
||||
const spans = [span({ resource: { 'service.name': 'api' } })];
|
||||
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['service.name']);
|
||||
|
||||
@@ -9,14 +9,14 @@ import {
|
||||
RESERVED_ERROR,
|
||||
} from './utils/generateColorPair';
|
||||
|
||||
/** Like `||`, but keeps `0`. */
|
||||
/** Like `||`, but keeps `0` and `false`. */
|
||||
const valueOrUndefined = <T>(value: T): T | undefined =>
|
||||
value || value === 0 ? value : undefined;
|
||||
value || value === 0 || value === false ? value : undefined;
|
||||
|
||||
/**
|
||||
* Look up an attribute from both `resource` and `attributes` on a span.
|
||||
* Resources are checked first (service.name, k8s.* etc. live there).
|
||||
* Falsy values count as missing, except `0`.
|
||||
* Falsy values count as missing, except `0` and `false`.
|
||||
*
|
||||
* Accepts both `SpanV3` (waterfall) and `FlamegraphSpan` (flamegraph) by typing
|
||||
* structurally — the only fields touched are `resource` and `attributes`.
|
||||
@@ -30,7 +30,7 @@ export function getSpanAttribute(
|
||||
tagMap?: Record<string, any>;
|
||||
},
|
||||
key: string,
|
||||
): string | undefined {
|
||||
): string | number | boolean | undefined {
|
||||
return (
|
||||
valueOrUndefined(span.resource?.[key]) ??
|
||||
valueOrUndefined(span.attributes?.[key]) ??
|
||||
@@ -117,7 +117,7 @@ export function getSpanGroupValue(
|
||||
span: SpanV3,
|
||||
colorByFieldName: string,
|
||||
): string {
|
||||
return getSpanAttribute(span, colorByFieldName) || 'unknown';
|
||||
return String(getSpanAttribute(span, colorByFieldName) ?? 'unknown');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,6 +143,6 @@ export function resolveSpanColor(
|
||||
*/
|
||||
export function getAvailableColorByFieldNames(spans: SpanV3[]): string[] {
|
||||
return COLOR_BY_OPTIONS.filter((opt) =>
|
||||
spans.some((s) => getSpanAttribute(s, opt.field.name)),
|
||||
spans.some((s) => getSpanAttribute(s, opt.field.name) !== undefined),
|
||||
).map((opt) => opt.field.name);
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ const timestampAt = (index: number, start: number, end: number): string => {
|
||||
return new Date(end - index * step).toISOString();
|
||||
};
|
||||
|
||||
export const traceSpanRows = ({
|
||||
const flatSpanRows = ({
|
||||
count,
|
||||
offset,
|
||||
limit,
|
||||
@@ -173,9 +173,22 @@ export const traceSpanRows = ({
|
||||
};
|
||||
});
|
||||
|
||||
/** The List view returns every span field, with resource and attribute fields nested in maps. */
|
||||
export const traceSpanRows = (options: SpanRowsOptions): RawRow[] =>
|
||||
flatSpanRows(options).map(
|
||||
({ timestamp, data: { 'service.name': serviceName, ...data } }) => ({
|
||||
timestamp,
|
||||
data: {
|
||||
...data,
|
||||
attributes: {},
|
||||
resource: { 'service.name': serviceName },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/** The Trace view lists root spans, with the span count of the whole trace. */
|
||||
export const traceRootSpanRows = (options: SpanRowsOptions): RawRow[] =>
|
||||
traceSpanRows(options).map((row, index) => ({
|
||||
flatSpanRows(options).map((row, index) => ({
|
||||
...row,
|
||||
data: {
|
||||
...row.data,
|
||||
|
||||
@@ -59,8 +59,6 @@ export interface PostableAlertRuleV2 {
|
||||
};
|
||||
usePolicy?: boolean;
|
||||
};
|
||||
version?: string;
|
||||
source?: string;
|
||||
state?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { AlertTypes } from './alertTypes';
|
||||
import { PostableAlertRuleV2 } from './alertTypesV2';
|
||||
import { ICompositeMetricQuery } from './compositeQuery';
|
||||
import { AlertDef, Labels } from './def';
|
||||
import { Labels } from './def';
|
||||
|
||||
function toRuleTypeDTO(ruleType: string | undefined): RuletypesRuleTypeDTO {
|
||||
switch (ruleType) {
|
||||
@@ -69,7 +69,10 @@ function stripUndefinedLabels(
|
||||
export function toPostableRuleDTO(
|
||||
local: PostableAlertRuleV2,
|
||||
): RuletypesPostableRuleDTO {
|
||||
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
|
||||
const payload: Record<
|
||||
Exclude<keyof RuletypesPostableRuleDTO, 'source' | 'version'>,
|
||||
any
|
||||
> = {
|
||||
alert: local.alert,
|
||||
alertType: toAlertTypeDTO(local.alertType),
|
||||
ruleType: toRuleTypeDTO(local.ruleType),
|
||||
@@ -83,49 +86,18 @@ export function toPostableRuleDTO(
|
||||
notificationSettings: local.notificationSettings,
|
||||
evaluation: local.evaluation,
|
||||
schemaVersion: local.schemaVersion,
|
||||
source: local.source,
|
||||
version: local.version,
|
||||
disabled: local.disabled,
|
||||
description: (local as unknown as RuletypesPostableRuleDTO).description,
|
||||
};
|
||||
return payload as unknown as RuletypesPostableRuleDTO;
|
||||
}
|
||||
|
||||
export function toPostableRuleDTOFromAlertDef(
|
||||
local: AlertDef,
|
||||
): RuletypesPostableRuleDTO {
|
||||
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
|
||||
alert: local.alert,
|
||||
alertType: toAlertTypeDTO(local.alertType),
|
||||
ruleType: toRuleTypeDTO(local.ruleType),
|
||||
condition: local.condition,
|
||||
annotations: local.annotations,
|
||||
labels: stripUndefinedLabels(local.labels),
|
||||
evalWindow: local.evalWindow,
|
||||
frequency: local.frequency,
|
||||
preferredChannels: local.preferredChannels,
|
||||
notificationSettings: (local as unknown as RuletypesPostableRuleDTO)
|
||||
.notificationSettings,
|
||||
evaluation: (local as unknown as RuletypesPostableRuleDTO).evaluation,
|
||||
schemaVersion: (local as unknown as RuletypesPostableRuleDTO).schemaVersion,
|
||||
source: local.source,
|
||||
version: local.version,
|
||||
disabled: local.disabled,
|
||||
description: (local as unknown as RuletypesPostableRuleDTO).description,
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function fromRuleDTOToPostableRuleV2(
|
||||
dto: RuletypesRuleDTO,
|
||||
): PostableAlertRuleV2 {
|
||||
return dto as unknown as PostableAlertRuleV2;
|
||||
}
|
||||
|
||||
export function fromRuleDTOToAlertDef(dto: RuletypesRuleDTO): AlertDef {
|
||||
return dto as unknown as AlertDef;
|
||||
}
|
||||
|
||||
function toEQueryType(queryType: RuletypesQueryTypeDTO): EQueryType {
|
||||
switch (queryType) {
|
||||
case RuletypesQueryTypeDTO.builder:
|
||||
|
||||
@@ -56,13 +56,14 @@ func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string)
|
||||
return plannedMaintenances, nil
|
||||
}
|
||||
|
||||
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, orgID string, id valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
storableMaintenanceRule := new(alertmanagertypes.PlannedMaintenanceWithRules)
|
||||
err := r.sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(storableMaintenanceRule).
|
||||
Relation("Rules").
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id.StringValue()).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
@@ -162,11 +163,12 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
return pm, nil
|
||||
}
|
||||
|
||||
func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error {
|
||||
func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, orgID string, id valuer.UUID) error {
|
||||
_, err := r.sqlstore.
|
||||
BunDB().
|
||||
NewDelete().
|
||||
Model(new(alertmanagertypes.StorablePlannedMaintenance)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id.StringValue()).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
@@ -182,7 +184,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := r.GetPlannedMaintenanceByID(ctx, id)
|
||||
existing, err := r.GetPlannedMaintenanceByID(ctx, claims.OrgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package sqlalertmanagerstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -8,11 +9,13 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -92,3 +95,67 @@ func TestListPlannedMaintenanceSkipsInvalid(t *testing.T) {
|
||||
require.Len(t, list, 1)
|
||||
assert.Equal(t, valid.ID, list[0].ID)
|
||||
}
|
||||
|
||||
func TestPlannedMaintenanceScopedToOrg(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
sameOrg bool
|
||||
wantFound bool
|
||||
}{
|
||||
{name: "SameOrg_Found", sameOrg: true, wantFound: true},
|
||||
{name: "OtherOrg_NotFound", sameOrg: false, wantFound: false},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
now := time.Now().UTC()
|
||||
schedule := &alertmanagertypes.StorablePlannedMaintenance{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
Name: "window",
|
||||
Schedule: `{"timezone":"UTC","startTime":"2024-01-01T12:00:00Z","recurrence":{"duration":"2h","repeatType":"daily"}}`,
|
||||
OrgID: valuer.GenerateUUID().StringValue(),
|
||||
}
|
||||
_, err := store.BunDB().NewInsert().Model(schedule).Exec(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
if testCase.sameOrg {
|
||||
orgID = schedule.OrgID
|
||||
}
|
||||
maintenanceStore := NewMaintenanceStore(store, factorytest.NewSettings())
|
||||
|
||||
got, err := maintenanceStore.GetPlannedMaintenanceByID(t.Context(), orgID, schedule.ID)
|
||||
if testCase.wantFound {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, schedule.ID, got.ID)
|
||||
} else {
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound))
|
||||
}
|
||||
|
||||
postable := &alertmanagertypes.PostablePlannedMaintenance{Name: "renamed", Schedule: new(alertmanagertypes.Schedule)}
|
||||
require.NoError(t, json.Unmarshal([]byte(schedule.Schedule), postable.Schedule))
|
||||
claimsCtx := authtypes.NewContextWithClaims(t.Context(), authtypes.Claims{OrgID: orgID, Email: "user@example.com"})
|
||||
err = maintenanceStore.UpdatePlannedMaintenance(claimsCtx, postable, schedule.ID)
|
||||
if testCase.wantFound {
|
||||
require.NoError(t, err)
|
||||
got, err = maintenanceStore.GetPlannedMaintenanceByID(t.Context(), orgID, schedule.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", got.Name)
|
||||
} else {
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound))
|
||||
}
|
||||
|
||||
err = maintenanceStore.DeletePlannedMaintenance(t.Context(), orgID, schedule.ID)
|
||||
require.NoError(t, err)
|
||||
count, err := store.BunDB().NewSelect().Model(new(alertmanagertypes.StorablePlannedMaintenance)).Where("id = ?", schedule.ID.StringValue()).Count(t.Context())
|
||||
require.NoError(t, err)
|
||||
if testCase.wantFound {
|
||||
assert.Equal(t, 0, count)
|
||||
} else {
|
||||
assert.Equal(t, 1, count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ type provider struct {
|
||||
prometheusHandler prometheus.Handler
|
||||
traceDetailHandler tracedetail.Handler
|
||||
rulerHandler ruler.Handler
|
||||
rulerService ruler.Ruler
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
statsHandler statsreporter.Handler
|
||||
savedViewHandler savedview.Handler
|
||||
@@ -142,6 +143,7 @@ func NewFactory(
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
rulerService ruler.Ruler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
globalConfig global.Config,
|
||||
@@ -196,6 +198,7 @@ func NewFactory(
|
||||
llmPricingRuleHandler,
|
||||
traceDetailHandler,
|
||||
rulerHandler,
|
||||
rulerService,
|
||||
statsHandler,
|
||||
savedViewHandler,
|
||||
globalConfig,
|
||||
@@ -252,6 +255,7 @@ func newProvider(
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
rulerService ruler.Ruler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
globalConfig global.Config,
|
||||
@@ -307,6 +311,7 @@ func newProvider(
|
||||
prometheusHandler: prometheusHandler,
|
||||
traceDetailHandler: traceDetailHandler,
|
||||
rulerHandler: rulerHandler,
|
||||
rulerService: rulerService,
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
statsHandler: statsHandler,
|
||||
savedViewHandler: savedViewHandler,
|
||||
|
||||
@@ -4,8 +4,11 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -204,73 +207,166 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListDowntimeSchedules), handler.OpenAPIDef{
|
||||
ID: "ListDowntimeSchedules",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "List downtime schedules",
|
||||
Description: "This endpoint lists all planned maintenance / downtime schedules",
|
||||
RequestQuery: new(alertmanagertypes.ListPlannedMaintenanceParams),
|
||||
Response: make([]*alertmanagertypes.PlannedMaintenance, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListDowntimeSchedules, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListDowntimeSchedules",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "List downtime schedules",
|
||||
Description: "This endpoint lists all planned maintenance / downtime schedules",
|
||||
RequestQuery: new(alertmanagertypes.ListPlannedMaintenanceParams),
|
||||
Response: make([]*alertmanagertypes.PlannedMaintenance, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.GetDowntimeScheduleByID), handler.OpenAPIDef{
|
||||
ID: "GetDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Get downtime schedule by ID",
|
||||
Description: "This endpoint returns a downtime schedule by ID",
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.GetDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Get downtime schedule by ID",
|
||||
Description: "This endpoint returns a downtime schedule by ID",
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CreateDowntimeSchedule), handler.OpenAPIDef{
|
||||
ID: "CreateDowntimeSchedule",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Create downtime schedule",
|
||||
Description: "This endpoint creates a new planned maintenance / downtime schedule",
|
||||
Request: new(alertmanagertypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.CreateDowntimeSchedule, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateDowntimeSchedule",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Create downtime schedule",
|
||||
Description: "This endpoint creates a new planned maintenance / downtime schedule",
|
||||
Request: new(alertmanagertypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbCreate),
|
||||
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
|
||||
}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceRule,
|
||||
TargetIDs: ruler.DowntimeRuleIDsExtractor,
|
||||
TargetSelector: ruler.DowntimeRuleSelector,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateDowntimeScheduleByID), handler.OpenAPIDef{
|
||||
ID: "UpdateDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Update downtime schedule",
|
||||
Description: "This endpoint updates a downtime schedule by ID",
|
||||
Request: new(alertmanagertypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.UpdateDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Update downtime schedule",
|
||||
Description: "This endpoint updates a downtime schedule by ID",
|
||||
Request: new(alertmanagertypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbUpdate),
|
||||
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbDetach),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbDetach),
|
||||
}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceRule,
|
||||
TargetIDs: ruler.NewDowntimeRuleAttachExtractor(provider.rulerService),
|
||||
TargetSelector: ruler.DowntimeRuleSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
SourceResource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceMetaResourceRule,
|
||||
TargetIDs: ruler.NewDowntimeRuleDetachExtractor(provider.rulerService),
|
||||
TargetSelector: ruler.DowntimeRuleSelector,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.DeleteDowntimeScheduleByID), handler.OpenAPIDef{
|
||||
ID: "DeleteDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Delete downtime schedule",
|
||||
Description: "This endpoint deletes a downtime schedule by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v1/downtime_schedules/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.rulerHandler.DeleteDowntimeScheduleByID, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteDowntimeScheduleByID",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Delete downtime schedule",
|
||||
Description: "This endpoint deletes a downtime schedule by ID",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourcePlannedMaintenance.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourcePlannedMaintenance,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
@@ -25,14 +22,12 @@ import (
|
||||
const unmappedModelsLookback = time.Hour
|
||||
|
||||
type module struct {
|
||||
store llmpricingruletypes.Store
|
||||
querier querier.Querier
|
||||
telemetryMetadataStore telemetrytypes.MetadataStore
|
||||
logger *slog.Logger
|
||||
store llmpricingruletypes.Store
|
||||
querier querier.Querier
|
||||
}
|
||||
|
||||
func NewModule(store llmpricingruletypes.Store, querier querier.Querier, telemetryMetadataStore telemetrytypes.MetadataStore, providerSettings factory.ProviderSettings) llmpricingrule.Module {
|
||||
return &module{store: store, querier: querier, telemetryMetadataStore: telemetryMetadataStore, logger: providerSettings.Logger}
|
||||
func NewModule(store llmpricingruletypes.Store, querier querier.Querier) llmpricingrule.Module {
|
||||
return &module{store: store, querier: querier}
|
||||
}
|
||||
|
||||
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
|
||||
@@ -56,36 +51,13 @@ func (module *module) ListUnmappedModels(ctx context.Context, orgID valuer.UUID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return llmpricingruletypes.UnmappedModels(models, rules), nil
|
||||
}
|
||||
|
||||
// Collect keeps the rule counts even when ClickHouse is unreachable: the
|
||||
// counts that depend on it are skipped so the reporter retains their last value.
|
||||
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
rules, err := module.listAllRules(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats := llmpricingruletypes.NewStatsFromRules(rules)
|
||||
|
||||
if models, err := module.discoverModels(ctx, orgID); err != nil {
|
||||
module.logger.DebugContext(ctx, "failed to collect unmapped model stats", errors.Attr(err))
|
||||
} else {
|
||||
maps.Copy(stats, llmpricingruletypes.NewStatsFromUnmappedModels(llmpricingruletypes.UnmappedModels(models, rules)))
|
||||
}
|
||||
|
||||
values := make(map[string]*telemetrytypes.TelemetryFieldValues)
|
||||
for _, selector := range aiobservabilitytypes.StatsFieldValueSelectors(time.Now()) {
|
||||
fieldValues, _, err := module.telemetryMetadataStore.GetAllValues(ctx, orgID, selector)
|
||||
if err != nil {
|
||||
module.logger.DebugContext(ctx, "failed to collect gen_ai attribute stats", slog.String("key", selector.Name), errors.Attr(err))
|
||||
continue
|
||||
unmapped := make([]*llmpricingruletypes.UnmappedModel, 0, len(models))
|
||||
for _, m := range models {
|
||||
if !llmpricingruletypes.ModelMatchesAnyRule(m.ModelName, rules) {
|
||||
unmapped = append(unmapped, m)
|
||||
}
|
||||
values[selector.Name] = fieldValues
|
||||
}
|
||||
maps.Copy(stats, aiobservabilitytypes.NewStatsFromFieldValues(values))
|
||||
|
||||
return stats, nil
|
||||
return unmapped, nil
|
||||
}
|
||||
|
||||
// CreateOrUpdate saves a batch of pricing rules. isOverride decides how a rule
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -14,8 +13,6 @@ type Module interface {
|
||||
// Since this module interacts with OpAMP, it must implement the AgentFeature interface.
|
||||
agentConf.AgentFeature
|
||||
|
||||
statsreporter.StatsCollector
|
||||
|
||||
List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error)
|
||||
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error)
|
||||
CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []*llmpricingruletypes.UpdatableLLMPricingRule) (err error)
|
||||
|
||||
@@ -3,6 +3,7 @@ package querybuilder
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -14,7 +15,10 @@ import (
|
||||
)
|
||||
|
||||
func TelemetrySelector(_ context.Context, resource coretypes.Resource, id string, _ valuer.UUID) ([]coretypes.Selector, error) {
|
||||
values := telemetrytypes.NewTelemetryGrantSelectors(id)
|
||||
values, err := telemetrySelectorValues(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
selectors := make([]coretypes.Selector, 0, len(values))
|
||||
for _, value := range values {
|
||||
@@ -182,27 +186,103 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]string, 0)
|
||||
atoms := make([]WhereClauseCondition, 0)
|
||||
for _, condition := range normalized.Conditions {
|
||||
if !condition.TopLevel {
|
||||
if !condition.TopLevel || len(condition.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
key, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key)
|
||||
if !ok {
|
||||
if condition.Operator != "=" && condition.Operator != "IN" {
|
||||
continue
|
||||
}
|
||||
|
||||
if condition.Operator == "=" || condition.Operator == "IN" {
|
||||
for _, value := range condition.Values {
|
||||
ids = append(ids, queryType+"/"+key+"/"+value)
|
||||
}
|
||||
if _, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key); !ok {
|
||||
continue
|
||||
}
|
||||
atoms = append(atoms, condition)
|
||||
}
|
||||
if len(atoms) == 0 {
|
||||
return []string{typeWildcard}, nil
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
return []string{typeWildcard}, nil
|
||||
listIndex := -1
|
||||
for index, atom := range atoms {
|
||||
if atom.Operator != "IN" || len(atom.Values) < 2 {
|
||||
continue
|
||||
}
|
||||
if listIndex >= 0 {
|
||||
return []string{queryType + "/" + telemetryAtomsFilter(atoms)}, nil
|
||||
}
|
||||
listIndex = index
|
||||
}
|
||||
if listIndex < 0 {
|
||||
return []string{queryType + "/" + telemetryAtomsFilter(atoms)}, nil
|
||||
}
|
||||
|
||||
list := atoms[listIndex]
|
||||
ids := make([]string, 0, len(list.Values))
|
||||
for _, value := range list.Values {
|
||||
atoms[listIndex] = WhereClauseCondition{Key: list.Key, Operator: "=", Values: []string{value}}
|
||||
ids = append(ids, queryType+"/"+telemetryAtomsFilter(atoms))
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func telemetryAtomsFilter(atoms []WhereClauseCondition) string {
|
||||
parts := make([]string, 0, len(atoms))
|
||||
for _, atom := range atoms {
|
||||
literals := make([]string, 0, len(atom.Values))
|
||||
for _, value := range atom.Values {
|
||||
literals = append(literals, FilterStringLiteral(value))
|
||||
}
|
||||
if len(literals) == 1 {
|
||||
parts = append(parts, atom.Key+" = "+literals[0])
|
||||
continue
|
||||
}
|
||||
parts = append(parts, atom.Key+" IN ("+strings.Join(literals, ", ")+")")
|
||||
}
|
||||
slices.Sort(parts)
|
||||
|
||||
return strings.Join(slices.Compact(parts), " AND ")
|
||||
}
|
||||
|
||||
func telemetrySelectorValues(id string) ([]string, error) {
|
||||
queryType, filter, found := strings.Cut(id, "/")
|
||||
if !found || filter == coretypes.WildCardSelectorString {
|
||||
return telemetrytypes.NewTelemetryGrantSelectors(id), nil
|
||||
}
|
||||
|
||||
normalized, err := NormalizeWhereClause(filter, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values := make([]string, 0)
|
||||
for _, condition := range normalized.Conditions {
|
||||
if !condition.TopLevel {
|
||||
continue
|
||||
}
|
||||
key, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case condition.Operator == "=", condition.Operator == "IN" && len(condition.Values) == 1:
|
||||
values = append(values, queryType+"/"+key+"/"+condition.Values[0], queryType+"/"+key+"/"+coretypes.WildCardSelectorString)
|
||||
case condition.Operator == "IN":
|
||||
values = append(values, queryType+"/"+key+"/"+coretypes.WildCardSelectorString)
|
||||
}
|
||||
}
|
||||
values = append(values, queryType+"/"+coretypes.WildCardSelectorString, coretypes.WildCardSelectorString)
|
||||
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
unique := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
unique = append(unique, value)
|
||||
}
|
||||
|
||||
return unique, nil
|
||||
}
|
||||
|
||||
@@ -21,100 +21,172 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
expected []coretypes.ResourceWithID
|
||||
}{
|
||||
{
|
||||
name: "top level key equality",
|
||||
name: "TopLevelEquality_OneResource",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'checkout' AND status = 500"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'checkout'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resource prefixed key",
|
||||
name: "ResourcePrefixedKey_KeptInID",
|
||||
body: builderQueryBody("traces", "resource.signoz.workspace.key.id = 'checkout'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/resource.signoz.workspace.key.id = 'checkout'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "in atom requires every value",
|
||||
name: "SingleList_OneResourcePerValue",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('b', 'a')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'a'"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'b'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple equality atoms each require a grant",
|
||||
name: "SameKeyEqualities_OneResource",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'b' AND signoz.workspace.key.id = 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'a' AND signoz.workspace.key.id = 'b'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no filter expression",
|
||||
name: "DifferentKeyEqualities_OneResource",
|
||||
body: builderQueryBody("logs", "resource.service.name = 'checkout' AND resource.deployment.environment.name = 'prod'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.deployment.environment.name = 'prod' AND resource.service.name = 'checkout'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SingleListWithEquality_ExpandsPerValue",
|
||||
body: builderQueryBody("logs", "resource.service.name IN ('b', 'a') AND signoz.workspace.key.id = 'k'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'a' AND signoz.workspace.key.id = 'k'"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'b' AND signoz.workspace.key.id = 'k'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TwoLists_OneResource",
|
||||
body: builderQueryBody("logs", "resource.service.name IN ('a', 'b') AND signoz.workspace.key.id IN ('y', 'z')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name IN ('a', 'b') AND signoz.workspace.key.id IN ('y', 'z')"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SingleValueListBesideList_Pinned",
|
||||
body: builderQueryBody("logs", "resource.service.name IN ('a', 'b') AND signoz.workspace.key.id IN ('k')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'a' AND signoz.workspace.key.id = 'k'"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'b' AND signoz.workspace.key.id = 'k'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "SingleValueListsOnly_Pinned",
|
||||
body: builderQueryBody("logs", "resource.service.name IN ('a') AND signoz.workspace.key.id IN ('k')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'a' AND signoz.workspace.key.id = 'k'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RepeatedList_ExpandsOnce",
|
||||
body: builderQueryBody("logs", "resource.service.name IN ('checkout') AND resource.service.name IN ('checkout')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'checkout'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TwoListsOneKey_OneResource",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('a', 'b') AND resource.signoz.workspace.key.id IN ('b', 'c')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.signoz.workspace.key.id IN ('b', 'c') AND signoz.workspace.key.id IN ('a', 'b')"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OldEnvironmentSpelling_NotAnAtom",
|
||||
body: builderQueryBody("traces", "resource.deployment.environment = 'prod'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ServiceList_OneResourcePerValue",
|
||||
body: builderQueryBody("logs", "resource.service.name IN ('frontend', 'checkout')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'checkout'"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/resource.service.name = 'frontend'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BareCustomerKey_NotAnAtom",
|
||||
body: builderQueryBody("logs", "service.name = 'checkout'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NoFilter_QueryTypeWildcard",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "key atom under or does not qualify",
|
||||
name: "TopLevelOr_NoAtoms",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'a' OR status = 500"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negated key atom does not qualify",
|
||||
body: builderQueryBody("logs", "NOT signoz.workspace.key.id = 'a'"),
|
||||
name: "ListUnderOr_NoAtoms",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('a', 'b') OR status = 500"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "key inequality does not qualify",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id != 'a'"),
|
||||
name: "NestedNegationUnion_NoAtoms",
|
||||
body: builderQueryBody("logs", "NOT (NOT resource.service.name = 'checkout' AND NOT resource.service.name = 'frontend')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "value with spaces and slashes stays plaintext in the id",
|
||||
name: "ValueWithSpacesAndSlashes_Plaintext",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'check out/2'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/check out/2"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'check out/2'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "audit source maps to audit logs resource",
|
||||
name: "AuditSource_AuditLogsResource",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"signoz.workspace.key.id = 'a'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id = 'a'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ai builder query maps to traces resource without a signal",
|
||||
name: "AIBuilderQuery_TracesResource",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/signoz.workspace.key.id/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/signoz.workspace.key.id = 'checkout'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ai builder query without filter is wildcard",
|
||||
name: "AIBuilderQueryNoFilter_Wildcard",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "promql is wildcard only",
|
||||
name: "PromQL_WildcardOnly",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{"query":"up"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: "promql/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "clickhouse sql covers all signals",
|
||||
name: "ClickHouseSQL_AllSignals",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"clickhouse_sql","spec":{"query":"SELECT 1"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "clickhouse_sql/*"},
|
||||
@@ -124,29 +196,29 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "formula produces no resources",
|
||||
name: "Formula_NoResources",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_formula","spec":{"expression":"A/B"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{},
|
||||
},
|
||||
{
|
||||
name: "trace operator rides on its referenced queries",
|
||||
name: "TraceOperator_ReferencedQueries",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id = 'checkout'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "variable substitution qualifies",
|
||||
name: "VariableSubstitution_Qualifies",
|
||||
body: `{"variables":{"key":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = $key"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'checkout'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate queries dedupe",
|
||||
name: "DuplicateQueries_Deduped",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id='a'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id = 'a'"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -188,12 +260,28 @@ func TestTelemetrySelector(t *testing.T) {
|
||||
return values
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a"))
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id = 'a'"))
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/resource.signoz.workspace.key.id = 'a' AND status = 500"))
|
||||
assert.Equal(t, []string{
|
||||
"builder_query/resource.service.name/x",
|
||||
"builder_query/resource.service.name/*",
|
||||
"builder_query/signoz.workspace.key.id/z",
|
||||
"builder_query/signoz.workspace.key.id/*",
|
||||
"builder_query/*",
|
||||
"*",
|
||||
}, selectorValues("builder_query/resource.service.name = 'x' AND signoz.workspace.key.id = 'z'"))
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id IN ('a', 'b')"))
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id IN ('a')"))
|
||||
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id = 'a' OR status = 500"))
|
||||
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/service.name = 'x'"))
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id = 'a/b'"))
|
||||
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/*"))
|
||||
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query"))
|
||||
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql"))
|
||||
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql/*"))
|
||||
assert.Equal(t, []string{"*"}, selectorValues("*"))
|
||||
// a value containing "/" stays one logical segment (SplitN 3).
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a/b"))
|
||||
|
||||
_, err := TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, "builder_query/signoz.workspace.key.id = ", orgID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBuilderQueryWithFilterExpressionResources(t *testing.T) {
|
||||
@@ -205,7 +293,7 @@ func TestBuilderQueryWithFilterExpressionResources(t *testing.T) {
|
||||
{
|
||||
name: "FilterPresent_ScopedID",
|
||||
body: `{"start":1,"end":2,"filter":{"expression":"signoz.workspace.key.id = 'key-a'"}}`,
|
||||
expected: []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/key-a"}},
|
||||
expected: []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id = 'key-a'"}},
|
||||
},
|
||||
{
|
||||
name: "FilterAbsent_Wildcard",
|
||||
|
||||
@@ -2,6 +2,7 @@ package querybuilder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -39,6 +40,7 @@ const (
|
||||
type normalizedPart struct {
|
||||
text string
|
||||
join joinKind
|
||||
negated bool
|
||||
skipped bool
|
||||
}
|
||||
|
||||
@@ -48,11 +50,12 @@ type normalizedValue struct {
|
||||
}
|
||||
|
||||
type whereClauseNormalizer struct {
|
||||
variables map[string]qbtypes.VariableItem
|
||||
conditions []WhereClauseCondition
|
||||
negated bool
|
||||
orDepth int
|
||||
errors []string
|
||||
variables map[string]qbtypes.VariableItem
|
||||
conditions []WhereClauseCondition
|
||||
negated bool
|
||||
orDepth int
|
||||
negationDepth int
|
||||
errors []string
|
||||
}
|
||||
|
||||
func NormalizeWhereClause(expression string, variables map[string]qbtypes.VariableItem) (*NormalizedWhereClause, error) {
|
||||
@@ -113,8 +116,11 @@ func NormalizeWhereClause(expression string, variables map[string]qbtypes.Variab
|
||||
sort.Slice(visitor.conditions, func(i, j int) bool {
|
||||
return visitor.conditions[i].sortKey() < visitor.conditions[j].sortKey()
|
||||
})
|
||||
conditions := slices.CompactFunc(visitor.conditions, func(left, right WhereClauseCondition) bool {
|
||||
return left.sortKey() == right.sortKey()
|
||||
})
|
||||
|
||||
return &NormalizedWhereClause{Expression: part.text, Conditions: visitor.conditions}, nil
|
||||
return &NormalizedWhereClause{Expression: part.text, Conditions: conditions}, nil
|
||||
}
|
||||
|
||||
func (condition WhereClauseCondition) sortKey() string {
|
||||
@@ -197,20 +203,22 @@ func (visitor *whereClauseNormalizer) visitUnaryExpression(ctx grammar.IUnaryExp
|
||||
negated := ctx.NOT() != nil
|
||||
if negated {
|
||||
visitor.negated = !visitor.negated
|
||||
visitor.negationDepth++
|
||||
}
|
||||
|
||||
part := visitor.visitPrimary(ctx.Primary())
|
||||
|
||||
if negated {
|
||||
visitor.negated = !visitor.negated
|
||||
visitor.negationDepth--
|
||||
|
||||
if part.skipped {
|
||||
return part
|
||||
}
|
||||
if part.join != joinKindNone {
|
||||
return normalizedPart{text: "NOT (" + part.text + ")", join: joinKindNone}
|
||||
if part.join != joinKindNone || part.negated {
|
||||
return normalizedPart{text: "NOT (" + part.text + ")", negated: true}
|
||||
}
|
||||
return normalizedPart{text: "NOT " + part.text, join: joinKindNone}
|
||||
return normalizedPart{text: "NOT " + part.text, negated: true}
|
||||
}
|
||||
|
||||
return part
|
||||
@@ -439,7 +447,7 @@ func (visitor *whereClauseNormalizer) appendCondition(key, operator string, valu
|
||||
Operator: operator,
|
||||
Values: values,
|
||||
Negated: visitor.negated,
|
||||
TopLevel: visitor.orDepth == 0 && !visitor.negated,
|
||||
TopLevel: visitor.orDepth == 0 && visitor.negationDepth == 0,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,15 @@ func TestNormalizeWhereClauseEquivalenceClasses(t *testing.T) {
|
||||
},
|
||||
expected: "service.name = 'frontend' AND status = 200",
|
||||
},
|
||||
{
|
||||
name: "nested not keeps its parentheses",
|
||||
expressions: []string{
|
||||
"NOT (NOT a = 1)",
|
||||
"NOT(NOT a=1)",
|
||||
"not (not a = 1)",
|
||||
},
|
||||
expected: "NOT (NOT a = 1)",
|
||||
},
|
||||
{
|
||||
name: "operand order",
|
||||
expressions: []string{
|
||||
@@ -275,15 +284,25 @@ func TestNormalizeWhereClauseTopLevel(t *testing.T) {
|
||||
expected: map[string]bool{"service.name": false, "status": true},
|
||||
},
|
||||
{
|
||||
name: "double negation restores top level",
|
||||
name: "any negation disqualifies even when it cancels",
|
||||
expression: "NOT (NOT (service.name = 'a'))",
|
||||
expected: map[string]bool{"service.name": true},
|
||||
expected: map[string]bool{"service.name": false},
|
||||
},
|
||||
{
|
||||
name: "in condition under and is top level",
|
||||
expression: "service.name IN ('a', 'b') AND x = 1",
|
||||
expected: map[string]bool{"service.name": true, "x": true},
|
||||
},
|
||||
{
|
||||
name: "nested negation over an and is a disjunction",
|
||||
expression: "NOT (NOT service.name = 'a' AND NOT status = 500)",
|
||||
expected: map[string]bool{"service.name": false, "status": false},
|
||||
},
|
||||
{
|
||||
name: "negation over an or is a conjunction of negations",
|
||||
expression: "NOT (service.name = 'a' OR status = 500)",
|
||||
expected: map[string]bool{"service.name": false, "status": false},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
@@ -327,6 +346,8 @@ func TestNormalizeWhereClauseIdempotence(t *testing.T) {
|
||||
"a IN [3, 1, 2] AND hasAll(tags, ['a', 'b']) AND body CONTAINS 'x'",
|
||||
"duration BETWEEN 1 AND 10 OR duration > 100",
|
||||
`msg = 'with \'escapes\' and "quotes"'`,
|
||||
"NOT (NOT resource.region = 'us-east')",
|
||||
"NOT (NOT (a = 1 OR b = 2)) AND NOT c EXISTS",
|
||||
}
|
||||
|
||||
for _, expression := range expressions {
|
||||
@@ -439,3 +460,13 @@ func TestFilterStringLiteral(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWhereClauseDedupesRepeatedConditions(t *testing.T) {
|
||||
normalized, err := NormalizeWhereClause("service.name IN ('checkout') AND service.name IN ('checkout') AND status = 500 AND status = 500", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "service.name IN ('checkout') AND status = 500", normalized.Expression)
|
||||
assert.Equal(t, []WhereClauseCondition{
|
||||
{Key: "service.name", Operator: "IN", Values: []string{"checkout"}, TopLevel: true},
|
||||
{Key: "status", Operator: "=", Values: []string{"500"}, TopLevel: true},
|
||||
}, normalized.Conditions)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,79 @@ const (
|
||||
|
||||
type diffSide int
|
||||
|
||||
var DowntimeRuleIDsExtractor = coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
ids, err := coretypes.BodyJSONArray("alertIds").Fn(ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return defaultToWildcardIfEmpty(ids), nil
|
||||
}}
|
||||
|
||||
var DowntimeRuleSelector coretypes.SelectorFunc = func(ctx context.Context, resource coretypes.Resource, id string, orgID valuer.UUID) ([]coretypes.Selector, error) {
|
||||
if id == coretypes.WildCardSelectorString {
|
||||
return coretypes.WildcardSelector(ctx, resource, id, orgID)
|
||||
}
|
||||
|
||||
return coretypes.IDSelector(ctx, resource, id, orgID)
|
||||
}
|
||||
|
||||
func NewDowntimeRuleAttachExtractor(rulerService Ruler) coretypes.ResourceIDsExtractor {
|
||||
return newDowntimeDiffExtractor(rulerService, attachedSide)
|
||||
}
|
||||
|
||||
func NewDowntimeRuleDetachExtractor(rulerService Ruler) coretypes.ResourceIDsExtractor {
|
||||
return newDowntimeDiffExtractor(rulerService, detachedSide)
|
||||
}
|
||||
|
||||
func newDowntimeDiffExtractor(rulerService Ruler, side diffSide) coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := ec.Request.Context()
|
||||
orgID, err := orgIDFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scheduleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stored, err := rulerService.MaintenanceStore().GetPlannedMaintenanceByID(ctx, orgID.StringValue(), scheduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
incoming, err := DowntimeRuleIDsExtractor.Fn(ec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attached, detached := diffDowntimeRuleLinks(stored.RuleIDs, incoming)
|
||||
if side == detachedSide {
|
||||
return detached, nil
|
||||
}
|
||||
|
||||
return attached, nil
|
||||
}}
|
||||
}
|
||||
|
||||
func diffDowntimeRuleLinks(stored, incoming []string) (attached, detached []string) {
|
||||
return diffLinks(defaultToWildcardIfEmpty(stored), incoming)
|
||||
}
|
||||
|
||||
func defaultToWildcardIfEmpty(ids []string) []string {
|
||||
if len(ids) == 0 {
|
||||
return []string{coretypes.WildCardSelectorString}
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
func NewCreateRoutePolicyChannelAttachExtractor(alertmanagerService alertmanager.Alertmanager) coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
package ruler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type stubRuler struct {
|
||||
Ruler
|
||||
maintenanceStore alertmanagertypes.MaintenanceStore
|
||||
}
|
||||
|
||||
func (stub stubRuler) MaintenanceStore() alertmanagertypes.MaintenanceStore {
|
||||
return stub.maintenanceStore
|
||||
}
|
||||
|
||||
func TestDiffLinks(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -75,3 +94,125 @@ func TestDiffLinks(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffDowntimeRuleLinks(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
stored []string
|
||||
incoming []string
|
||||
wantAttached []string
|
||||
wantDetached []string
|
||||
}{
|
||||
{
|
||||
name: "AllToAll_NoChange",
|
||||
stored: nil,
|
||||
incoming: []string{"*"},
|
||||
wantAttached: []string{},
|
||||
wantDetached: []string{},
|
||||
},
|
||||
{
|
||||
name: "NarrowFromAllToOne_DetachesAllAttachesOne",
|
||||
stored: nil,
|
||||
incoming: []string{"r1"},
|
||||
wantAttached: []string{"r1"},
|
||||
wantDetached: []string{"*"},
|
||||
},
|
||||
{
|
||||
name: "WidenFromOneToAll_DetachesOneAttachesAll",
|
||||
stored: []string{"r1"},
|
||||
incoming: []string{"*"},
|
||||
wantAttached: []string{"*"},
|
||||
wantDetached: []string{"r1"},
|
||||
},
|
||||
{
|
||||
name: "SwapRules_DiffsIDs",
|
||||
stored: []string{"r1"},
|
||||
incoming: []string{"r2"},
|
||||
wantAttached: []string{"r2"},
|
||||
wantDetached: []string{"r1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
attached, detached := diffDowntimeRuleLinks(testCase.stored, testCase.incoming)
|
||||
|
||||
assert.ElementsMatch(t, testCase.wantAttached, attached)
|
||||
assert.ElementsMatch(t, testCase.wantDetached, detached)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDowntimeRuleIDsExtractor(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantIDs []string
|
||||
}{
|
||||
{
|
||||
name: "MissingAlertIds_AllRules",
|
||||
body: `{"name": "window"}`,
|
||||
wantIDs: []string{"*"},
|
||||
},
|
||||
{
|
||||
name: "EmptyAlertIds_AllRules",
|
||||
body: `{"alertIds": []}`,
|
||||
wantIDs: []string{"*"},
|
||||
},
|
||||
{
|
||||
name: "ExplicitAlertIds_Kept",
|
||||
body: `{"alertIds": ["r1", "r2"]}`,
|
||||
wantIDs: []string{"r1", "r2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
ids, err := DowntimeRuleIDsExtractor.Fn(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.wantIDs, ids)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDowntimeDiffExtractorScopesStoreByOrg(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
scheduleID := valuer.GenerateUUID()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
side diffSide
|
||||
hasClaims bool
|
||||
wantIDs []string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "AttachSide_UsesClaimsOrg", side: attachedSide, hasClaims: true, wantIDs: []string{"r2"}},
|
||||
{name: "DetachSide_UsesClaimsOrg", side: detachedSide, hasClaims: true, wantIDs: []string{"r1"}},
|
||||
{name: "NoClaims_Error", side: attachedSide, wantErr: true},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
store := alertmanagertypestest.NewMockMaintenanceStore(t)
|
||||
if testCase.hasClaims {
|
||||
store.EXPECT().GetPlannedMaintenanceByID(mock.Anything, orgID.StringValue(), scheduleID).Return(&alertmanagertypes.PlannedMaintenance{RuleIDs: []string{"r1"}}, nil)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/downtime_schedules/"+scheduleID.StringValue(), nil)
|
||||
req = mux.SetURLVars(req, map[string]string{"id": scheduleID.StringValue()})
|
||||
if testCase.hasClaims {
|
||||
req = req.WithContext(authtypes.NewContextWithClaims(req.Context(), authtypes.Claims{OrgID: orgID.StringValue()}))
|
||||
}
|
||||
|
||||
ids, err := newDowntimeDiffExtractor(stubRuler{maintenanceStore: store}, testCase.side).Fn(coretypes.ExtractorContext{Request: req, RequestBody: []byte(`{"alertIds": ["r2"]}`)})
|
||||
if testCase.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.wantIDs, ids)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +283,13 @@ func (handler *handler) GetDowntimeScheduleByID(rw http.ResponseWriter, req *htt
|
||||
return
|
||||
}
|
||||
|
||||
schedule, err := handler.ruler.MaintenanceStore().GetPlannedMaintenanceByID(ctx, id)
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
schedule, err := handler.ruler.MaintenanceStore().GetPlannedMaintenanceByID(ctx, claims.OrgID, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -356,7 +362,13 @@ func (handler *handler) DeleteDowntimeScheduleByID(rw http.ResponseWriter, req *
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.ruler.MaintenanceStore().DeletePlannedMaintenance(ctx, id)
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.ruler.MaintenanceStore().DeletePlannedMaintenance(ctx, claims.OrgID, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -163,7 +163,7 @@ func NewModules(
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, fl), providerSettings, config.TraceDetail),
|
||||
SpanMapper: spanMapper,
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier, telemetryMetadataStore, providerSettings),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
|
||||
Tag: tagModule,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ llmpricingrule.Handler }{},
|
||||
struct{ tracedetail.Handler }{},
|
||||
struct{ ruler.Handler }{},
|
||||
struct{ ruler.Ruler }{},
|
||||
struct{ statsreporter.Handler }{},
|
||||
struct{ savedview.Handler }{},
|
||||
global.Config{},
|
||||
|
||||
@@ -48,6 +48,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/querier/signozquerier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/sharder"
|
||||
"github.com/SigNoz/signoz/pkg/sharder/noopsharder"
|
||||
"github.com/SigNoz/signoz/pkg/sharder/singlesharder"
|
||||
@@ -268,6 +269,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewDeleteOrphanSARolesFactory(),
|
||||
sqlmigration.NewAddRoutePolicyTuplesFactory(sqlstore),
|
||||
sqlmigration.NewRenameSavedViewDeprecatedTraceFieldsFactory(),
|
||||
sqlmigration.NewAddDowntimeTuplesFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -332,7 +334,7 @@ func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, p
|
||||
)
|
||||
}
|
||||
|
||||
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config, gatewayService gateway.Gateway, identNResolver identn.IdentNResolver, sharder sharder.Sharder, auditor auditor.Auditor, web web.Web, alertmanagerService alertmanager.Alertmanager) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
|
||||
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config, gatewayService gateway.Gateway, identNResolver identn.IdentNResolver, sharder sharder.Sharder, auditor auditor.Auditor, web web.Web, alertmanagerService alertmanager.Alertmanager, rulerService ruler.Ruler) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
signozapiserver.NewFactory(
|
||||
orgGetter,
|
||||
@@ -374,6 +376,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.LLMPricingRuleHandler,
|
||||
handlers.TraceDetail,
|
||||
handlers.RulerHandler,
|
||||
rulerService,
|
||||
handlers.StatsHandler,
|
||||
handlers.SavedView,
|
||||
globalConfig,
|
||||
|
||||
@@ -107,6 +107,7 @@ func TestNewProviderFactories(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -570,7 +570,6 @@ func New(
|
||||
cloudIntegrationModule,
|
||||
modules.LogsPipeline,
|
||||
modules.InfraMonitoring,
|
||||
modules.LLMPricingRule,
|
||||
querier,
|
||||
authz,
|
||||
}
|
||||
@@ -623,7 +622,7 @@ func New(
|
||||
ctx,
|
||||
providerSettings,
|
||||
config.APIServer,
|
||||
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway, identNResolver, sharder, auditor, web, alertmanager),
|
||||
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway, identNResolver, sharder, auditor, web, alertmanager, rulerInstance),
|
||||
"signoz",
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
174
pkg/sqlmigration/142_add_downtime_tuples.go
Normal file
174
pkg/sqlmigration/142_add_downtime_tuples.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addDowntimeTuples struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddDowntimeTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_downtime_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addDowntimeTuples{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addDowntimeTuples) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addDowntimeTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var storeID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var orgIDs []string
|
||||
err = tx.NewSelect().
|
||||
Table("organizations").
|
||||
Column("id").
|
||||
Scan(ctx, &orgIDs)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
|
||||
crudVerbs := []string{"create", "read", "update", "delete", "list"}
|
||||
readVerbs := []string{"read", "list"}
|
||||
linkVerbs := []string{"attach", "detach"}
|
||||
|
||||
tuples := make([]migrationTuple, 0, 20)
|
||||
for _, verb := range crudVerbs {
|
||||
tuples = append(tuples,
|
||||
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", "planned-maintenance", verb},
|
||||
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", "planned-maintenance", verb},
|
||||
)
|
||||
}
|
||||
for _, verb := range readVerbs {
|
||||
tuples = append(tuples, migrationTuple{authtypes.SigNozViewerRoleName, "metaresource", "planned-maintenance", verb})
|
||||
}
|
||||
for _, objectName := range []string{"planned-maintenance", "rule"} {
|
||||
for _, verb := range linkVerbs {
|
||||
tuples = append(tuples,
|
||||
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", objectName, verb},
|
||||
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", objectName, verb},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for _, tuple := range tuples {
|
||||
entropy := ulid.DefaultEntropy()
|
||||
now := time.Now().UTC()
|
||||
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
|
||||
|
||||
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
|
||||
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
|
||||
|
||||
if isPG {
|
||||
user := "role:" + roleSubject + "#assignee"
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
|
||||
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
|
||||
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
managedRoleGroups[roleName] = string(data)
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for roleName, data := range managedRoleGroups {
|
||||
if _, err := tx.NewUpdate().
|
||||
Model(new(roles)).
|
||||
Set("transaction_groups = ?", data).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
|
||||
Where("name = ?", roleName).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addDowntimeTuples) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package aiobservabilitytypes
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// statsLookback reaches into the previous day; the metadata store rounds the
|
||||
// start down to a day boundary.
|
||||
const statsLookback = 24 * time.Hour
|
||||
|
||||
// statsAttributeKeys are the gen_ai span attributes whose recent presence the
|
||||
// stats reporter tracks under telemetry.traces.<key>.exists.
|
||||
var statsAttributeKeys = []string{GenAIRequestModel, GenAIToolName, GenAIAgentName}
|
||||
|
||||
// StatsFieldValueSelectors look up whether each tracked attribute had a string
|
||||
// value since the start of the previous day. One value is enough.
|
||||
func StatsFieldValueSelectors(now time.Time) []*telemetrytypes.FieldValueSelector {
|
||||
selectors := make([]*telemetrytypes.FieldValueSelector, 0, len(statsAttributeKeys))
|
||||
for _, key := range statsAttributeKeys {
|
||||
selectors = append(selectors, &telemetrytypes.FieldValueSelector{
|
||||
FieldKeySelector: &telemetrytypes.FieldKeySelector{
|
||||
StartUnixMilli: now.Add(-statsLookback).UnixMilli(),
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
Name: key,
|
||||
},
|
||||
Limit: 1,
|
||||
})
|
||||
}
|
||||
return selectors
|
||||
}
|
||||
|
||||
// NewStatsFromFieldValues reports whether each looked-up key had a value. Keys
|
||||
// absent from values are left out, so a failed lookup never reports false.
|
||||
func NewStatsFromFieldValues(values map[string]*telemetrytypes.TelemetryFieldValues) map[string]any {
|
||||
stats := make(map[string]any, len(values))
|
||||
for _, key := range statsAttributeKeys {
|
||||
if fieldValues := values[key]; fieldValues != nil {
|
||||
stats["telemetry.traces."+key+".exists"] = len(fieldValues.StringValues) > 0
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package aiobservabilitytypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
func TestNewStatsFromFieldValues(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
values map[string]*telemetrytypes.TelemetryFieldValues
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "NoLookups_NothingReported",
|
||||
want: map[string]any{},
|
||||
},
|
||||
{
|
||||
name: "EveryKeySeen_AllTrue",
|
||||
values: map[string]*telemetrytypes.TelemetryFieldValues{
|
||||
GenAIRequestModel: {StringValues: []string{"gpt-5-nano"}},
|
||||
GenAIToolName: {StringValues: []string{"search"}},
|
||||
GenAIAgentName: {StringValues: []string{"planner"}},
|
||||
},
|
||||
want: map[string]any{
|
||||
"telemetry.traces.gen_ai.request.model.exists": true,
|
||||
"telemetry.traces.gen_ai.tool.name.exists": true,
|
||||
"telemetry.traces.gen_ai.agent.name.exists": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EmptyAndMissingLookups_OnlyLookedUpKeysReported",
|
||||
values: map[string]*telemetrytypes.TelemetryFieldValues{
|
||||
GenAIRequestModel: {StringValues: []string{"gpt-5-nano"}},
|
||||
GenAIToolName: {},
|
||||
GenAIOperationName: {StringValues: []string{"chat"}},
|
||||
},
|
||||
want: map[string]any{
|
||||
"telemetry.traces.gen_ai.request.model.exists": true,
|
||||
"telemetry.traces.gen_ai.tool.name.exists": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.want, NewStatsFromFieldValues(testCase.values))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,19 @@ func NewMockMaintenanceStore(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockMaintenanceStore {
|
||||
if helper, ok := t.(interface{ Helper() }); ok {
|
||||
helper.Helper()
|
||||
}
|
||||
|
||||
mock := &MockMaintenanceStore{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
t.Cleanup(func() {
|
||||
if helper, ok := t.(interface{ Helper() }); ok {
|
||||
helper.Helper()
|
||||
}
|
||||
mock.AssertExpectations(t)
|
||||
})
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -75,7 +84,7 @@ type MockMaintenanceStore_CreatePlannedMaintenance_Call struct {
|
||||
// CreatePlannedMaintenance is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - postablePlannedMaintenance *alertmanagertypes.PostablePlannedMaintenance
|
||||
func (_e *MockMaintenanceStore_Expecter) CreatePlannedMaintenance(context1 interface{}, postablePlannedMaintenance interface{}) *MockMaintenanceStore_CreatePlannedMaintenance_Call {
|
||||
func (_e *MockMaintenanceStore_Expecter) CreatePlannedMaintenance(context1 any, postablePlannedMaintenance any) *MockMaintenanceStore_CreatePlannedMaintenance_Call {
|
||||
return &MockMaintenanceStore_CreatePlannedMaintenance_Call{Call: _e.mock.On("CreatePlannedMaintenance", context1, postablePlannedMaintenance)}
|
||||
}
|
||||
|
||||
@@ -108,16 +117,16 @@ func (_c *MockMaintenanceStore_CreatePlannedMaintenance_Call) RunAndReturn(run f
|
||||
}
|
||||
|
||||
// DeletePlannedMaintenance provides a mock function for the type MockMaintenanceStore
|
||||
func (_mock *MockMaintenanceStore) DeletePlannedMaintenance(context1 context.Context, uUID valuer.UUID) error {
|
||||
ret := _mock.Called(context1, uUID)
|
||||
func (_mock *MockMaintenanceStore) DeletePlannedMaintenance(context1 context.Context, s string, uUID valuer.UUID) error {
|
||||
ret := _mock.Called(context1, s, uUID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for DeletePlannedMaintenance")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) error); ok {
|
||||
r0 = returnFunc(context1, uUID)
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID) error); ok {
|
||||
r0 = returnFunc(context1, s, uUID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
@@ -131,24 +140,30 @@ type MockMaintenanceStore_DeletePlannedMaintenance_Call struct {
|
||||
|
||||
// DeletePlannedMaintenance is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - uUID valuer.UUID
|
||||
func (_e *MockMaintenanceStore_Expecter) DeletePlannedMaintenance(context1 interface{}, uUID interface{}) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
|
||||
return &MockMaintenanceStore_DeletePlannedMaintenance_Call{Call: _e.mock.On("DeletePlannedMaintenance", context1, uUID)}
|
||||
func (_e *MockMaintenanceStore_Expecter) DeletePlannedMaintenance(context1 any, s any, uUID any) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
|
||||
return &MockMaintenanceStore_DeletePlannedMaintenance_Call{Call: _e.mock.On("DeletePlannedMaintenance", context1, s, uUID)}
|
||||
}
|
||||
|
||||
func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) Run(run func(context1 context.Context, uUID valuer.UUID)) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
|
||||
func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) Run(run func(context1 context.Context, s string, uUID valuer.UUID)) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 valuer.UUID
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(valuer.UUID)
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 valuer.UUID
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(valuer.UUID)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
@@ -159,14 +174,14 @@ func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) Return(err error)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) RunAndReturn(run func(context1 context.Context, uUID valuer.UUID) error) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
|
||||
func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) RunAndReturn(run func(context1 context.Context, s string, uUID valuer.UUID) error) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetPlannedMaintenanceByID provides a mock function for the type MockMaintenanceStore
|
||||
func (_mock *MockMaintenanceStore) GetPlannedMaintenanceByID(context1 context.Context, uUID valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
ret := _mock.Called(context1, uUID)
|
||||
func (_mock *MockMaintenanceStore) GetPlannedMaintenanceByID(context1 context.Context, s string, uUID valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
ret := _mock.Called(context1, s, uUID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetPlannedMaintenanceByID")
|
||||
@@ -174,18 +189,18 @@ func (_mock *MockMaintenanceStore) GetPlannedMaintenanceByID(context1 context.Co
|
||||
|
||||
var r0 *alertmanagertypes.PlannedMaintenance
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error)); ok {
|
||||
return returnFunc(context1, uUID)
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error)); ok {
|
||||
return returnFunc(context1, s, uUID)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) *alertmanagertypes.PlannedMaintenance); ok {
|
||||
r0 = returnFunc(context1, uUID)
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID) *alertmanagertypes.PlannedMaintenance); ok {
|
||||
r0 = returnFunc(context1, s, uUID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*alertmanagertypes.PlannedMaintenance)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, valuer.UUID) error); ok {
|
||||
r1 = returnFunc(context1, uUID)
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, valuer.UUID) error); ok {
|
||||
r1 = returnFunc(context1, s, uUID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
@@ -199,24 +214,30 @@ type MockMaintenanceStore_GetPlannedMaintenanceByID_Call struct {
|
||||
|
||||
// GetPlannedMaintenanceByID is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - uUID valuer.UUID
|
||||
func (_e *MockMaintenanceStore_Expecter) GetPlannedMaintenanceByID(context1 interface{}, uUID interface{}) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
|
||||
return &MockMaintenanceStore_GetPlannedMaintenanceByID_Call{Call: _e.mock.On("GetPlannedMaintenanceByID", context1, uUID)}
|
||||
func (_e *MockMaintenanceStore_Expecter) GetPlannedMaintenanceByID(context1 any, s any, uUID any) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
|
||||
return &MockMaintenanceStore_GetPlannedMaintenanceByID_Call{Call: _e.mock.On("GetPlannedMaintenanceByID", context1, s, uUID)}
|
||||
}
|
||||
|
||||
func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) Run(run func(context1 context.Context, uUID valuer.UUID)) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
|
||||
func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) Run(run func(context1 context.Context, s string, uUID valuer.UUID)) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 valuer.UUID
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(valuer.UUID)
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 valuer.UUID
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(valuer.UUID)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
@@ -227,7 +248,7 @@ func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) Return(plannedMai
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) RunAndReturn(run func(context1 context.Context, uUID valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error)) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
|
||||
func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) RunAndReturn(run func(context1 context.Context, s string, uUID valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error)) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -268,7 +289,7 @@ type MockMaintenanceStore_ListPlannedMaintenance_Call struct {
|
||||
// ListPlannedMaintenance is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
func (_e *MockMaintenanceStore_Expecter) ListPlannedMaintenance(context1 interface{}, s interface{}) *MockMaintenanceStore_ListPlannedMaintenance_Call {
|
||||
func (_e *MockMaintenanceStore_Expecter) ListPlannedMaintenance(context1 any, s any) *MockMaintenanceStore_ListPlannedMaintenance_Call {
|
||||
return &MockMaintenanceStore_ListPlannedMaintenance_Call{Call: _e.mock.On("ListPlannedMaintenance", context1, s)}
|
||||
}
|
||||
|
||||
@@ -326,7 +347,7 @@ type MockMaintenanceStore_UpdatePlannedMaintenance_Call struct {
|
||||
// - context1 context.Context
|
||||
// - postablePlannedMaintenance *alertmanagertypes.PostablePlannedMaintenance
|
||||
// - uUID valuer.UUID
|
||||
func (_e *MockMaintenanceStore_Expecter) UpdatePlannedMaintenance(context1 interface{}, postablePlannedMaintenance interface{}, uUID interface{}) *MockMaintenanceStore_UpdatePlannedMaintenance_Call {
|
||||
func (_e *MockMaintenanceStore_Expecter) UpdatePlannedMaintenance(context1 any, postablePlannedMaintenance any, uUID any) *MockMaintenanceStore_UpdatePlannedMaintenance_Call {
|
||||
return &MockMaintenanceStore_UpdatePlannedMaintenance_Call{Call: _e.mock.On("UpdatePlannedMaintenance", context1, postablePlannedMaintenance, uUID)}
|
||||
}
|
||||
|
||||
|
||||
@@ -403,8 +403,8 @@ type ListPlannedMaintenanceParams struct {
|
||||
|
||||
type MaintenanceStore interface {
|
||||
CreatePlannedMaintenance(context.Context, *PostablePlannedMaintenance) (*PlannedMaintenance, error)
|
||||
DeletePlannedMaintenance(context.Context, valuer.UUID) error
|
||||
GetPlannedMaintenanceByID(context.Context, valuer.UUID) (*PlannedMaintenance, error)
|
||||
DeletePlannedMaintenance(context.Context, string, valuer.UUID) error
|
||||
GetPlannedMaintenanceByID(context.Context, string, valuer.UUID) (*PlannedMaintenance, error)
|
||||
UpdatePlannedMaintenance(context.Context, *PostablePlannedMaintenance, valuer.UUID) error
|
||||
ListPlannedMaintenance(context.Context, string) ([]*PlannedMaintenance, error)
|
||||
}
|
||||
|
||||
@@ -134,12 +134,16 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
// rule — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
// saved-view — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
@@ -245,12 +249,16 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindPlannedMaintenance}, WildCardSelectorString)},
|
||||
// rule — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindRule}, WildCardSelectorString)},
|
||||
// saved-view — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindSavedView}, WildCardSelectorString)},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
@@ -11,7 +10,6 @@ const (
|
||||
statKeyPanelTracesCount = "dashboard.panels.traces.count"
|
||||
statKeyPanelMetricsCount = "dashboard.panels.metrics.count"
|
||||
statKeyPanelLogsCount = "dashboard.panels.logs.count"
|
||||
statKeyPanelAICount = "dashboard.panels.ai.count"
|
||||
)
|
||||
|
||||
// panelSignalStatKeys maps a builder query's signal to the stat it contributes
|
||||
@@ -24,17 +22,14 @@ var panelSignalStatKeys = map[telemetrytypes.Signal]string{
|
||||
}
|
||||
|
||||
// NewStatsFromStorableDashboards reports the stats of stored dashboards. Rows that
|
||||
// do not decode as v2 contribute to dashboard.count only. System dashboards are
|
||||
// provisioned into every org, so they are left out of every count.
|
||||
// do not decode as v2 contribute to dashboard.count only.
|
||||
func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]any {
|
||||
stats := newStats()
|
||||
stats := newPanelStats()
|
||||
|
||||
for _, dashboard := range dashboards {
|
||||
if dashboard == nil || dashboard.Source == SourceSystem {
|
||||
if dashboard == nil {
|
||||
continue
|
||||
}
|
||||
incrementStat(stats, statKeyDashboardCount)
|
||||
|
||||
dashboardV2, err := dashboard.ToDashboardV2(nil)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -42,6 +37,7 @@ func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]
|
||||
addPanelStats(&dashboardV2.Spec, stats)
|
||||
}
|
||||
|
||||
stats[statKeyDashboardCount] = int64(len(dashboards))
|
||||
return stats
|
||||
}
|
||||
|
||||
@@ -49,26 +45,24 @@ func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]
|
||||
// created, straight off the postable spec — the create path has no reason to make
|
||||
// a storable round-trip just to be counted.
|
||||
func NewStatsFromPostableDashboardV2(postable PostableDashboardV2) map[string]any {
|
||||
stats := newStats()
|
||||
stats[statKeyDashboardCount] = int64(1)
|
||||
stats := newPanelStats()
|
||||
addPanelStats(&postable.Spec, stats)
|
||||
|
||||
stats[statKeyDashboardCount] = int64(1)
|
||||
return stats
|
||||
}
|
||||
|
||||
func newStats() map[string]any {
|
||||
func newPanelStats() map[string]any {
|
||||
return map[string]any{
|
||||
statKeyDashboardCount: int64(0),
|
||||
statKeyPanelCount: int64(0),
|
||||
statKeyPanelTracesCount: int64(0),
|
||||
statKeyPanelMetricsCount: int64(0),
|
||||
statKeyPanelLogsCount: int64(0),
|
||||
statKeyPanelAICount: int64(0),
|
||||
}
|
||||
}
|
||||
|
||||
// addPanelStats counts the panels of a v2 spec, and each panel's queries against
|
||||
// the signal they read. AI queries count only towards the AI stat.
|
||||
// the signal they read.
|
||||
func addPanelStats(spec *DashboardSpec, stats map[string]any) {
|
||||
for _, panel := range spec.Panels {
|
||||
if panel == nil {
|
||||
@@ -82,10 +76,6 @@ func addPanelStats(spec *DashboardSpec, stats map[string]any) {
|
||||
continue
|
||||
}
|
||||
for _, envelope := range composite.Queries {
|
||||
if envelope.Type == qb.QueryTypeBuilderAI {
|
||||
incrementStat(stats, statKeyPanelAICount)
|
||||
continue
|
||||
}
|
||||
if key, ok := panelSignalStatKeys[envelope.GetSignal()]; ok {
|
||||
incrementStat(stats, key)
|
||||
}
|
||||
|
||||
@@ -117,60 +117,20 @@ func TestNewStatsFromStorableDashboardsCountsCompositeSubQueries(t *testing.T) {
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
|
||||
}
|
||||
|
||||
func statsAIBuilderQuery() string {
|
||||
return `{
|
||||
// An AI builder query is always a traces query, so it counts towards traces.
|
||||
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
|
||||
aiBuilder := `{
|
||||
"kind": "time_series",
|
||||
"spec": {"plugin": {"kind": "signoz/AIBuilderQuery", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}}}
|
||||
}`
|
||||
}
|
||||
|
||||
// An AI builder query reads traces but counts only towards the AI stat.
|
||||
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
|
||||
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
|
||||
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(aiBuilder))
|
||||
|
||||
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
|
||||
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelAICount])
|
||||
}
|
||||
|
||||
func TestNewStatsFromStorableDashboardsCountsAICompositeSubQueries(t *testing.T) {
|
||||
composite := `{
|
||||
"kind": "time_series",
|
||||
"spec": {"plugin": {"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_ai_query", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_query", "spec": ` + statsBuilderQuerySpec("B", "logs") + `}
|
||||
]}}}
|
||||
}`
|
||||
dashboard := newStatsStorableV2(t, `
|
||||
"p1": `+statsPanel(composite)+`,
|
||||
"p2": `+statsPanel(statsAIBuilderQuery())+`
|
||||
`)
|
||||
|
||||
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
|
||||
|
||||
assert.Equal(t, int64(2), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(2), stats[statKeyPanelAICount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
|
||||
}
|
||||
|
||||
func TestNewStatsFromStorableDashboardsSkipsSystemDashboards(t *testing.T) {
|
||||
system := newStatsStorableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
|
||||
system.Source = SourceSystem
|
||||
system.Name = SystemDashboardNamePrefix + "overview"
|
||||
user := newStatsStorableV2(t, `"p1": `+statsPanel(statsBuilderQuery("logs")))
|
||||
|
||||
stats := NewStatsFromStorableDashboards([]*StorableDashboard{system, user})
|
||||
|
||||
assert.Equal(t, int64(1), stats[statKeyDashboardCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
|
||||
}
|
||||
|
||||
// promql and clickhouse queries carry no signal, so they land in the panel total
|
||||
@@ -255,17 +215,6 @@ func TestNewStatsFromPostableDashboardV2(t *testing.T) {
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
|
||||
}
|
||||
|
||||
func TestNewStatsFromPostableDashboardV2CountsAIBuilderQueries(t *testing.T) {
|
||||
postable := newStatsPostableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
|
||||
|
||||
stats := NewStatsFromPostableDashboardV2(postable)
|
||||
|
||||
assert.Equal(t, int64(1), stats[statKeyDashboardCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelAICount])
|
||||
}
|
||||
|
||||
func TestNewStatsFromPostableDashboardV2WithNoPanels(t *testing.T) {
|
||||
@@ -284,5 +233,4 @@ func TestNewStatsFromStorableDashboardsWithNoDashboards(t *testing.T) {
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user