Compare commits

..

5 Commits

Author SHA1 Message Date
nityanandagohain
411717b72b fix: limit values to just 1 2026-11-02 21:27:16 +05:30
nityanandagohain
3deb3351bb Merge remote-tracking branch 'origin/issue_577' into issue_577 2026-11-02 21:16:33 +05:30
nityanandagohain
ddc8a5ecb7 fix: address comment 2026-11-02 21:15:36 +05:30
Nityananda Gohain
55d892c075 Merge branch 'main' into issue_577 2026-11-02 21:04:01 +05:30
nityanandagohain
89df02dac7 feat(stats): report AI observability usage from the stats endpoint 2026-11-02 20:55:36 +05:30
129 changed files with 5141 additions and 2193 deletions

View File

@@ -15079,9 +15079,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- planned-maintenance:list
- VIEWER
- tokenizer:
- planned-maintenance:list
- VIEWER
summary: List downtime schedules
tags:
- downtimeschedules
@@ -15136,13 +15136,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- planned-maintenance:create
- planned-maintenance:attach
- rule:attach
- EDITOR
- tokenizer:
- planned-maintenance:create
- planned-maintenance:attach
- rule:attach
- EDITOR
summary: Create downtime schedule
tags:
- downtimeschedules
@@ -15187,9 +15183,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- planned-maintenance:delete
- EDITOR
- tokenizer:
- planned-maintenance:delete
- EDITOR
summary: Delete downtime schedule
tags:
- downtimeschedules
@@ -15245,9 +15241,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- planned-maintenance:read
- VIEWER
- tokenizer:
- planned-maintenance:read
- VIEWER
summary: Get downtime schedule by ID
tags:
- downtimeschedules
@@ -15302,17 +15298,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- planned-maintenance:update
- planned-maintenance:attach
- planned-maintenance:detach
- rule:attach
- rule:detach
- EDITOR
- tokenizer:
- planned-maintenance:update
- planned-maintenance:attach
- planned-maintenance:detach
- rule:attach
- rule:detach
- EDITOR
summary: Update downtime schedule
tags:
- downtimeschedules

View File

@@ -53,6 +53,7 @@ export enum QueryParams {
variables = 'variables',
version = 'version',
source = 'source',
showClassicCreateAlertsPage = 'showClassicCreateAlertsPage',
isTestAlert = 'isTestAlert',
yAxisUnit = 'yAxisUnit',
ruleName = 'ruleName',

View File

@@ -3,6 +3,7 @@ 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';
@@ -590,6 +591,7 @@ export const listViewInitialTraceQuery: Query = {
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
offset: 0,
pageSize: 10,
selectColumns: defaultTraceSelectedColumns,
},
],
},

View File

@@ -26,6 +26,7 @@ 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',

View File

@@ -0,0 +1,136 @@
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',
);
});
});
});

View File

@@ -0,0 +1,60 @@
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',
);
});
});

View File

@@ -23,6 +23,25 @@ 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({
@@ -57,6 +76,7 @@ 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', () => {
@@ -75,6 +95,20 @@ 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) {
@@ -90,6 +124,7 @@ 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();
});
@@ -114,6 +149,9 @@ 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';
}
@@ -122,6 +160,7 @@ 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', () => {
@@ -245,5 +284,27 @@ 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();
});
});
});

View File

@@ -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: RuletypesRuleTypeDTO.anomaly_rule,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
condition: {
compositeQuery: {
builderQueries: {
@@ -201,6 +201,14 @@ 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',

View File

@@ -1,13 +1,14 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Tabs, TabsProps } from 'antd';
import { Form, 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';
@@ -15,20 +16,25 @@ 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 { ALERT_TYPE_BREADCRUMB_TITLE } from './defaults';
import { ALERTS_VALUES_MAP, 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;
@@ -40,7 +46,7 @@ function CreateRules(): JSX.Element {
}, [isTypeSelectionMode]);
const alertType = useMemo(() => {
if (ruleTypeFromURL === RuletypesRuleTypeDTO.anomaly_rule) {
if (ruleTypeFromURL === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
return AlertTypes.ANOMALY_BASED_ALERT;
}
if (!alertTypeFromURL) {
@@ -53,6 +59,14 @@ 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);
@@ -66,10 +80,13 @@ function CreateRules(): JSX.Element {
const handleSelectType = useCallback(
(type: AlertTypes, newTab?: boolean): void => {
if (type === AlertTypes.ANOMALY_BASED_ALERT) {
queryParams.set(QueryParams.ruleType, RuletypesRuleTypeDTO.anomaly_rule);
queryParams.set(
QueryParams.ruleType,
AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
);
queryParams.set(QueryParams.alertType, AlertTypes.METRICS_BASED_ALERT);
} else {
queryParams.set(QueryParams.ruleType, RuletypesRuleTypeDTO.threshold_rule);
queryParams.set(QueryParams.ruleType, AlertDetectionTypes.THRESHOLD_ALERT);
queryParams.set(QueryParams.alertType, type);
}
@@ -82,8 +99,26 @@ 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, alertType]);
}, [
isTypeSelectionMode,
handleSelectType,
showClassicCreateAlertsPageFlag,
alertType,
formInstance,
initialAlertValue,
]);
const items: TabsProps['items'] = [
{

View File

@@ -1,7 +1,14 @@
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';
@@ -15,6 +22,8 @@ function CreateAlertHeader(): JSX.Element {
const alertRuleContext = useAlertRuleOptional();
const { currentQuery } = useQueryBuilder();
const { safeNavigate } = useSafeNavigate();
const urlQuery = useUrlQuery();
const groupByLabels = useMemo(() => {
const labels = new Array<string>();
@@ -37,6 +46,14 @@ 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 })}
@@ -44,6 +61,15 @@ 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">

View File

@@ -1,6 +1,9 @@
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';
@@ -8,6 +11,11 @@ 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,
@@ -93,4 +101,37 @@ 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();
});
});

View File

@@ -556,6 +556,8 @@ describe('Footer utils', () => {
},
ruleType: 'threshold_rule',
schemaVersion: 'v2alpha1',
source: 'http://localhost/',
version: 'v5',
});
});

View File

@@ -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 = RuletypesRuleTypeDTO.threshold_rule;
let ruleType: string = AlertDetectionTypes.THRESHOLD_ALERT;
if (query.queryType === EQueryType.PROM) {
ruleType = 'promql_rule';
}
@@ -292,7 +292,9 @@ export function buildCreateThresholdAlertRulePayload(
summary: notificationSettings.description,
},
notificationSettings: notificationSettingsProps,
version: 'v5',
schemaVersion: 'v2alpha1',
source: window?.location.toString(),
};
}
@@ -319,7 +321,7 @@ export function buildCreateAnomalyAlertRulePayload(
return {
...payload,
ruleType: RuletypesRuleTypeDTO.anomaly_rule,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
alertType: AlertTypes.METRICS_BASED_ALERT,
condition: {
...payload.condition,

View File

@@ -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]: RuletypesRuleTypeDTO.threshold_rule,
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
},
undefined,
true,
@@ -252,7 +252,7 @@ describe('QuerySection', () => {
expect.any(Object),
{
[QueryParams.alertType]: AlertTypes.TRACES_BASED_ALERT,
[QueryParams.ruleType]: RuletypesRuleTypeDTO.threshold_rule,
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
},
undefined,
true,
@@ -376,7 +376,7 @@ describe('QuerySection', () => {
expect(result[1]).toStrictEqual({
[QueryParams.alertType]: AlertTypes.METRICS_BASED_ALERT,
[QueryParams.ruleType]: RuletypesRuleTypeDTO.threshold_rule,
[QueryParams.ruleType]: AlertDetectionTypes.THRESHOLD_ALERT,
});
});

View File

@@ -1,4 +1,4 @@
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { AlertDetectionTypes } from 'container/FormAlertRules';
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
? RuletypesRuleTypeDTO.anomaly_rule
: RuletypesRuleTypeDTO.threshold_rule,
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
: AlertDetectionTypes.THRESHOLD_ALERT,
condition: {
...initialAlertDef.condition,
targetUnit: thresholdState.thresholds?.[0].unit,

View File

@@ -1,3 +1,4 @@
import { ENTITY_VERSION_V5 } from 'constants/app';
import {
initialQueryBuilderFormValuesMap,
initialQueryPromQLData,
@@ -38,6 +39,7 @@ const defaultEvaluation: PostableAlertRuleV2['evaluation'] = {
export const defaultPostableAlertRuleV2: PostableAlertRuleV2 = {
alertType: AlertTypes.METRICS_BASED_ALERT,
version: ENTITY_VERSION_V5,
schemaVersion: NEW_ALERT_SCHEMA_VERSION,
condition: {
compositeQuery: {

View File

@@ -10,7 +10,6 @@ import {
useRef,
useState,
} from 'react';
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { useAppLocation } from 'lib/router/useAppLocation';
import {
useCreateRule,
@@ -18,6 +17,7 @@ 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
? RuletypesRuleTypeDTO.anomaly_rule
: RuletypesRuleTypeDTO.threshold_rule,
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
: AlertDetectionTypes.THRESHOLD_ALERT,
},
undefined,
true,

View File

@@ -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,7 +251,8 @@ export {
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AlertThresholdState {
const isAnomalyRule = alertDef.ruleType === RuletypesRuleTypeDTO.anomaly_rule;
const isAnomalyRule =
alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
return {
...INITIAL_ALERT_THRESHOLD_STATE,
thresholds:
@@ -286,7 +287,7 @@ export function getThresholdStateFromAlertDef(
export function getAlertTypeFromAlertDef(
alertDef: PostableAlertRuleV2 | undefined,
): AlertTypes {
if (alertDef?.ruleType === RuletypesRuleTypeDTO.anomaly_rule) {
if (alertDef?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
return AlertTypes.ANOMALY_BASED_ALERT;
}
return alertDef?.alertType as AlertTypes;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
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;
`;

View File

@@ -2,7 +2,6 @@ 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';
@@ -46,6 +45,7 @@ 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 === RuletypesRuleTypeDTO.anomaly_rule;
alertDef?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
const chartDataAvailable =
chartData &&

View File

@@ -0,0 +1,146 @@
.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;
}

View File

@@ -0,0 +1,10 @@
.rule-definition {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.rule-unit-selector {
width: 150px;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
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;
`;

View File

@@ -0,0 +1,9 @@
export interface ILabelRecord {
key: string;
value: string;
}
export interface IOption {
label: string;
value: string;
}

View File

@@ -0,0 +1,54 @@
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;
};

View File

@@ -1,11 +1,80 @@
import { Card } from 'antd';
import { Button, Card, Col, Form, Input, Select } from 'antd';
import { Typography } from '@signozhq/ui/typography';
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;
@@ -16,3 +85,19 @@ 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;
`;

View File

@@ -0,0 +1,91 @@
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,
};
};

View File

@@ -0,0 +1,14 @@
// 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);
});
});

View File

@@ -1,4 +1,7 @@
import { SelectProps } from 'antd';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import getStep from 'lib/getStep';
import {
IBuilderFormula,
IBuilderQuery,
@@ -7,6 +10,48 @@ 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<

View File

@@ -4,7 +4,8 @@ import {
InframonitoringtypesClusterRecordDTO,
InframonitoringtypesNodeConditionDTO,
} from 'api/generated/services/sigNoz.schemas';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';

View File

@@ -12,11 +12,9 @@ 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,
InfraMonitoringEntity,
} from '../constants';
import { INFRA_MONITORING_ATTR_KEYS } 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';

View File

@@ -146,114 +146,13 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
await expect(screen.findByText('promql/*')).resolves.toBeInTheDocument();
});
it('defaults the key to signoz.workspace.key.id', async () => {
it('hardcodes the key and does not let it be edited', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
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.');
const keyInput = screen.getByTestId('wizard-key-input-logs-read');
expect(keyInput).toHaveValue('signoz.workspace.key.id');
expect(keyInput).toBeDisabled();
});
it('hides Key field for query types that do not support key scoping', async () => {
@@ -264,7 +163,7 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
await user.click(await screen.findByText('ClickHouse SQL'));
expect(
screen.queryByTestId('wizard-key-select-logs-read'),
screen.queryByTestId('wizard-key-input-logs-read'),
).not.toBeInTheDocument();
});
@@ -389,18 +288,38 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
expect(screen.getByRole('checkbox', { name: 'Any value' })).toBeChecked();
});
it('restores the selected key in the selector once the value changes', async () => {
it('keeps the key input hardcoded when the selector uses another 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');
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(screen.getByTestId('wizard-value-input-logs-read'), '2');
expect(selectorInput).toHaveValue(
'builder_query/signoz.workspace.key.id/web-12',
'builder_query/signoz.workspace.key.id/frontend2',
);
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toHaveAttribute(
'aria-disabled',

View File

@@ -30,26 +30,17 @@ export const QUERY_TYPES: readonly QueryTypeOption[] = [
export const DEFAULT_QUERY_TYPE: QueryTypeId = 'builder_query';
// 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 SUPPORTED_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;
}

View File

@@ -15,7 +15,7 @@ import { Typography } from '@signozhq/ui/typography';
import {
ANY_RESOURCE_VALUE,
QUERY_TYPES,
SUPPORTED_GRANT_KEYS,
SUPPORTED_GRANT_KEY,
} from './TelemetrySelectorWizard.constants';
import { isQueryTypeAvailable } from './TelemetrySelectorWizard.utils';
import useTelemetrySelectorWizard from './useTelemetrySelectorWizard';
@@ -38,7 +38,6 @@ function TelemetrySelectorWizard({
open,
queryType,
selectedQueryType,
grantKey,
value,
selector,
isAnyResource,
@@ -47,7 +46,6 @@ function TelemetrySelectorWizard({
canAdd,
handleOpenChange,
handleQueryTypeChange,
handleKeyChange,
handleValueChange,
handleAnyResourceChange,
handleSelectorChange,
@@ -132,22 +130,12 @@ function TelemetrySelectorWizard({
<Typography as="label" weight="medium">
Key
</Typography>
<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>
<Input
value={SUPPORTED_GRANT_KEY}
readOnly
disabled
testId={`wizard-key-input-${testId}`}
/>
</div>
)}

View File

@@ -2,14 +2,13 @@ import { AuthZResource } from 'lib/authz/hooks/useAuthZ/types';
import {
ANY_RESOURCE_VALUE,
DEFAULT_GRANT_KEY,
ParsedSelector,
QUERY_TYPES,
QueryTypeId,
QueryTypeOption,
SelectorDraft,
SelectorValidation,
SUPPORTED_GRANT_KEYS,
SUPPORTED_GRANT_KEY,
} from './TelemetrySelectorWizard.constants';
const METRIC_RESOURCES: ReadonlySet<AuthZResource> = new Set<AuthZResource>([
@@ -38,20 +37,6 @@ 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('/');
@@ -62,18 +47,14 @@ function splitSelector(selector: string): string[] {
return [parts[0], parts[1], parts.slice(2).join('/')];
}
export function buildSelector({
queryType,
key,
value,
}: SelectorDraft): string {
export function buildSelector({ queryType, value }: SelectorDraft): string {
const trimmedValue = value.trim();
if (!supportsKeyScoping(queryType) || !trimmedValue) {
return `${queryType}/${ANY_RESOURCE_VALUE}`;
}
return `${queryType}/${key}/${trimmedValue}`;
return `${queryType}/${SUPPORTED_GRANT_KEY}/${trimmedValue}`;
}
export function parseSelector(selector: string): ParsedSelector {
@@ -81,7 +62,6 @@ 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] : '',
};
}
@@ -121,9 +101,7 @@ export function validateSelector(selector: string): SelectorValidation {
}
return {
message: `Use <query-type>/${ANY_RESOURCE_VALUE} or <query-type>/<key>/<value> with one of: ${SUPPORTED_GRANT_KEYS.join(
', ',
)}.`,
message: `Use <query-type>/${ANY_RESOURCE_VALUE} or <query-type>/${SUPPORTED_GRANT_KEY}/<value>.`,
isError: false, // intentionally not an error
};
}
@@ -134,8 +112,7 @@ export function validateSelector(selector: string): SelectorValidation {
};
}
const [, rawKey, value] = parts;
const key = foldGrantKey(rawKey);
const [, key, value] = parts;
if (value === ANY_RESOURCE_VALUE) {
return {
@@ -151,15 +128,6 @@ 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,
@@ -167,5 +135,5 @@ export function validateSelector(selector: string): SelectorValidation {
}
export function getDefaultSelector(queryType: QueryTypeId): string {
return buildSelector({ queryType, key: DEFAULT_GRANT_KEY, value: '' });
return buildSelector({ queryType, value: '' });
}

View File

@@ -2,7 +2,6 @@ import { useCallback, useMemo, useState } from 'react';
import {
ANY_RESOURCE_VALUE,
DEFAULT_GRANT_KEY,
DEFAULT_QUERY_TYPE,
QueryTypeId,
QueryTypeOption,
@@ -13,7 +12,6 @@ import {
getDefaultSelector,
getQueryTypeOption,
isAnyResourceValue,
isSupportedGrantKey,
parseSelector,
validateSelector,
} from './TelemetrySelectorWizard.utils';
@@ -26,7 +24,6 @@ interface UseTelemetrySelectorWizardResult {
open: boolean;
queryType: QueryTypeId;
selectedQueryType: QueryTypeOption | undefined;
grantKey: string;
value: string;
selector: string;
isAnyResource: boolean;
@@ -35,7 +32,6 @@ 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;
@@ -48,7 +44,6 @@ 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),
@@ -63,17 +58,10 @@ function useTelemetrySelectorWizard({
const validation = useMemo(() => validateSelector(selector), [selector]);
const applyDraft = useCallback(
(nextQueryType: QueryTypeId, nextKey: string, nextValue: string): void => {
(nextQueryType: QueryTypeId, nextValue: string): void => {
setQueryType(nextQueryType);
setGrantKey(nextKey);
setValue(nextValue);
setSelector(
buildSelector({
queryType: nextQueryType,
key: nextKey,
value: nextValue,
}),
);
setSelector(buildSelector({ queryType: nextQueryType, value: nextValue }));
},
[],
);
@@ -83,30 +71,23 @@ function useTelemetrySelectorWizard({
const selected = (Array.isArray(next) ? next[0] : next) as QueryTypeId;
const keepsValue = getQueryTypeOption(selected)?.supportsKeyScoping ?? false;
applyDraft(selected, grantKey, keepsValue ? value : '');
applyDraft(selected, keepsValue ? value : '');
},
[applyDraft, grantKey, value],
);
const handleKeyChange = useCallback(
(next: string | string[]): void => {
applyDraft(queryType, Array.isArray(next) ? next[0] : next, value);
},
[applyDraft, queryType, value],
[applyDraft, value],
);
const handleValueChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
applyDraft(queryType, grantKey, event.target.value);
applyDraft(queryType, event.target.value);
},
[applyDraft, queryType, grantKey],
[applyDraft, queryType],
);
const handleAnyResourceChange = useCallback(
(checked: boolean): void => {
applyDraft(queryType, grantKey, checked ? ANY_RESOURCE_VALUE : '');
applyDraft(queryType, checked ? ANY_RESOURCE_VALUE : '');
},
[applyDraft, queryType, grantKey],
[applyDraft, queryType],
);
const handleSelectorChange = useCallback(
@@ -118,9 +99,6 @@ function useTelemetrySelectorWizard({
if (parsed.queryType) {
setQueryType(parsed.queryType);
}
if (parsed.key && isSupportedGrantKey(parsed.key)) {
setGrantKey(parsed.key);
}
setValue(parsed.value);
},
[],
@@ -131,7 +109,6 @@ function useTelemetrySelectorWizard({
if (!nextOpen) {
setQueryType(DEFAULT_QUERY_TYPE);
setGrantKey(DEFAULT_GRANT_KEY);
setValue('');
setSelector(getDefaultSelector(DEFAULT_QUERY_TYPE));
}
@@ -161,7 +138,6 @@ function useTelemetrySelectorWizard({
open,
queryType,
selectedQueryType,
grantKey,
value,
selector,
isAnyResource: isAnyResourceValue(value),
@@ -170,7 +146,6 @@ function useTelemetrySelectorWizard({
canAdd: !validation.isError,
handleOpenChange,
handleQueryTypeChange,
handleKeyChange,
handleValueChange,
handleAnyResourceChange,
handleSelectorChange,

View File

@@ -1,15 +1,9 @@
import { ENVIRONMENT } from 'constants/env';
import {
initialQueriesMap,
initialQueryBuilderFormTraceOperatorValues,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { initialQueriesMap, 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';
@@ -30,7 +24,7 @@ jest.mock('react-redux', () => {
});
// List columns come from the options menu (server-synced preferences). Pin them
// so the expected columns render, independent of that API.
// so the query fires and the expected columns render, independent of that API.
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
__esModule: true,
default: (): unknown => ({
@@ -41,8 +35,6 @@ 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() } },
@@ -56,19 +48,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
@@ -76,8 +68,6 @@ const listRows = [
response_status_code: '',
span_id: '5704353737b6778e',
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
attributes: {},
resource: { 'service.name': 'demo-app' },
},
},
];
@@ -94,9 +84,7 @@ const mockSuccess = (rows: unknown[] = listRows): void => {
);
};
const renderListView = (
stagedQuery: Query = initialQueriesMap.traces,
): ReturnType<typeof render> =>
const renderListView = (): ReturnType<typeof render> =>
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
<ListView
@@ -110,8 +98,8 @@ const renderListView = (
initialRoute: '/traces-explorer',
queryBuilderOverrides: {
panelType: PANEL_TYPES.LIST,
stagedQuery,
currentQuery: stagedQuery,
stagedQuery: initialQueriesMap.traces,
currentQuery: initialQueriesMap.traces,
redirectWithQueryBuilderData: jest.fn(),
} as any,
},
@@ -130,10 +118,6 @@ 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$/);
@@ -146,37 +130,4 @@ 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');
});
});
});

View File

@@ -100,20 +100,23 @@ function ListView({
const paginationConfig =
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
// 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 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],
);
const queryKey = useMemo(
() => [
@@ -124,12 +127,14 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
],
[
stagedQuery,
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
],
@@ -150,6 +155,7 @@ function ListView({
},
tableParams: {
pagination: paginationConfig,
selectColumns: options?.selectColumns,
},
},
// ENTITY_VERSION_V4,
@@ -158,7 +164,10 @@ function ListView({
queryKey,
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
!timeRangeUpdateLoading &&
!!stagedQuery &&
panelType === PANEL_TYPES.LIST &&
!!options?.selectColumns?.length,
},
);

View File

@@ -1,7 +1,6 @@
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';
@@ -17,10 +16,7 @@ export function getFieldColumn(
return {
id: buildCompositeKey(name, fieldContext, fieldDataType),
header: 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),
accessorFn: (row): unknown => row[name],
enableMove: !isTimestamp,
enableRemove: !isTimestamp,
canBeHidden: !isTimestamp,

View File

@@ -1,6 +1,7 @@
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';
@@ -15,6 +16,7 @@ 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';
@@ -77,6 +79,8 @@ 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 />;
@@ -90,9 +94,9 @@ function AlertDetails(): JSX.Element {
initialAlertState={initialAlertState}
>
<div
className="alert-details alert-details-v2"
className={classNames('alert-details', { 'alert-details-v2': isV2Alert })}
data-testid="alert-details-root"
data-schema-version={alertRuleDetails?.schemaVersion}
data-schema-version={isV2Alert ? NEW_ALERT_SCHEMA_VERSION : 'v1'}
>
<AlertBreadcrumb
className="alert-details__breadcrumb"

View File

@@ -5,16 +5,20 @@ 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, Trash2 } from '@signozhq/icons';
import { Copy, Ellipsis, PenLine, 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';
@@ -25,14 +29,45 @@ function AlertActionButtons({
ruleId: string;
alertDetails: AlertHeaderProps['alertDetails'];
}): JSX.Element {
const { alertRuleState, setAlertRuleState } = useAlertRule();
const { alertRuleState, setAlertRuleState, alertRuleName, setAlertRuleName } =
useAlertRule();
const [intermediateName, setIntermediateName] = useState<string>(
alertDetails.alert,
);
const [isRenameAlertOpen, setIsRenameAlertOpen] = useState<boolean>(false);
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 menuItems: DropdownItemType[] = [
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(
{
type: 'item',
value: 'clone-rule',
@@ -48,7 +83,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<
@@ -62,6 +97,12 @@ 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), []);
@@ -72,39 +113,50 @@ 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"
/>
)}
</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}
/>
}
<>
<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"
/>
</Dropdown>
</span>
</Tooltip>
</div>
)}
</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}
/>
</>
);
}

View File

@@ -0,0 +1,99 @@
.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;
}
}
}
}
}
}

View File

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

View File

@@ -7,4 +7,35 @@
.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;
}
}
}

View File

@@ -1,10 +1,17 @@
import { useEffect } from 'react';
import { useEffect, useMemo } 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 { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import {
NEW_ALERT_SCHEMA_VERSION,
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';
@@ -12,8 +19,8 @@ export type AlertHeaderProps = {
alertDetails: RuletypesRuleDTO | PostableAlertRuleV2;
};
function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
const { alert: alertName } = alertDetails;
const { alertRuleName, setAlertRuleName } = useAlertRule();
const { state, alert: alertName, labels } = alertDetails;
const { alertRuleState, alertRuleName, setAlertRuleName } = useAlertRule();
useEffect(() => {
if (alertRuleName === undefined && alertName) {
@@ -23,9 +30,50 @@ 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">
<CreateAlertV2Header />
{isV2Alert ? <CreateAlertV2Header /> : CreateAlertV1Header}
<div className="alert-info__action-buttons">
<AlertActionButtons
alertDetails={alertDetails}

View File

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

View File

@@ -0,0 +1,69 @@
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,
};

View File

@@ -7,9 +7,11 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
cloneRuleByID,
deleteRuleByID,
getGetRuleByIDQueryKey,
getGetRuleHistoryTimelineQueryOptions,
invalidateGetRuleByID,
invalidateListRules,
updateRuleByID,
useGetRuleByID,
useGetRuleHistoryOverallStatus,
useGetRuleHistoryStats,
@@ -50,7 +52,8 @@ import EditRules from 'pages/EditRules';
import BetaTag from 'periscope/components/BetaTag/BetaTag';
import { useAlertRule } from 'providers/Alert';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
import { AlertDef, AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
import APIError from 'types/api/error';
import { nanoToMilli } from 'utils/timeUtils';
import { Typography } from '@signozhq/ui/typography';
@@ -515,6 +518,60 @@ 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,
}: {

View File

@@ -6,7 +6,7 @@
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
import { rest } from 'msw';
import { countControl } from '@/storybook/controls/controls';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
@@ -23,9 +23,11 @@ import {
import {
alertRuleByIdResponse,
ALERT_SCHEMAS,
notificationChannelsResponse,
CHANNEL_MAX,
FIRST_RULE_NAME,
type AlertSchema,
} from '../../stories/__story_mockdata__/alerts';
const STORY_RULE_ID = 'rule-1';
@@ -75,6 +77,13 @@ 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(
@@ -133,6 +142,7 @@ export const alertHistoryMocks = defineStoryMocks({
alertRuleByIdResponse(String(req.params.id), {
severity: 'mixed',
state: 'mixed',
schema: values.alertSchema,
}),
),
),

View File

@@ -11,11 +11,13 @@ 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,
@@ -39,6 +41,13 @@ 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:
@@ -85,6 +94,7 @@ export const alertOverviewMocks = defineStoryMocks({
alertRuleByIdResponse(String(req.params.id), {
severity: values.ruleSeverity,
state: values.ruleState,
schema: values.alertSchema,
kind: values.ruleKind,
}),
),

View File

@@ -39,6 +39,11 @@ 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.
@@ -63,6 +68,11 @@ 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' },

View File

@@ -70,6 +70,10 @@ 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];
@@ -351,6 +355,7 @@ const ruleName = (index: number): string => {
export interface RuleShape {
severity: SeverityChoice;
state: RuleStateChoice;
schema?: AlertSchema;
kind?: RuleKind;
}
@@ -391,7 +396,10 @@ const buildRule = (index: number, shape: RuleShape): RuletypesRuleDTO => {
updatedAt: ago((index + 1) * HOUR),
createdBy: 'ada@signoz.io',
updatedBy: 'grace@signoz.io',
schemaVersion: RuletypesRuleDTOSchemaVersion.v2alpha1,
// classic rows predate schemaVersion, which the spec now requires
schemaVersion: (shape.schema === 'classic'
? undefined
: RuletypesRuleDTOSchemaVersion.v2alpha1) as RuletypesRuleDTOSchemaVersion,
version: 'v5',
source: 'http://localhost/alerts',
preferredChannels: ['ops-slack'],

View File

@@ -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,7 +28,8 @@ 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 and everything else opens the form.
* the picker, `classic-form` opts back into the classic form, and everything else
* opens the current one.
*/
const ALERT_MODES = [
'select-type',
@@ -37,6 +38,7 @@ const ALERT_MODES = [
'traces',
'exceptions',
'anomaly',
'classic-form',
] as const;
type AlertMode = (typeof ALERT_MODES)[number];
@@ -47,6 +49,7 @@ 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 => {
@@ -60,11 +63,15 @@ const routeFor = (mode: AlertMode): string => {
[QueryParams.alertType]: alertType,
[QueryParams.ruleType]:
mode === 'anomaly'
? RuletypesRuleTypeDTO.anomaly_rule
: RuletypesRuleTypeDTO.threshold_rule,
? AlertDetectionTypes.ANOMALY_DETECTION_ALERT
: AlertDetectionTypes.THRESHOLD_ALERT,
[QueryParams.relativeTime]: '6h',
});
if (mode === 'classic-form') {
params.set(QueryParams.showClassicCreateAlertsPage, 'true');
}
return `${ROUTES.ALERTS_NEW}?${params.toString()}`;
};

View File

@@ -81,6 +81,11 @@ 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

View File

@@ -11,12 +11,19 @@ import { AxiosError } from 'axios';
import Spinner from 'components/Spinner';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import EditAlertV2 from 'container/EditAlertV2';
import EditRulesContainer from 'container/EditRules';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { navigate } from 'lib/router/navigation';
import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
import {
NEW_ALERT_SCHEMA_VERSION,
PostableAlertRuleV2,
} from 'types/api/alerts/alertTypesV2';
import {
fromRuleDTOToAlertDef,
fromRuleDTOToPostableRuleV2,
} from 'types/api/alerts/convert';
import './EditRules.styles.scss';
@@ -89,13 +96,17 @@ function EditRules(): JSX.Element {
return <Spinner tip="Loading Rules..." />;
}
const initialAlert = fromRuleDTOToPostableRuleV2(ruleData);
let initialV2AlertValue: PostableAlertRuleV2 | null = null;
if (ruleData.schemaVersion === NEW_ALERT_SCHEMA_VERSION) {
initialV2AlertValue = fromRuleDTOToPostableRuleV2(ruleData);
}
return (
<div className="edit-rules-container">
<EditAlertV2
initialAlert={initialAlert}
alertType={initialAlert.alertType}
<EditRulesContainer
ruleId={ruleId || ''}
initialValue={fromRuleDTOToAlertDef(ruleData)}
initialV2AlertValue={initialV2AlertValue}
/>
</div>
);

View File

@@ -0,0 +1,150 @@
/**
* 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}` }),
);
};

View File

@@ -0,0 +1,137 @@
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?',
);
},
};

View File

@@ -5,7 +5,7 @@
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { RoleType } from 'types/roles';
import { choiceControl } from '@/storybook/controls/controls';
@@ -116,21 +116,6 @@ 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(

View File

@@ -6,7 +6,6 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
openLogsCard,
openSelectorWizard,
roleEditorMocks,
save,
} from './RoleEditor.stories.mocks';
@@ -94,46 +93,14 @@ 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> => {
await openSelectorWizard(canvasElement);
},
};
const card = await openLogsCard(canvasElement);
const [onlySelected] = await within(card).findAllByText('Only selected');
/** 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.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(onlySelected);
await userEvent.click(
await screen.findByTestId(
'wizard-key-option-resource.service.name-logs-read',
),
await within(card).findByRole('button', { name: 'Wizard' }),
);
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');
await screen.findByRole('dialog', { name: 'Selector Wizard' });
},
};

View File

@@ -190,12 +190,10 @@ function SpanDetailsContent({
return null;
}
return {
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') ?? ''),
clusterName: getSpanAttribute(selectedSpan, 'k8s.cluster.name') || '',
podName: getSpanAttribute(selectedSpan, 'k8s.pod.name') || '',
nodeName: getSpanAttribute(selectedSpan, 'k8s.node.name') || '',
hostName: getSpanAttribute(selectedSpan, 'host.name') || '',
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
};
}, [selectedSpan]);

View File

@@ -88,17 +88,5 @@ 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');
});
});
});

View File

@@ -80,7 +80,7 @@ export function getFlamegraphRowMetrics(
export function getFlamegraphServiceName(
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
): string {
return String(getSpanAttribute(span, 'service.name') ?? '');
return getSpanAttribute(span, 'service.name') || '';
}
/**
@@ -92,7 +92,7 @@ export function getFlamegraphSpanGroupValue(
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
field: TelemetryFieldKey,
): string {
return String(getSpanAttribute(span, field.name) ?? 'unknown');
return getSpanAttribute(span, field.name) || 'unknown';
}
interface GetSpanColorArgs {

View File

@@ -27,14 +27,6 @@ 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']);

View File

@@ -9,14 +9,14 @@ import {
RESERVED_ERROR,
} from './utils/generateColorPair';
/** Like `||`, but keeps `0` and `false`. */
/** Like `||`, but keeps `0`. */
const valueOrUndefined = <T>(value: T): T | undefined =>
value || value === 0 || value === false ? value : undefined;
value || value === 0 ? 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` and `false`.
* Falsy values count as missing, except `0`.
*
* 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 | number | boolean | undefined {
): string | undefined {
return (
valueOrUndefined(span.resource?.[key]) ??
valueOrUndefined(span.attributes?.[key]) ??
@@ -117,7 +117,7 @@ export function getSpanGroupValue(
span: SpanV3,
colorByFieldName: string,
): string {
return String(getSpanAttribute(span, colorByFieldName) ?? 'unknown');
return 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) !== undefined),
spans.some((s) => getSpanAttribute(s, opt.field.name)),
).map((opt) => opt.field.name);
}

View File

@@ -145,7 +145,7 @@ const timestampAt = (index: number, start: number, end: number): string => {
return new Date(end - index * step).toISOString();
};
const flatSpanRows = ({
export const traceSpanRows = ({
count,
offset,
limit,
@@ -173,22 +173,9 @@ const flatSpanRows = ({
};
});
/** 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[] =>
flatSpanRows(options).map((row, index) => ({
traceSpanRows(options).map((row, index) => ({
...row,
data: {
...row.data,

View File

@@ -59,6 +59,8 @@ export interface PostableAlertRuleV2 {
};
usePolicy?: boolean;
};
version?: string;
source?: string;
state?: string;
disabled?: boolean;
}

View File

@@ -16,7 +16,7 @@ import { EQueryType } from 'types/common/dashboard';
import { AlertTypes } from './alertTypes';
import { PostableAlertRuleV2 } from './alertTypesV2';
import { ICompositeMetricQuery } from './compositeQuery';
import { Labels } from './def';
import { AlertDef, Labels } from './def';
function toRuleTypeDTO(ruleType: string | undefined): RuletypesRuleTypeDTO {
switch (ruleType) {
@@ -69,10 +69,7 @@ function stripUndefinedLabels(
export function toPostableRuleDTO(
local: PostableAlertRuleV2,
): RuletypesPostableRuleDTO {
const payload: Record<
Exclude<keyof RuletypesPostableRuleDTO, 'source' | 'version'>,
any
> = {
const payload: Record<keyof RuletypesPostableRuleDTO, any> = {
alert: local.alert,
alertType: toAlertTypeDTO(local.alertType),
ruleType: toRuleTypeDTO(local.ruleType),
@@ -86,18 +83,49 @@ 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:

View File

@@ -56,14 +56,13 @@ func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string)
return plannedMaintenances, nil
}
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, orgID string, id valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, 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 {
@@ -163,12 +162,11 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
return pm, nil
}
func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, orgID string, id valuer.UUID) error {
func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, 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 {
@@ -184,7 +182,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
return err
}
existing, err := r.GetPlannedMaintenanceByID(ctx, claims.OrgID, id)
existing, err := r.GetPlannedMaintenanceByID(ctx, id)
if err != nil {
return err
}

View File

@@ -1,7 +1,6 @@
package sqlalertmanagerstore
import (
"encoding/json"
"path/filepath"
"testing"
"time"
@@ -9,13 +8,11 @@ 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"
)
@@ -95,67 +92,3 @@ 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)
}
})
}
}

View File

@@ -95,7 +95,6 @@ type provider struct {
prometheusHandler prometheus.Handler
traceDetailHandler tracedetail.Handler
rulerHandler ruler.Handler
rulerService ruler.Ruler
llmPricingRuleHandler llmpricingrule.Handler
statsHandler statsreporter.Handler
savedViewHandler savedview.Handler
@@ -143,7 +142,6 @@ func NewFactory(
llmPricingRuleHandler llmpricingrule.Handler,
traceDetailHandler tracedetail.Handler,
rulerHandler ruler.Handler,
rulerService ruler.Ruler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
globalConfig global.Config,
@@ -198,7 +196,6 @@ func NewFactory(
llmPricingRuleHandler,
traceDetailHandler,
rulerHandler,
rulerService,
statsHandler,
savedViewHandler,
globalConfig,
@@ -255,7 +252,6 @@ func newProvider(
llmPricingRuleHandler llmpricingrule.Handler,
traceDetailHandler tracedetail.Handler,
rulerHandler ruler.Handler,
rulerService ruler.Ruler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
globalConfig global.Config,
@@ -311,7 +307,6 @@ func newProvider(
prometheusHandler: prometheusHandler,
traceDetailHandler: traceDetailHandler,
rulerHandler: rulerHandler,
rulerService: rulerService,
llmPricingRuleHandler: llmPricingRuleHandler,
statsHandler: statsHandler,
savedViewHandler: savedViewHandler,

View File

@@ -4,11 +4,8 @@ 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"
)
@@ -207,166 +204,73 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}
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 {
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 {
return err
}

View File

@@ -4,9 +4,12 @@ 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"
@@ -22,12 +25,14 @@ import (
const unmappedModelsLookback = time.Hour
type module struct {
store llmpricingruletypes.Store
querier querier.Querier
store llmpricingruletypes.Store
querier querier.Querier
telemetryMetadataStore telemetrytypes.MetadataStore
logger *slog.Logger
}
func NewModule(store llmpricingruletypes.Store, querier querier.Querier) llmpricingrule.Module {
return &module{store: store, 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 (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
@@ -51,13 +56,36 @@ func (module *module) ListUnmappedModels(ctx context.Context, orgID valuer.UUID)
return nil, err
}
unmapped := make([]*llmpricingruletypes.UnmappedModel, 0, len(models))
for _, m := range models {
if !llmpricingruletypes.ModelMatchesAnyRule(m.ModelName, rules) {
unmapped = append(unmapped, m)
}
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
}
return unmapped, nil
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
}
values[selector.Name] = fieldValues
}
maps.Copy(stats, aiobservabilitytypes.NewStatsFromFieldValues(values))
return stats, nil
}
// CreateOrUpdate saves a batch of pricing rules. isOverride decides how a rule

View File

@@ -5,6 +5,7 @@ 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"
)
@@ -13,6 +14,8 @@ 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)

View File

@@ -3,7 +3,6 @@ package querybuilder
import (
"context"
"encoding/json"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -15,10 +14,7 @@ import (
)
func TelemetrySelector(_ context.Context, resource coretypes.Resource, id string, _ valuer.UUID) ([]coretypes.Selector, error) {
values, err := telemetrySelectorValues(id)
if err != nil {
return nil, err
}
values := telemetrytypes.NewTelemetryGrantSelectors(id)
selectors := make([]coretypes.Selector, 0, len(values))
for _, value := range values {
@@ -186,103 +182,27 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
return nil, err
}
atoms := make([]WhereClauseCondition, 0)
for _, condition := range normalized.Conditions {
if !condition.TopLevel || len(condition.Values) == 0 {
continue
}
if condition.Operator != "=" && condition.Operator != "IN" {
continue
}
if _, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key); !ok {
continue
}
atoms = append(atoms, condition)
}
if len(atoms) == 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)
ids := 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)
if condition.Operator == "=" || condition.Operator == "IN" {
for _, value := range condition.Values {
ids = append(ids, queryType+"/"+key+"/"+value)
}
}
}
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)
if len(ids) == 0 {
return []string{typeWildcard}, nil
}
return unique, nil
return ids, nil
}

View File

@@ -21,172 +21,100 @@ func TestQueryRangeResources(t *testing.T) {
expected []coretypes.ResourceWithID
}{
{
name: "TopLevelEquality_OneResource",
name: "top level key equality",
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: "ResourcePrefixedKey_KeptInID",
name: "resource prefixed key",
body: builderQueryBody("traces", "resource.signoz.workspace.key.id = 'checkout'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/resource.signoz.workspace.key.id = 'checkout'"},
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
},
},
{
name: "SingleList_OneResourcePerValue",
name: "in atom requires every value",
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: "SameKeyEqualities_OneResource",
name: "multiple equality atoms each require a grant",
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' AND 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: "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",
name: "no filter expression",
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "TopLevelOr_NoAtoms",
name: "key atom under or does not qualify",
body: builderQueryBody("logs", "signoz.workspace.key.id = 'a' OR status = 500"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "ListUnderOr_NoAtoms",
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('a', 'b') OR status = 500"),
name: "negated key atom does not qualify",
body: builderQueryBody("logs", "NOT signoz.workspace.key.id = 'a'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "NestedNegationUnion_NoAtoms",
body: builderQueryBody("logs", "NOT (NOT resource.service.name = 'checkout' AND NOT resource.service.name = 'frontend')"),
name: "key inequality does not qualify",
body: builderQueryBody("logs", "signoz.workspace.key.id != 'a'"),
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
},
},
{
name: "ValueWithSpacesAndSlashes_Plaintext",
name: "value with spaces and slashes stays plaintext in the id",
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: "AuditSource_AuditLogsResource",
name: "audit source maps to audit logs resource",
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: "AIBuilderQuery_TracesResource",
name: "ai builder query maps to traces resource without a signal",
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: "AIBuilderQueryNoFilter_Wildcard",
name: "ai builder query without filter is wildcard",
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/*"},
},
},
{
name: "PromQL_WildcardOnly",
name: "promql is wildcard only",
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{"query":"up"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: "promql/*"},
},
},
{
name: "ClickHouseSQL_AllSignals",
name: "clickhouse sql covers all signals",
body: `{"compositeQuery":{"queries":[{"type":"clickhouse_sql","spec":{"query":"SELECT 1"}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "clickhouse_sql/*"},
@@ -196,29 +124,29 @@ func TestQueryRangeResources(t *testing.T) {
},
},
{
name: "Formula_NoResources",
name: "formula produces no resources",
body: `{"compositeQuery":{"queries":[{"type":"builder_formula","spec":{"expression":"A/B"}}]}}`,
expected: []coretypes.ResourceWithID{},
},
{
name: "TraceOperator_ReferencedQueries",
name: "trace operator rides on its referenced queries",
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: "VariableSubstitution_Qualifies",
name: "variable substitution 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: "DuplicateQueries_Deduped",
name: "duplicate queries dedupe",
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"},
},
},
}
@@ -260,28 +188,12 @@ 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/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/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/*", "*"}, selectorValues("builder_query"))
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql/*"))
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql"))
assert.Equal(t, []string{"*"}, selectorValues("*"))
_, err := TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, "builder_query/signoz.workspace.key.id = ", orgID)
assert.Error(t, err)
// 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"))
}
func TestBuilderQueryWithFilterExpressionResources(t *testing.T) {
@@ -293,7 +205,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",

View File

@@ -2,7 +2,6 @@ package querybuilder
import (
"fmt"
"slices"
"sort"
"strconv"
"strings"
@@ -40,7 +39,6 @@ const (
type normalizedPart struct {
text string
join joinKind
negated bool
skipped bool
}
@@ -50,12 +48,11 @@ type normalizedValue struct {
}
type whereClauseNormalizer struct {
variables map[string]qbtypes.VariableItem
conditions []WhereClauseCondition
negated bool
orDepth int
negationDepth int
errors []string
variables map[string]qbtypes.VariableItem
conditions []WhereClauseCondition
negated bool
orDepth int
errors []string
}
func NormalizeWhereClause(expression string, variables map[string]qbtypes.VariableItem) (*NormalizedWhereClause, error) {
@@ -116,11 +113,8 @@ 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: conditions}, nil
return &NormalizedWhereClause{Expression: part.text, Conditions: visitor.conditions}, nil
}
func (condition WhereClauseCondition) sortKey() string {
@@ -203,22 +197,20 @@ 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 || part.negated {
return normalizedPart{text: "NOT (" + part.text + ")", negated: true}
if part.join != joinKindNone {
return normalizedPart{text: "NOT (" + part.text + ")", join: joinKindNone}
}
return normalizedPart{text: "NOT " + part.text, negated: true}
return normalizedPart{text: "NOT " + part.text, join: joinKindNone}
}
return part
@@ -447,7 +439,7 @@ func (visitor *whereClauseNormalizer) appendCondition(key, operator string, valu
Operator: operator,
Values: values,
Negated: visitor.negated,
TopLevel: visitor.orDepth == 0 && visitor.negationDepth == 0,
TopLevel: visitor.orDepth == 0 && !visitor.negated,
})
}

View File

@@ -25,15 +25,6 @@ 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{
@@ -284,25 +275,15 @@ func TestNormalizeWhereClauseTopLevel(t *testing.T) {
expected: map[string]bool{"service.name": false, "status": true},
},
{
name: "any negation disqualifies even when it cancels",
name: "double negation restores top level",
expression: "NOT (NOT (service.name = 'a'))",
expected: map[string]bool{"service.name": false},
expected: map[string]bool{"service.name": true},
},
{
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 {
@@ -346,8 +327,6 @@ 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 {
@@ -460,13 +439,3 @@ 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)
}

View File

@@ -17,79 +17,6 @@ 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 {

View File

@@ -1,30 +1,11 @@
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
@@ -94,125 +75,3 @@ 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)
})
}
}

View File

@@ -283,13 +283,7 @@ func (handler *handler) GetDowntimeScheduleByID(rw http.ResponseWriter, req *htt
return
}
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
schedule, err := handler.ruler.MaintenanceStore().GetPlannedMaintenanceByID(ctx, claims.OrgID, id)
schedule, err := handler.ruler.MaintenanceStore().GetPlannedMaintenanceByID(ctx, id)
if err != nil {
render.Error(rw, err)
return
@@ -362,13 +356,7 @@ func (handler *handler) DeleteDowntimeScheduleByID(rw http.ResponseWriter, req *
return
}
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
err = handler.ruler.MaintenanceStore().DeletePlannedMaintenance(ctx, claims.OrgID, id)
err = handler.ruler.MaintenanceStore().DeletePlannedMaintenance(ctx, id)
if err != nil {
render.Error(rw, err)
return

View File

@@ -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),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier, telemetryMetadataStore, providerSettings),
Tag: tagModule,
}
}

View File

@@ -105,7 +105,6 @@ 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{},

View File

@@ -48,7 +48,6 @@ 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"
@@ -269,7 +268,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewDeleteOrphanSARolesFactory(),
sqlmigration.NewAddRoutePolicyTuplesFactory(sqlstore),
sqlmigration.NewRenameSavedViewDeprecatedTraceFieldsFactory(),
sqlmigration.NewAddDowntimeTuplesFactory(sqlstore),
)
}
@@ -334,7 +332,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, rulerService ruler.Ruler) 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) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
return factory.MustNewNamedMap(
signozapiserver.NewFactory(
orgGetter,
@@ -376,7 +374,6 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.LLMPricingRuleHandler,
handlers.TraceDetail,
handlers.RulerHandler,
rulerService,
handlers.StatsHandler,
handlers.SavedView,
globalConfig,

View File

@@ -107,7 +107,6 @@ func TestNewProviderFactories(t *testing.T) {
nil,
nil,
nil,
nil,
)
})
}

View File

@@ -570,6 +570,7 @@ func New(
cloudIntegrationModule,
modules.LogsPipeline,
modules.InfraMonitoring,
modules.LLMPricingRule,
querier,
authz,
}
@@ -622,7 +623,7 @@ func New(
ctx,
providerSettings,
config.APIServer,
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway, identNResolver, sharder, auditor, web, alertmanager, rulerInstance),
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway, identNResolver, sharder, auditor, web, alertmanager),
"signoz",
)
if err != nil {

View File

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

View File

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

View File

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

View File

@@ -18,19 +18,10 @@ 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() {
if helper, ok := t.(interface{ Helper() }); ok {
helper.Helper()
}
mock.AssertExpectations(t)
})
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
@@ -84,7 +75,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 any, postablePlannedMaintenance any) *MockMaintenanceStore_CreatePlannedMaintenance_Call {
func (_e *MockMaintenanceStore_Expecter) CreatePlannedMaintenance(context1 interface{}, postablePlannedMaintenance interface{}) *MockMaintenanceStore_CreatePlannedMaintenance_Call {
return &MockMaintenanceStore_CreatePlannedMaintenance_Call{Call: _e.mock.On("CreatePlannedMaintenance", context1, postablePlannedMaintenance)}
}
@@ -117,16 +108,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, s string, uUID valuer.UUID) error {
ret := _mock.Called(context1, s, uUID)
func (_mock *MockMaintenanceStore) DeletePlannedMaintenance(context1 context.Context, uUID valuer.UUID) error {
ret := _mock.Called(context1, uUID)
if len(ret) == 0 {
panic("no return value specified for DeletePlannedMaintenance")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID) error); ok {
r0 = returnFunc(context1, s, uUID)
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) error); ok {
r0 = returnFunc(context1, uUID)
} else {
r0 = ret.Error(0)
}
@@ -140,30 +131,24 @@ 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 any, s any, uUID any) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
return &MockMaintenanceStore_DeletePlannedMaintenance_Call{Call: _e.mock.On("DeletePlannedMaintenance", context1, s, 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 (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) Run(run func(context1 context.Context, s string, uUID valuer.UUID)) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) Run(run func(context1 context.Context, 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 string
var arg1 valuer.UUID
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 valuer.UUID
if args[2] != nil {
arg2 = args[2].(valuer.UUID)
arg1 = args[1].(valuer.UUID)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
@@ -174,14 +159,14 @@ func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) Return(err error)
return _c
}
func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) RunAndReturn(run func(context1 context.Context, s string, uUID valuer.UUID) error) *MockMaintenanceStore_DeletePlannedMaintenance_Call {
func (_c *MockMaintenanceStore_DeletePlannedMaintenance_Call) RunAndReturn(run func(context1 context.Context, 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, s string, uUID valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
ret := _mock.Called(context1, s, uUID)
func (_mock *MockMaintenanceStore) GetPlannedMaintenanceByID(context1 context.Context, uUID valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
ret := _mock.Called(context1, uUID)
if len(ret) == 0 {
panic("no return value specified for GetPlannedMaintenanceByID")
@@ -189,18 +174,18 @@ func (_mock *MockMaintenanceStore) GetPlannedMaintenanceByID(context1 context.Co
var r0 *alertmanagertypes.PlannedMaintenance
var r1 error
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, error)); ok {
return returnFunc(context1, uUID)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID) *alertmanagertypes.PlannedMaintenance); ok {
r0 = returnFunc(context1, s, uUID)
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) *alertmanagertypes.PlannedMaintenance); ok {
r0 = returnFunc(context1, uUID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*alertmanagertypes.PlannedMaintenance)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, valuer.UUID) error); ok {
r1 = returnFunc(context1, s, uUID)
if returnFunc, ok := ret.Get(1).(func(context.Context, valuer.UUID) error); ok {
r1 = returnFunc(context1, uUID)
} else {
r1 = ret.Error(1)
}
@@ -214,30 +199,24 @@ 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 any, s any, uUID any) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
return &MockMaintenanceStore_GetPlannedMaintenanceByID_Call{Call: _e.mock.On("GetPlannedMaintenanceByID", context1, s, 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 (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) Run(run func(context1 context.Context, s string, uUID valuer.UUID)) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) Run(run func(context1 context.Context, 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 string
var arg1 valuer.UUID
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 valuer.UUID
if args[2] != nil {
arg2 = args[2].(valuer.UUID)
arg1 = args[1].(valuer.UUID)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
@@ -248,7 +227,7 @@ func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) Return(plannedMai
return _c
}
func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) RunAndReturn(run func(context1 context.Context, s string, uUID valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error)) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
func (_c *MockMaintenanceStore_GetPlannedMaintenanceByID_Call) RunAndReturn(run func(context1 context.Context, uUID valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error)) *MockMaintenanceStore_GetPlannedMaintenanceByID_Call {
_c.Call.Return(run)
return _c
}
@@ -289,7 +268,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 any, s any) *MockMaintenanceStore_ListPlannedMaintenance_Call {
func (_e *MockMaintenanceStore_Expecter) ListPlannedMaintenance(context1 interface{}, s interface{}) *MockMaintenanceStore_ListPlannedMaintenance_Call {
return &MockMaintenanceStore_ListPlannedMaintenance_Call{Call: _e.mock.On("ListPlannedMaintenance", context1, s)}
}
@@ -347,7 +326,7 @@ type MockMaintenanceStore_UpdatePlannedMaintenance_Call struct {
// - context1 context.Context
// - postablePlannedMaintenance *alertmanagertypes.PostablePlannedMaintenance
// - uUID valuer.UUID
func (_e *MockMaintenanceStore_Expecter) UpdatePlannedMaintenance(context1 any, postablePlannedMaintenance any, uUID any) *MockMaintenanceStore_UpdatePlannedMaintenance_Call {
func (_e *MockMaintenanceStore_Expecter) UpdatePlannedMaintenance(context1 interface{}, postablePlannedMaintenance interface{}, uUID interface{}) *MockMaintenanceStore_UpdatePlannedMaintenance_Call {
return &MockMaintenanceStore_UpdatePlannedMaintenance_Call{Call: _e.mock.On("UpdatePlannedMaintenance", context1, postablePlannedMaintenance, uUID)}
}

View File

@@ -403,8 +403,8 @@ type ListPlannedMaintenanceParams struct {
type MaintenanceStore interface {
CreatePlannedMaintenance(context.Context, *PostablePlannedMaintenance) (*PlannedMaintenance, error)
DeletePlannedMaintenance(context.Context, string, valuer.UUID) error
GetPlannedMaintenanceByID(context.Context, string, valuer.UUID) (*PlannedMaintenance, error)
DeletePlannedMaintenance(context.Context, valuer.UUID) error
GetPlannedMaintenanceByID(context.Context, valuer.UUID) (*PlannedMaintenance, error)
UpdatePlannedMaintenance(context.Context, *PostablePlannedMaintenance, valuer.UUID) error
ListPlannedMaintenance(context.Context, string) ([]*PlannedMaintenance, error)
}

View File

@@ -134,16 +134,12 @@ 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)},
@@ -249,16 +245,12 @@ 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)},

View File

@@ -1,6 +1,7 @@
package dashboardtypes
import (
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -10,6 +11,7 @@ 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
@@ -22,14 +24,17 @@ 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.
// 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.
func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]any {
stats := newPanelStats()
stats := newStats()
for _, dashboard := range dashboards {
if dashboard == nil {
if dashboard == nil || dashboard.Source == SourceSystem {
continue
}
incrementStat(stats, statKeyDashboardCount)
dashboardV2, err := dashboard.ToDashboardV2(nil)
if err != nil {
continue
@@ -37,7 +42,6 @@ func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]
addPanelStats(&dashboardV2.Spec, stats)
}
stats[statKeyDashboardCount] = int64(len(dashboards))
return stats
}
@@ -45,24 +49,26 @@ 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 := newPanelStats()
stats := newStats()
stats[statKeyDashboardCount] = int64(1)
addPanelStats(&postable.Spec, stats)
stats[statKeyDashboardCount] = int64(1)
return stats
}
func newPanelStats() map[string]any {
func newStats() 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.
// the signal they read. AI queries count only towards the AI stat.
func addPanelStats(spec *DashboardSpec, stats map[string]any) {
for _, panel := range spec.Panels {
if panel == nil {
@@ -76,6 +82,10 @@ 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)
}

View File

@@ -117,20 +117,60 @@ func TestNewStatsFromStorableDashboardsCountsCompositeSubQueries(t *testing.T) {
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
}
// An AI builder query is always a traces query, so it counts towards traces.
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
aiBuilder := `{
func statsAIBuilderQuery() string {
return `{
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/AIBuilderQuery", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}}}
}`
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(aiBuilder))
}
// An AI builder query reads traces but counts only towards the AI stat.
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
assert.Equal(t, int64(1), stats[statKeyPanelCount])
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
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(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
@@ -215,6 +255,17 @@ 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) {
@@ -233,4 +284,5 @@ 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