Compare commits

..

5 Commits

70 changed files with 2521 additions and 2100 deletions

2
.github/CODEOWNERS vendored
View File

@@ -189,9 +189,7 @@ go.mod @therealpandey
## Infrastructure Monitoring
/frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend
/frontend/src/container/InfraMonitoringHosts/ @SigNoz/pulse-frontend
/frontend/src/container/InfraMonitoringHostsV2/ @SigNoz/pulse-frontend
/frontend/src/container/InfraMonitoringK8s/ @SigNoz/pulse-frontend
/frontend/src/container/InfraMonitoringK8sV2/ @SigNoz/pulse-frontend
## Alerts
/frontend/src/pages/AlertList/ @SigNoz/pulse-frontend

View File

@@ -543,31 +543,6 @@ components:
required:
- id
type: object
AuthtypesGettableRole:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
name:
type: string
orgId:
type: string
type:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- description
- type
- orgId
type: object
AuthtypesGettableToken:
properties:
accessToken:
@@ -719,6 +694,43 @@ components:
- detach
type: string
AuthtypesRole:
properties:
createdAt:
format: date-time
type: string
description:
type: string
id:
type: string
name:
type: string
orgId:
type: string
type:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- description
- type
- orgId
type: object
AuthtypesRoleMapping:
properties:
defaultRole:
type: string
groupMappings:
additionalProperties:
type: string
nullable: true
type: object
useRoleAttribute:
type: boolean
type: object
AuthtypesRoleWithTransactionGroups:
properties:
createdAt:
format: date-time
@@ -746,18 +758,6 @@ components:
- orgId
- transactionGroups
type: object
AuthtypesRoleMapping:
properties:
defaultRole:
type: string
groupMappings:
additionalProperties:
type: string
nullable: true
type: object
useRoleAttribute:
type: boolean
type: object
AuthtypesSamlConfig:
properties:
attributeMapping:
@@ -12022,7 +12022,7 @@ paths:
properties:
data:
items:
$ref: '#/components/schemas/AuthtypesGettableRole'
$ref: '#/components/schemas/AuthtypesRole'
type: array
status:
type: string
@@ -12206,7 +12206,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesRole'
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
status:
type: string
required:

View File

@@ -119,6 +119,10 @@ func (provider *provider) CheckTransactions(ctx context.Context, subject string,
return results, nil
}
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
return provider.openfgaServer.ListObjects(ctx, subject, relation, objectType)
}
func (provider *provider) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
return provider.openfgaServer.Write(ctx, additions, deletions)
}
@@ -127,6 +131,10 @@ func (provider *provider) ReadTuples(ctx context.Context, tupleKey *openfgav1.Re
return provider.openfgaServer.ReadTuples(ctx, tupleKey)
}
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
return provider.pkgAuthzService.Get(ctx, orgID, id)
}
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
return provider.pkgAuthzService.GetByOrgIDAndName(ctx, orgID, name)
}
@@ -175,7 +183,7 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
return provider.Write(ctx, tuples, nil)
}
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) error {
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.RoleWithTransactionGroups) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
@@ -200,31 +208,70 @@ func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *a
return err
}
return provider.store.Create(ctx, role)
if err := provider.store.Create(ctx, role.Role); err != nil {
return err
}
return nil
}
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
return provider.store.Get(ctx, orgID, id)
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) (*authtypes.Role, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existingRole, err := provider.store.GetByOrgIDAndName(ctx, role.OrgID, role.Name)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
}
if existingRole != nil {
return existingRole, nil
}
err = provider.store.Create(ctx, role)
if err != nil {
return nil, err
}
return role, nil
}
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.Role) error {
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
role, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
if err != nil {
return nil, err
}
transactionGroups := authtypes.MustNewTransactionGroupsFromTuples(tuples)
return authtypes.MakeRoleWithTransactionGroups(role, transactionGroups), nil
}
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.RoleWithTransactionGroups) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existingRole, err := provider.Get(ctx, orgID, updatedRole.ID)
existingRole, err := provider.GetWithTransactionGroups(ctx, orgID, updatedRole.ID)
if err != nil {
return err
}
existingTuples, err := provider.readAllTuplesForRole(ctx, existingRole.Name, orgID)
if err != nil {
return err
}
existingGroups := authtypes.MustNewTransactionGroupsFromTuples(existingTuples)
additions, deletions := existingGroups.Diff(updatedRole.TransactionGroups)
additions, deletions := existingRole.TransactionGroups.Diff(updatedRole.TransactionGroups)
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
if err != nil {
return err
@@ -240,7 +287,7 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
return err
}
return provider.store.Update(ctx, orgID, updatedRole)
return provider.store.Update(ctx, orgID, updatedRole.Role)
}
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
@@ -249,7 +296,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
role, err := provider.Get(ctx, orgID, id)
role, err := provider.GetWithTransactionGroups(ctx, orgID, id)
if err != nil {
return err
}
@@ -265,7 +312,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
}
}
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
if err != nil {
return err
}
@@ -277,24 +324,6 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
return provider.store.Delete(ctx, orgID, id)
}
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
tuples := make([]*openfgav1.TupleKey, 0)
for _, objectType := range provider.registry.Types() {
typeTuples, err := provider.openfgaServer.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
User: subject,
Object: objectType.StringValue() + ":",
})
if err != nil {
return nil, err
}
tuples = append(tuples, typeTuples...)
}
return tuples, nil
}
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) []*openfgav1.TupleKey {
tuples := []*openfgav1.TupleKey{}
@@ -346,3 +375,21 @@ func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) []*
return tuples
}
func (provider *provider) readAllTuplesForRole(ctx context.Context, roleName string, orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
subject := authtypes.MustNewSubject(coretypes.NewResourceRole(), roleName, orgID, &coretypes.VerbAssignee)
tuples := make([]*openfgav1.TupleKey, 0)
for _, objectType := range provider.registry.Types() {
typeTuples, err := provider.ReadTuples(ctx, &openfgav1.ReadRequestTupleKey{
User: subject,
Object: objectType.StringValue() + ":",
})
if err != nil {
return nil, err
}
tuples = append(tuples, typeTuples...)
}
return tuples, nil
}

View File

@@ -105,6 +105,10 @@ func (server *Server) BatchCheck(ctx context.Context, tupleReq map[string]*openf
return server.pkgAuthzService.BatchCheck(ctx, tupleReq)
}
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, objectType coretypes.Type) ([]*coretypes.Object, error) {
return server.pkgAuthzService.ListObjects(ctx, subject, relation, objectType)
}
func (server *Server) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
return server.pkgAuthzService.Write(ctx, additions, deletions)
}

View File

@@ -0,0 +1,29 @@
import { PropsWithChildren } from 'react';
type CommonProps = PropsWithChildren<{
className?: string;
minSize?: number;
maxSize?: number;
defaultSize?: number;
direction?: 'horizontal' | 'vertical';
autoSaveId?: string;
withHandle?: boolean;
}>;
export function ResizablePanelGroup({
children,
className,
}: CommonProps): JSX.Element {
return <div className={className}>{children}</div>;
}
export function ResizablePanel({
children,
className,
}: CommonProps): JSX.Element {
return <div className={className}>{children}</div>;
}
export function ResizableHandle({ className }: CommonProps): JSX.Element {
return <div className={className} />;
}

View File

@@ -21,6 +21,7 @@ const config: Config.InitialOptions = {
'\\.md$': '<rootDir>/__mocks__/cssMock.ts',
'^uplot$': '<rootDir>/__mocks__/uplotMock.ts',
'^motion/react$': '<rootDir>/__mocks__/motionMock.tsx',
'^@signozhq/resizable$': '<rootDir>/__mocks__/resizableMock.tsx',
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,

View File

@@ -49,6 +49,7 @@
"@sentry/vite-plugin": "5.3.0",
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.4.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/ui": "0.0.23",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
@@ -127,7 +128,7 @@
"timestamp-nano": "^1.0.0",
"typescript": "5.9.3",
"uplot": "1.6.31",
"uuid": "14.0.1",
"uuid": "^8.3.2",
"vite": "npm:rolldown-vite@7.3.1",
"vite-plugin-html": "3.2.2",
"zod": "4.3.6",
@@ -221,5 +222,34 @@
"*.(scss|css)": [
"stylelint"
]
},
"resolutions": {
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10",
"debug": "4.3.4",
"semver": "7.5.4",
"xml2js": "0.5.0",
"phin": "^3.7.1",
"body-parser": "1.20.3",
"http-proxy-middleware": "4.1.1",
"cross-spawn": "7.0.5",
"cookie": "^0.7.1",
"serialize-javascript": "6.0.2",
"prismjs": "1.30.0",
"got": "11.8.5",
"form-data": "4.0.6",
"brace-expansion": "^2.0.3",
"on-headers": "^1.1.0",
"js-cookie": "^3.0.7",
"tmp": "0.2.7",
"vite": "npm:rolldown-vite@7.3.1",
"dompurify": "3.4.11",
"js-yaml@3": "3.15.0",
"js-yaml@4": "4.2.0",
"yaml@1": "1.10.3",
"react-router@6": "6.30.4",
"markdown-it": "14.2.0",
"mdast-util-to-hast@13": "13.2.1",
"protocol-buffers-schema": "3.6.1"
}
}

1864
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,53 +1,6 @@
blockExoticSubdeps: true
trustPolicy: no-downgrade
minimumReleaseAge: 2880 # 2d
minimumReleaseAgeStrict: true
minimumReleaseAgeExclude:
- '@signozhq/*'
minimumReleaseAgeStrict: true
# Security floors for vulnerable transitive deps. Where possible, targets are
# capped to avoid crossing breaking versions (major; and minor for 0.x).
# Some entries may still force a breaking bump when no safe release exists
# within the consumers constraint—see per-entry notes.
overrides:
# via: direct devDep @babel/core ^7.22.11 (+ babel plugin peers)
# remove: bump @babel/core in package.json to ^7.29.6
'@babel/core@<=7.29.0': '>=7.29.6 <8'
# via: jest > babel-plugin-istanbul > @istanbuljs/load-nyc-config@1.1.0 (js-yaml ^3.13.1)
# remove: blocked — 1.1.0 is latest and still depends on js-yaml 3.x
'@istanbuljs/load-nyc-config>js-yaml': '>=4.2.0 <5'
# via: msw@1.3.2 (devDep) > cookie ^0.4.2
# remove: upgrade msw to >=2 (ships cookie ^1). Do NOT open the cap: cookie >=1 is
# ESM-only and breaks msw under jest's CJS sandbox (kills every test suite)
cookie@<0.7.0: '>=0.7.1 <1'
# via: direct dep dompurify 3.4.0; @grafana/data@11.6.15 (3.4.0/3.2.4 exact);
# @monaco-editor/react > monaco-editor@0.55.1 (3.2.7 exact)
# remove: bump direct dep to 3.4.11; @grafana/data (latest 13.1.0) and
# monaco-editor (latest 0.55.1) still pin vulnerable versions — blocked
dompurify@<=3.4.10: '>=3.4.11 <4'
# via: rolldown-vite@7.3.1 (esbuild ^0.27.0); orval@8.9.1 (^0.27.4); ts-jest@29.4.9 (~0.27.4)
# remove: blocked on rolldown-vite (7.3.1 is latest, still ^0.27.0);
# orval >=8.20.0 and ts-jest >=29.4.11 already fixed on their side
esbuild@>=0.27.3 <0.28.1: '>=0.28.1 <0.29.0'
# via: react-use@17.5.1 (direct, js-cookie ^2.2.1); @grafana/data > react-use@17.6.0
# remove: bump react-use to >=17.6.1 (js-cookie ^3); @grafana/data side blocked
js-cookie@<=3.0.5: '>=3.0.7 <4'
# via: @orval/core@8.9.1 (devDep, js-yaml 4.1.1 EXACT pin — not deletable);
# json-schema-to-typescript@15 > @apidevtools/json-schema-ref-parser (^4.1.0)
# remove: upgrade orval to >=8.20.0 (drops js-yaml dependency entirely)
js-yaml@>=4.0.0 <=4.1.1: '>=4.2.0 <5'
# via: react-syntax-highlighter@15.5.0 (prismjs ^1.27.0 + refractor@3 ~1.27.0 tilde-pinned)
# remove: bump react-syntax-highlighter to >=16.1.1 (prismjs ^1.30.0, refractor@5)
prismjs@<1.30.0: '>=1.30.0 <2'
# via: direct dep react-router-dom-v5-compat@6.30.3 (react-router 6.30.3 exact)
# remove: bump react-router-dom-v5-compat to 6.30.4. Do NOT open the cap:
# react-router >=7 requires React 19 and breaks the app-wide CompatRouter
react-router@>=6.7.0 <6.30.4: '>=6.30.4 <7'
# via: msw@1.3.2 (devDep) > inquirer@8 > external-editor@3.1.0 (tmp ^0.0.33)
# remove: upgrade msw to >=2 (drops the inquirer/external-editor chain)
tmp@<0.2.6: '>=0.2.6 <0.3.0'
# via: jest > babel-plugin-macros > cosmiconfig@7 (yaml ^1.10.0);
# typescript-plugin-css-modules > postcss-load-config@3 (^1.10.2)
# remove: blocked — babel-plugin-macros 3.1.0 (latest) still uses cosmiconfig@7
yaml@>=1.0.0 <1.10.3: '>=1.10.3 <2'
trustPolicy: no-downgrade
trustPolicyExclude:
- 'semver@6.3.1 || 5.7.2'
blockExoticSubdeps: true

View File

@@ -2082,39 +2082,6 @@ export interface AuthtypesGettableAuthDomainDTO {
updatedAt?: string;
}
export interface AuthtypesGettableRoleDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
description: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
*/
type: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface AuthtypesGettableTokenDTO {
/**
* @type string
@@ -2358,6 +2325,39 @@ export interface AuthtypesPostableUserRoleDTO {
}
export interface AuthtypesRoleDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
description: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
*/
type: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface AuthtypesRoleWithTransactionGroupsDTO {
/**
* @type string
* @format date-time
@@ -10614,7 +10614,7 @@ export type ListRoles200 = {
/**
* @type array
*/
data: AuthtypesGettableRoleDTO[];
data: AuthtypesRoleDTO[];
/**
* @type string
*/
@@ -10636,7 +10636,7 @@ export type GetRolePathParameters = {
id: string;
};
export type GetRole200 = {
data: AuthtypesRoleDTO;
data: AuthtypesRoleWithTransactionGroupsDTO;
/**
* @type string
*/

View File

@@ -1,26 +1,50 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { AxiosError, AxiosResponse } from 'axios';
import { ErrorV2Resp } from 'types/api';
import { ExportRawDataProps } from 'types/api/exportRawData/getExportRawData';
export interface FetchExportDataProps extends ExportRawDataProps {
signal?: AbortSignal;
}
async function postExportRawData({
format,
body,
signal,
}: FetchExportDataProps): Promise<AxiosResponse<Blob>> {
return axios.post<Blob>(
`export_raw_data?format=${encodeURIComponent(format)}`,
body,
{
responseType: 'blob',
decompress: true,
headers: {
Accept: 'application/octet-stream',
'Content-Type': 'application/json',
},
timeout: 0,
signal,
},
);
}
/**
* Fetches a single export_raw_data page and returns it as a Blob.
* Callers own retry/cancel/error UX.
*/
export async function fetchExportData(
props: FetchExportDataProps,
): Promise<Blob> {
const response = await postExportRawData(props);
return response.data;
}
export const downloadExportData = async (
props: ExportRawDataProps,
): Promise<void> => {
try {
const response = await axios.post<Blob>(
`export_raw_data?format=${encodeURIComponent(props.format)}`,
props.body,
{
responseType: 'blob',
decompress: true,
headers: {
Accept: 'application/octet-stream',
'Content-Type': 'application/json',
},
timeout: 0,
},
);
const response = await postExportRawData(props);
if (response.status !== 200) {
throw new Error(

View File

@@ -12,4 +12,5 @@
import '@signozhq/design-tokens';
import '@signozhq/icons';
import '@signozhq/resizable';
import '@signozhq/ui';

View File

@@ -3,7 +3,7 @@ import { Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRoles } from 'api/generated/services/role';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import APIError from 'types/api/error';
import { popupContainer } from 'utils/selectPopupContainer';
@@ -16,7 +16,7 @@ export interface RoleOption {
}
export function useRoles(): {
roles: AuthtypesGettableRoleDTO[];
roles: AuthtypesRoleDTO[];
isLoading: boolean;
isError: boolean;
error: APIError | undefined;
@@ -33,7 +33,7 @@ export function useRoles(): {
}
export function getRoleOptions(
roles: AuthtypesGettableRoleDTO[],
roles: AuthtypesRoleDTO[],
valueField: 'id' | 'name',
): RoleOption[] {
return roles.map((role) => ({
@@ -79,7 +79,7 @@ interface BaseProps {
placeholder?: string;
className?: string;
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
roles?: AuthtypesGettableRoleDTO[];
roles?: AuthtypesRoleDTO[];
loading?: boolean;
isError?: boolean;
error?: APIError;

View File

@@ -4,7 +4,7 @@ import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { useCopyToClipboard } from 'react-use';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
import RolesSelect from 'components/RolesSelect';
@@ -28,7 +28,7 @@ interface OverviewTabProps {
localRoles: string[];
onRolesChange: (v: string[]) => void;
isDisabled: boolean;
availableRoles: AuthtypesGettableRoleDTO[];
availableRoles: AuthtypesRoleDTO[];
rolesLoading?: boolean;
rolesError?: boolean;
rolesErrorObj?: APIError | undefined;

View File

@@ -7,13 +7,11 @@ import { AlertTypes } from 'types/api/alerts/alertTypes';
import { ALERT_TYPE_URL_MAP } from './constants';
// The setup-guide button only exists in the classic form, which is reachable
// via the unadvertised showClassicCreateAlertsPage param.
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}${ROUTES.ALERTS_NEW}`,
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
search: 'ruleType=anomaly_rule',
}),
}));
@@ -22,7 +20,7 @@ jest.mock('react-router-dom-v5-compat', () => ({
useNavigationType: jest.fn(() => 'PUSH'),
useLocation: jest.fn(() => ({
pathname: '/alerts/new',
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
search: 'ruleType=anomaly_rule',
hash: '',
state: null,
})),

View File

@@ -152,7 +152,7 @@ describe('CreateAlertRule', () => {
expect(screen.getByText(AlertTypes.METRICS_BASED_ALERT)).toBeInTheDocument();
});
it('should render new flow when ruleType is anomaly_rule', () => {
it('should render classic flow when ruleType is anomaly_rule even if showClassicCreateAlertsPage is not true', () => {
mockGetUrlQuery.mockImplementation((key: string) => {
if (key === QueryParams.showClassicCreateAlertsPage) {
return 'false';
@@ -163,8 +163,8 @@ describe('CreateAlertRule', () => {
return null;
});
render(<CreateAlertRule />);
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
});
it('should use alertType from URL when provided', () => {

View File

@@ -100,9 +100,10 @@ function CreateRules(): JSX.Element {
return <SelectAlertType onSelect={handleSelectType} />;
}
// The classic experience is no longer offered in the UI; the query param
// is kept as an unadvertised escape hatch until the flow is removed.
if (showClassicCreateAlertsPageFlag) {
if (
showClassicCreateAlertsPageFlag ||
alertType === AlertTypes.ANOMALY_BASED_ALERT
) {
return (
<FormAlertRules
alertType={alertType}

View File

@@ -2,9 +2,7 @@ import { useQuery } from 'react-query';
import { Button, Tooltip } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { FeatureKeys } from 'constants/features';
import { Activity, ChartLine } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { ChartLine } from '@signozhq/icons';
import { SuccessResponseV2 } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
@@ -21,7 +19,6 @@ import './styles.scss';
function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const { featureFlags } = useAppContext();
const {
data,
@@ -33,15 +30,9 @@ function AlertCondition(): JSX.Element {
});
const channels = data?.data || [];
const isAnomalyDetectionEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
?.active || false;
// Anomaly alerts always show both tabs so existing rules stay editable;
// metric alerts only offer the anomaly tab when the feature is enabled.
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||
(isAnomalyDetectionEnabled && alertType === AlertTypes.METRICS_BASED_ALERT);
alertType === AlertTypes.METRICS_BASED_ALERT;
const tabs = [
{
@@ -49,15 +40,16 @@ function AlertCondition(): JSX.Element {
icon: <ChartLine size={14} data-testid="threshold-view" />,
value: AlertTypes.METRICS_BASED_ALERT,
},
...(showMultipleTabs
? [
{
label: 'Anomaly',
icon: <Activity size={14} data-testid="anomaly-view" />,
value: AlertTypes.ANOMALY_BASED_ALERT,
},
]
: []),
// Hide anomaly tab for now
// ...(showMultipleTabs
// ? [
// {
// label: 'Anomaly',
// icon: <Activity size={14} data-testid="anomaly-view" />,
// value: AlertTypes.ANOMALY_BASED_ALERT,
// },
// ]
// : []),
];
const handleAlertTypeChange = (value: AlertTypes): void => {

View File

@@ -188,7 +188,7 @@ function AnomalyThreshold({
}}
options={ANOMALY_SEASONALITY_OPTIONS}
/>
{!notificationSettings.routingPolicies ? (
{notificationSettings.routingPolicies ? (
<>
<Typography.Text
data-testid="seasonality-text"

View File

@@ -1,11 +1,7 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { FeatureKeys } from 'constants/features';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { FeatureFlagProps } from 'types/api/features/getFeaturesFlags';
import { CreateAlertProvider } from '../../context';
import AlertCondition from '../AlertCondition';
@@ -100,23 +96,6 @@ const createTestQueryClient = (): QueryClient =>
},
});
const ANOMALY_DETECTION_FLAG: FeatureFlagProps = {
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
};
const useAppContextSpy = jest.spyOn(appHooks, 'useAppContext');
const mockAppContext = (isAnomalyDetectionEnabled: boolean): void => {
useAppContextSpy.mockReturnValue({
...getAppContextMockState(),
featureFlags: isAnomalyDetectionEnabled ? [ANOMALY_DETECTION_FLAG] : [],
});
};
const renderAlertCondition = (
alertType?: string,
): ReturnType<typeof render> => {
@@ -134,10 +113,6 @@ const renderAlertCondition = (
};
describe('AlertCondition', () => {
beforeEach(() => {
mockAppContext(true);
});
it('renders the stepper with correct step number and label', () => {
renderAlertCondition();
expect(screen.getByTestId(STEPPER_TEST_ID)).toHaveTextContent(
@@ -150,9 +125,10 @@ describe('AlertCondition', () => {
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
expect(
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
).not.toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(
// screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
// ).not.toBeInTheDocument();
// Verify threshold tab is active by default
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -160,7 +136,8 @@ describe('AlertCondition', () => {
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
});
it('renders threshold tab by default', () => {
@@ -175,27 +152,13 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
it('renders anomaly tab when alert type supports multiple tabs', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('renders anomaly tab when alert type supports multiple tabs', () => {
renderAlertCondition();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('does not offer the anomaly tab when anomaly detection is disabled', () => {
mockAppContext(false);
renderAlertCondition();
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.queryByText(ANOMALY_TAB_TEXT)).not.toBeInTheDocument();
});
it('shows both tabs for anomaly alerts even when anomaly detection is disabled', () => {
mockAppContext(false);
renderAlertCondition('ANOMALY_BASED_ALERT');
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_THRESHOLD_TEST_ID)).toBeInTheDocument();
});
it('shows AlertThreshold component when alert type is not anomaly based', () => {
renderAlertCondition();
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
@@ -204,7 +167,8 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
it('shows AnomalyThreshold component when alert type is anomaly based', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => {
renderAlertCondition();
// Click on anomaly tab to switch to anomaly-based alert
@@ -215,7 +179,8 @@ describe('AlertCondition', () => {
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
});
it('switches between threshold and anomaly tabs', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('switches between threshold and anomaly tabs', () => {
renderAlertCondition();
// Initially shows threshold component
@@ -240,7 +205,8 @@ describe('AlertCondition', () => {
).not.toBeInTheDocument();
});
it('applies active tab styling correctly', () => {
// TODO: Unskip this when anomaly tab is implemented
it.skip('applies active tab styling correctly', () => {
renderAlertCondition();
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
@@ -261,10 +227,11 @@ describe('AlertCondition', () => {
it('shows multiple tabs for METRICS_BASED_ALERT', () => {
renderAlertCondition('METRIC_BASED_ALERT');
// TODO: uncomment this when anomaly tab is implemented
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows multiple tabs for ANOMALY_BASED_ALERT', () => {
@@ -272,8 +239,9 @@ describe('AlertCondition', () => {
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(THRESHOLD_VIEW_TEST_ID)).toBeInTheDocument();
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
// TODO: uncomment this when anomaly tab is implemented
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
// expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
});
it('shows only threshold tab for LOGS_BASED_ALERT', () => {

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,12 +1,20 @@
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 { AlertTypes } from 'types/api/alerts/alertTypes';
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,
@@ -98,8 +106,34 @@ describe('CreateAlertHeader', () => {
).toHaveValue('TEST_ALERT');
});
it('should not render "switch to classic experience" button', () => {
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

@@ -14,7 +14,10 @@ import APIError from 'types/api/error';
import { isModifierKeyPressed } from 'utils/app';
import { useCreateAlertState } from '../context';
import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
import {
buildCreateThresholdAlertRulePayload,
validateCreateAlertState,
} from './utils';
import './styles.scss';
import {
@@ -82,7 +85,7 @@ function Footer(): JSX.Element {
);
const handleTestNotification = useCallback((): void => {
const payload = buildCreateAlertRulePayload({
const payload = buildCreateThresholdAlertRulePayload({
alertType,
basicAlertState,
thresholdState,
@@ -119,7 +122,7 @@ function Footer(): JSX.Element {
const queryClient = useQueryClient();
const handleSaveAlert = useCallback((): void => {
const payload = buildCreateAlertRulePayload({
const payload = buildCreateThresholdAlertRulePayload({
alertType,
basicAlertState,
thresholdState,

View File

@@ -18,8 +18,6 @@ import { EQueryType } from 'types/common/dashboard';
import { BuildCreateAlertRulePayloadArgs } from '../types';
import {
buildCreateAlertRulePayload,
buildCreateAnomalyAlertRulePayload,
buildCreateThresholdAlertRulePayload,
getAlertOnAbsentProps,
getEnforceMinimumDatapointsProps,
@@ -552,115 +550,4 @@ describe('Footer utils', () => {
},
);
});
describe('buildCreateAnomalyAlertRulePayload', () => {
const mockCreateAlertContextState = createMockAlertContextState();
const ANOMALY_PAYLOAD_ARGS: BuildCreateAlertRulePayloadArgs = {
basicAlertState: mockCreateAlertContextState.alertState,
thresholdState: {
...mockCreateAlertContextState.thresholdState,
thresholds: [
{
...mockCreateAlertContextState.thresholdState.thresholds[0],
thresholdValue: 3,
},
],
},
advancedOptions: mockCreateAlertContextState.advancedOptions,
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
notificationSettings: mockCreateAlertContextState.notificationSettings,
query: initialQueriesMap.metrics,
alertType: AlertTypes.ANOMALY_BASED_ALERT,
};
it('builds a v2alpha1 anomaly rule payload', () => {
const props = buildCreateAnomalyAlertRulePayload(ANOMALY_PAYLOAD_ARGS);
expect(props.ruleType).toBe('anomaly_rule');
expect(props.schemaVersion).toBe('v2alpha1');
expect(props.version).toBe('v5');
// The stored alertType is metric based; anomaly is a rule type
expect(props.alertType).toBe('METRIC_BASED_ALERT');
expect(props.condition.algorithm).toBe(
ANOMALY_PAYLOAD_ARGS.thresholdState.algorithm,
);
expect(props.condition.seasonality).toBe(
ANOMALY_PAYLOAD_ARGS.thresholdState.seasonality,
);
expect(props.condition.selectedQueryName).toBe(
ANOMALY_PAYLOAD_ARGS.thresholdState.selectedQuery,
);
// Evaluation comes from the anomaly condition's own window
expect(props.evaluation).toStrictEqual({
kind: 'rolling',
spec: {
evalWindow: ANOMALY_PAYLOAD_ARGS.thresholdState.evaluationWindow,
frequency: '1m',
},
});
});
it('keeps the target positive for the above operator', () => {
const props = buildCreateAnomalyAlertRulePayload({
...ANOMALY_PAYLOAD_ARGS,
thresholdState: {
...ANOMALY_PAYLOAD_ARGS.thresholdState,
operator: 'above',
},
});
expect(props.condition.thresholds?.spec[0].target).toBe(3);
expect(props.condition.thresholds?.spec[0].op).toBe('above');
});
it.each([['below'], ['2']])(
'negates the target for the below operator (%s)',
(op) => {
const props = buildCreateAnomalyAlertRulePayload({
...ANOMALY_PAYLOAD_ARGS,
thresholdState: {
...ANOMALY_PAYLOAD_ARGS.thresholdState,
operator: op,
},
});
expect(props.condition.thresholds?.spec[0].target).toBe(-3);
expect(props.condition.thresholds?.spec[0].op).toBe(op);
},
);
it('keeps the target positive for the outside_bounds operator', () => {
const props = buildCreateAnomalyAlertRulePayload({
...ANOMALY_PAYLOAD_ARGS,
thresholdState: {
...ANOMALY_PAYLOAD_ARGS.thresholdState,
operator: 'outside_bounds',
},
});
expect(props.condition.thresholds?.spec[0].target).toBe(3);
});
});
describe('buildCreateAlertRulePayload', () => {
const mockCreateAlertContextState = createMockAlertContextState();
const args: BuildCreateAlertRulePayloadArgs = {
basicAlertState: mockCreateAlertContextState.alertState,
thresholdState: mockCreateAlertContextState.thresholdState,
advancedOptions: mockCreateAlertContextState.advancedOptions,
evaluationWindow: mockCreateAlertContextState.evaluationWindow,
notificationSettings: mockCreateAlertContextState.notificationSettings,
query: initialQueriesMap.metrics,
alertType: mockCreateAlertContextState.alertType,
};
it('builds an anomaly payload for anomaly based alerts', () => {
const props = buildCreateAlertRulePayload({
...args,
alertType: AlertTypes.ANOMALY_BASED_ALERT,
});
expect(props.ruleType).toBe('anomaly_rule');
});
it('builds a threshold payload for other alert types', () => {
const props = buildCreateAlertRulePayload(args);
expect(props.ruleType).toBe('threshold_rule');
});
});
});

View File

@@ -2,7 +2,6 @@ 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 {
BasicThreshold,
PostableAlertRuleV2,
@@ -12,11 +11,9 @@ import { compositeQueryToQueryEnvelope } from 'utils/compositeQueryToQueryEnvelo
import {
AdvancedOptionsState,
AlertThresholdOperator,
EvaluationWindowState,
NotificationSettingsState,
} from '../context/types';
import { normalizeOperator } from '../utils';
import { BuildCreateAlertRulePayloadArgs } from './types';
// Get formatted time/unit pairs for create alert api payload
@@ -291,15 +288,16 @@ export function buildCreateThresholdAlertRulePayload(
}
// Build Create Anomaly Alert Rule Payload
// TODO: Update this function before enabling anomaly alert rule creation
export function buildCreateAnomalyAlertRulePayload(
args: BuildCreateAlertRulePayloadArgs,
): PostableAlertRuleV2 {
const {
alertType,
basicAlertState,
thresholdState,
query,
notificationSettings,
evaluationWindow,
advancedOptions,
} = args;
@@ -315,55 +313,19 @@ export function buildCreateAnomalyAlertRulePayload(
unit: basicAlertState.yAxisUnit,
});
// v2alpha1 thresholds are literal: "3 deviations below the predicted data"
// means the anomaly z-score must drop under -3, so the target is negated
// for the below operator (the deviations input is always positive).
const isBelowOperator =
normalizeOperator(thresholdState.operator) ===
AlertThresholdOperator.IS_BELOW;
const thresholds: BasicThreshold[] = thresholdState.thresholds.map(
(threshold) => {
const deviations = Math.abs(parseFloat(threshold.thresholdValue.toString()));
return {
name: threshold.label,
target: isBelowOperator ? -deviations : deviations,
matchType: thresholdState.matchType,
op: thresholdState.operator,
channels: threshold.channels,
targetUnit: threshold.unit,
};
},
);
const alertOnAbsentProps = getAlertOnAbsentProps(advancedOptions);
const enforceMinimumDatapointsProps =
getEnforceMinimumDatapointsProps(advancedOptions);
const evaluationProps = getEvaluationProps(evaluationWindow, advancedOptions);
const notificationSettingsProps =
getNotificationSettingsProps(notificationSettings);
// The anomaly condition carries its own evaluation window
// ("during the last X"), so the evaluation is always a rolling window.
const frequency = getFormattedTimeValue(
advancedOptions.evaluationCadence.default.value,
advancedOptions.evaluationCadence.default.timeUnit,
);
return {
alert: basicAlertState.name,
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
alertType:
alertType === AlertTypes.ANOMALY_BASED_ALERT
? AlertTypes.METRICS_BASED_ALERT
: alertType,
alertType,
condition: {
thresholds: {
kind: 'basic',
spec: thresholds,
},
compositeQuery,
selectedQueryName: thresholdState.selectedQuery,
algorithm: thresholdState.algorithm,
seasonality: thresholdState.seasonality,
...alertOnAbsentProps,
...enforceMinimumDatapointsProps,
},
@@ -373,25 +335,9 @@ export function buildCreateAnomalyAlertRulePayload(
summary: notificationSettings.description,
},
notificationSettings: notificationSettingsProps,
evaluation: {
kind: 'rolling',
spec: {
evalWindow: thresholdState.evaluationWindow,
frequency,
},
},
version: 'v5',
schemaVersion: 'v2alpha1',
evaluation: evaluationProps,
version: '',
schemaVersion: '',
source: window?.location.toString(),
};
}
// Build the create/test alert rule payload for the selected alert type
export function buildCreateAlertRulePayload(
args: BuildCreateAlertRulePayloadArgs,
): PostableAlertRuleV2 {
if (args.alertType === AlertTypes.ANOMALY_BASED_ALERT) {
return buildCreateAnomalyAlertRulePayload(args);
}
return buildCreateThresholdAlertRulePayload(args);
}

View File

@@ -316,60 +316,6 @@ describe('CreateAlertV2 utils', () => {
});
});
describe('getThresholdStateFromAlertDef for anomaly rules', () => {
const anomalyAlertDef: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
ruleType: 'anomaly_rule',
condition: {
...defaultPostableAlertRuleV2.condition,
algorithm: 'standard',
seasonality: 'daily',
selectedQueryName: 'A',
thresholds: {
kind: 'basic',
spec: [
{
name: 'critical',
target: -3,
targetUnit: '',
channels: ['email'],
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
op: AlertThresholdOperator.IS_BELOW,
},
],
},
},
evaluation: {
kind: 'rolling',
spec: {
evalWindow: '1h0m0s',
frequency: '1m',
},
},
};
it('shows the absolute deviation value for negative anomaly targets', () => {
const props = getThresholdStateFromAlertDef(anomalyAlertDef);
expect(props.thresholds[0].thresholdValue).toBe(3);
expect(props.operator).toBe(AlertThresholdOperator.IS_BELOW);
});
it('hydrates the anomaly evaluation window, algorithm and seasonality', () => {
const props = getThresholdStateFromAlertDef(anomalyAlertDef);
expect(props.evaluationWindow).toBe('1h0m0s');
expect(props.algorithm).toBe('standard');
expect(props.seasonality).toBe('daily');
});
it('does not touch the target for non-anomaly rules', () => {
const props = getThresholdStateFromAlertDef({
...anomalyAlertDef,
ruleType: 'threshold_rule',
});
expect(props.thresholds[0].thresholdValue).toBe(-3);
});
});
describe('normalizeOperator', () => {
it.each([
['1', AlertThresholdOperator.IS_ABOVE],

View File

@@ -239,39 +239,6 @@ describe('CreateAlertV2 Context Utils', () => {
});
});
it('should set evaluation window', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_EVALUATION_WINDOW',
payload: TimeDuration.ONE_HOUR,
});
expect(result).toStrictEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
evaluationWindow: TimeDuration.ONE_HOUR,
});
});
it('should set algorithm', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_ALGORITHM',
payload: Algorithm.STANDARD,
});
expect(result).toStrictEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
algorithm: Algorithm.STANDARD,
});
});
it('should set seasonality', () => {
const result = alertThresholdReducer(INITIAL_ALERT_THRESHOLD_STATE, {
type: 'SET_SEASONALITY',
payload: Seasonality.WEEKLY,
});
expect(result).toStrictEqual({
...INITIAL_ALERT_THRESHOLD_STATE,
seasonality: Seasonality.WEEKLY,
});
});
it('should set thresholds', () => {
const newThresholds = [
{

View File

@@ -124,12 +124,6 @@ export const alertThresholdReducer = (
return { ...state, operator: action.payload };
case 'SET_MATCH_TYPE':
return { ...state, matchType: action.payload };
case 'SET_EVALUATION_WINDOW':
return { ...state, evaluationWindow: action.payload };
case 'SET_ALGORITHM':
return { ...state, algorithm: action.payload };
case 'SET_SEASONALITY':
return { ...state, seasonality: action.payload };
case 'SET_THRESHOLDS':
return { ...state, thresholds: action.payload };
case 'RESET':

View File

@@ -4,7 +4,6 @@ import { Spin } from 'antd';
import { TIMEZONE_DATA } from 'components/CustomTimePicker/timezoneUtils';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { getRandomColor } from 'container/ExplorerOptions/utils';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import { PostableAlertRuleV2 } from 'types/api/alerts/alertTypesV2';
import { v4 } from 'uuid';
@@ -304,19 +303,13 @@ export function normalizeMatchType(
export function getThresholdStateFromAlertDef(
alertDef: PostableAlertRuleV2,
): AlertThresholdState {
// Anomaly targets are stored as literal z-scores (negative for the below
// operator); the deviations select always shows the positive value.
const isAnomalyRule =
alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT;
return {
...INITIAL_ALERT_THRESHOLD_STATE,
thresholds:
alertDef.condition.thresholds?.spec.map((threshold) => ({
id: v4(),
label: threshold.name,
thresholdValue: isAnomalyRule
? Math.abs(threshold.target)
: threshold.target,
thresholdValue: threshold.target,
recoveryThresholdValue: null,
unit: threshold.targetUnit,
color: getColorForThreshold(threshold.name),
@@ -328,18 +321,6 @@ export function getThresholdStateFromAlertDef(
matchType:
alertDef.condition.thresholds?.spec[0].matchType ||
AlertThresholdMatchType.AT_LEAST_ONCE,
...(isAnomalyRule
? {
evaluationWindow:
alertDef.evaluation?.spec?.evalWindow ||
INITIAL_ALERT_THRESHOLD_STATE.evaluationWindow,
algorithm:
alertDef.condition.algorithm || INITIAL_ALERT_THRESHOLD_STATE.algorithm,
seasonality:
alertDef.condition.seasonality ||
INITIAL_ALERT_THRESHOLD_STATE.seasonality,
}
: {}),
};
}

View File

@@ -14,28 +14,11 @@
box-sizing: border-box;
}
.entityMetricsTitleContainer {
display: flex;
align-items: center;
gap: 8px;
}
.entityMetricsTitle {
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.metricsExplorerLink {
display: flex;
align-items: center;
color: var(--l2-foreground);
transition: opacity 0.2s;
&:hover {
color: var(--l3-foreground);
}
}
.metricsHeader {
display: flex;
justify-content: flex-end;

View File

@@ -1,8 +1,6 @@
import { useCallback, useMemo, useRef } from 'react';
import { UseQueryResult } from 'react-query';
import { Link } from 'react-router-dom';
import { Compass } from '@signozhq/icons';
import { Skeleton, Tooltip } from 'antd';
import { Skeleton } from 'antd';
import cx from 'classnames';
import { PANEL_TYPES } from 'constants/queryBuilder';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
@@ -21,7 +19,6 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { useTimezone } from 'providers/Timezone';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { getMetricsExplorerUrl } from 'utils/explorerUtils';
import { buildEntityMetricsChartConfig } from './configBuilder';
@@ -207,31 +204,9 @@ function EntityMetrics<T>({
key={entityWidgetInfo[idx].title}
className={styles.entityMetricsCol}
>
<div className={styles.entityMetricsTitleContainer}>
<span className={styles.entityMetricsTitle}>
{entityWidgetInfo[idx].title}
</span>
{queryPayloads[idx] &&
queryPayloads[idx].graphType !== PANEL_TYPES.TABLE && (
<Tooltip title="Open in Metrics Explorer">
<Link
to={getMetricsExplorerUrl({
query: queryPayloads[idx].query,
...(selectedInterval && selectedInterval !== 'custom'
? { relativeTime: selectedInterval }
: {
startTimeMs: timeRange.startTime * 1000,
endTimeMs: timeRange.endTime * 1000,
}),
})}
className={styles.metricsExplorerLink}
data-testid={`open-metrics-explorer-${idx}`}
>
<Compass size={14} />
</Link>
</Tooltip>
)}
</div>
<span className={styles.entityMetricsTitle}>
{entityWidgetInfo[idx].title}
</span>
<div className={styles.entityMetricsCard} ref={graphRef}>
{renderCardContent(query, idx)}
</div>

View File

@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import * as appContextHooks from 'providers/App/App';
@@ -296,19 +295,17 @@ const renderEntityMetrics = (overrides = {}): any => {
};
return render(
<MemoryRouter>
<EntityMetrics
timeRange={defaultProps.timeRange}
isModalTimeSelection={defaultProps.isModalTimeSelection}
handleTimeChange={defaultProps.handleTimeChange}
selectedInterval={defaultProps.selectedInterval}
entity={defaultProps.entity}
entityWidgetInfo={defaultProps.entityWidgetInfo}
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
queryKey={defaultProps.queryKey}
category={defaultProps.category}
/>
</MemoryRouter>,
<EntityMetrics
timeRange={defaultProps.timeRange}
isModalTimeSelection={defaultProps.isModalTimeSelection}
handleTimeChange={defaultProps.handleTimeChange}
selectedInterval={defaultProps.selectedInterval}
entity={defaultProps.entity}
entityWidgetInfo={defaultProps.entityWidgetInfo}
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
queryKey={defaultProps.queryKey}
category={defaultProps.category}
/>,
);
};
@@ -337,8 +334,8 @@ const mockTableData: (import('../utils').MetricsTableData[] | null)[] = [
];
const mockQueryPayloads = [
{ graphType: 'graph', query: { queryType: 'builder' } }, // time_series
{ graphType: 'table', query: { queryType: 'builder' } }, // table
{ graphType: 'graph' }, // time_series
{ graphType: 'table' }, // table
];
describe('EntityMetrics', () => {
@@ -445,34 +442,6 @@ describe('EntityMetrics', () => {
);
});
it('renders metrics explorer link only for non-table panels', () => {
renderEntityMetrics();
expect(screen.getByTestId('open-metrics-explorer-0')).toBeInTheDocument();
expect(
screen.queryByTestId('open-metrics-explorer-1'),
).not.toBeInTheDocument();
});
it('builds metrics explorer link with relativeTime when a relative interval is selected', () => {
renderEntityMetrics({ selectedInterval: '5m' as Time });
const href = screen
.getByTestId('open-metrics-explorer-0')
.getAttribute('href');
expect(href).toContain('relativeTime=5m');
expect(href).not.toContain('startTime=');
expect(href).not.toContain('endTime=');
});
it('builds metrics explorer link with absolute time range in milliseconds for custom interval', () => {
renderEntityMetrics({ selectedInterval: 'custom' as Time });
const href = screen
.getByTestId('open-metrics-explorer-0')
.getAttribute('href');
expect(href).toContain(`startTime=${mockTimeRange.startTime * 1000}`);
expect(href).toContain(`endTime=${mockTimeRange.endTime * 1000}`);
expect(href).not.toContain('relativeTime=');
});
it('passes correct parameters to useEntityMetrics hook', () => {
renderEntityMetrics();
expect(mockUseEntityMetrics).toHaveBeenCalledWith(

View File

@@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom';
import cx from 'classnames';
import { Pagination, Skeleton } from 'antd';
import { useListRoles } from 'api/generated/services/role';
import { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ROUTES from 'constants/routes';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
@@ -21,7 +21,7 @@ const PAGE_SIZE = 20;
type DisplayItem =
| { type: 'section'; label: string; count?: number }
| { type: 'role'; role: AuthtypesGettableRoleDTO };
| { type: 'role'; role: AuthtypesRoleDTO };
interface RolesListContentProps {
searchQuery: string;
@@ -176,7 +176,7 @@ function RolesListContent({ searchQuery }: RolesListContentProps): JSX.Element {
);
}
const renderRow = (role: AuthtypesGettableRoleDTO): JSX.Element => (
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
<div
key={role.id}
className={cx(styles.tableRow, {

View File

@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from 'react-query';
import { ErrorType } from 'api/generatedAPIInstance';
import type {
AuthtypesPostableRoleDTO,
AuthtypesRoleDTO,
AuthtypesRoleWithTransactionGroupsDTO,
AuthtypesTransactionGroupDTO,
AuthtypesUpdatableRoleDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -133,7 +133,7 @@ export function transformTransactionGroupsToResourcePermissions(
}
export function transformApiToRolePermissions(
role: AuthtypesRoleDTO,
role: AuthtypesRoleWithTransactionGroupsDTO,
): RolePermissionsData {
return {
roleId: role.id,

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import {
getGetRolesByUserIDQueryKey,
useGetRolesByUserID,
@@ -21,11 +21,11 @@ export interface MemberRoleUpdateFailure {
}
interface UseMemberRoleManagerResult {
currentRoles: AuthtypesGettableRoleDTO[];
currentRoles: AuthtypesRoleDTO[];
isLoading: boolean;
applyDiff: (
localRoleIds: string[],
availableRoles: AuthtypesGettableRoleDTO[],
availableRoles: AuthtypesRoleDTO[],
) => Promise<MemberRoleUpdateFailure[]>;
}
@@ -40,7 +40,7 @@ export function useMemberRoleManager(
{ query: { enabled: !!userId && enabled } },
);
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
() => data?.data ?? [],
[data?.data],
);
@@ -61,7 +61,7 @@ export function useMemberRoleManager(
const applyDiff = useCallback(
async (
localRoleIds: string[],
availableRoles: AuthtypesGettableRoleDTO[],
availableRoles: AuthtypesRoleDTO[],
): Promise<MemberRoleUpdateFailure[]> => {
const currentRoleIds = new Set(
currentRoles.map((r) => r.id).filter(Boolean),

View File

@@ -6,7 +6,7 @@ import {
useDeleteServiceAccountRoleDeprecated,
useGetServiceAccountRoles,
} from 'api/generated/services/serviceaccount';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import { retryOn429 } from 'utils/errorUtils';
const enum PromiseStatus {
@@ -21,11 +21,11 @@ export interface RoleUpdateFailure {
}
interface UseServiceAccountRoleManagerResult {
currentRoles: AuthtypesGettableRoleDTO[];
currentRoles: AuthtypesRoleDTO[];
isLoading: boolean;
applyDiff: (
localRoleIds: string[],
availableRoles: AuthtypesGettableRoleDTO[],
availableRoles: AuthtypesRoleDTO[],
) => Promise<RoleUpdateFailure[]>;
}
@@ -40,7 +40,7 @@ export function useServiceAccountRoleManager(
{ query: { enabled: options?.enabled ?? true } },
);
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
() => data?.data ?? [],
[data?.data],
);
@@ -64,7 +64,7 @@ export function useServiceAccountRoleManager(
const applyDiff = useCallback(
async (
localRoleIds: string[],
availableRoles: AuthtypesGettableRoleDTO[],
availableRoles: AuthtypesRoleDTO[],
): Promise<RoleUpdateFailure[]> => {
const currentRoleIds = new Set(
currentRoles.map((r) => r.id).filter(Boolean),

View File

@@ -0,0 +1,183 @@
import 'tests/blob-polyfill';
import { fetchExportData } from 'api/v1/download/downloadExportData';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { downloadFile } from 'lib/exportData/downloadFile';
import { ExportFormat } from 'lib/exportData/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { runChunkedExport } from '../runChunkedExport';
jest.mock('api/v1/download/downloadExportData', () => ({
fetchExportData: jest.fn(),
}));
jest.mock('api/v5/v5', () => ({
prepareQueryRangePayloadV5: jest.fn(),
}));
jest.mock('lib/exportData/downloadFile', () => ({
downloadFile: jest.fn(),
getTimestampedFileName: jest.fn(
(base: string, ext: string) => `${base}.${ext}`,
),
}));
const mockFetch = fetchExportData as jest.Mock;
const mockPreparePayload = prepareQueryRangePayloadV5 as jest.Mock;
const mockDownloadFile = downloadFile as jest.Mock;
// Minimal query shape — the runner only maps over builder.queryData.
const query = {
builder: { queryData: [{}] },
} as unknown as Query;
const baseArgs = {
query,
timeRange: { start: 100, end: 200 },
fileNameBase: 'trace-x',
format: ExportFormat.Csv,
onProgress: jest.fn(),
};
// EXPORT_PAGE_SIZE is 50_000; rows → parts: 120k → 3, 40k → 1, etc.
const rowsForParts = (parts: number): number => parts * 50_000;
function runArgs(
overrides: Partial<Parameters<typeof runChunkedExport>[0]> = {},
): Parameters<typeof runChunkedExport>[0] {
return {
...baseArgs,
totalRows: rowsForParts(1),
signal: new AbortController().signal,
onProgress: jest.fn(),
...overrides,
};
}
async function savedFileText(): Promise<string> {
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
const [blob] = mockDownloadFile.mock.calls[0];
return (blob as Blob).text();
}
describe('runChunkedExport', () => {
beforeEach(() => {
jest.clearAllMocks();
mockPreparePayload.mockImplementation(({ query: pageQuery }) => ({
// Echo the page offset so fetch calls can be asserted per page.
queryPayload: { offset: pageQuery.builder.queryData[0].offset },
}));
});
it('fetches one page for small exports and saves the file', async () => {
mockFetch.mockResolvedValueOnce(new Blob(['h\na,b\n']));
const onProgress = jest.fn();
await runChunkedExport(runArgs({ totalRows: 40_000, onProgress }));
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0][0].body).toStrictEqual({ offset: 0 });
expect(onProgress).toHaveBeenCalledWith(100);
await expect(savedFileText()).resolves.toBe('h\na,b\n');
});
it('fetches every page with the right offsets and stitches in order', async () => {
mockFetch.mockImplementation(({ body }) =>
Promise.resolve(new Blob([`h\nrow-${body.offset}\n`])),
);
await runChunkedExport(runArgs({ totalRows: rowsForParts(3) }));
const offsets = mockFetch.mock.calls.map(([props]) => props.body.offset);
expect(offsets.sort((a, b) => a - b)).toStrictEqual([0, 50_000, 100_000]);
await expect(savedFileText()).resolves.toBe(
'h\nrow-0\nrow-50000\nrow-100000\n',
);
});
it('keeps page order even when later pages resolve first', async () => {
const resolvers: Record<number, (b: Blob) => void> = {};
mockFetch.mockImplementation(
({ body }) =>
new Promise((resolve) => {
resolvers[body.offset] = resolve;
}),
);
const promise = runChunkedExport(runArgs({ totalRows: rowsForParts(3) }));
// All 3 pages are in flight (concurrency 3); resolve in reverse order.
await Promise.resolve();
resolvers[100_000](new Blob(['h\nc\n']));
resolvers[50_000](new Blob(['h\nb\n']));
resolvers[0](new Blob(['h\na\n']));
await promise;
await expect(savedFileText()).resolves.toBe('h\na\nb\nc\n');
});
it('stops claiming pages once the server runs dry', async () => {
// Count says 5 pages but the server only has data for page 0.
mockFetch.mockImplementation(({ body }) =>
Promise.resolve(body.offset === 0 ? new Blob(['h\na\n']) : new Blob([])),
);
await runChunkedExport(runArgs({ totalRows: rowsForParts(5) }));
// Workers stop claiming once an empty part is observed — exact count is
// timing-dependent, but it must never fetch all 5 pages.
expect(mockFetch.mock.calls.length).toBeLessThan(5);
await expect(savedFileText()).resolves.toBe('h\na\n');
});
it('rejects on page failure, aborts in-flight siblings, saves nothing', async () => {
const seenSignals: AbortSignal[] = [];
mockFetch.mockImplementation(({ body, signal }) => {
seenSignals.push(signal);
return body.offset === 50_000
? Promise.reject(new Error('boom'))
: new Promise(() => {}); // siblings hang until aborted
});
await expect(
runChunkedExport(runArgs({ totalRows: rowsForParts(3) })),
).rejects.toThrow('boom');
expect(seenSignals.every((s) => s.aborted)).toBe(true);
expect(mockDownloadFile).not.toHaveBeenCalled();
});
it('propagates a user abort to the page fetches', async () => {
const controller = new AbortController();
mockFetch.mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
);
}),
);
const promise = runChunkedExport(
runArgs({ totalRows: rowsForParts(2), signal: controller.signal }),
);
await Promise.resolve();
controller.abort();
await expect(promise).rejects.toThrow('Aborted');
expect(mockDownloadFile).not.toHaveBeenCalled();
});
it('reports whole-number progress per completed part', async () => {
mockFetch.mockImplementation(({ body }) =>
Promise.resolve(new Blob([`h\nrow-${body.offset}\n`])),
);
const onProgress = jest.fn();
await runChunkedExport(runArgs({ totalRows: rowsForParts(3), onProgress }));
const reported = onProgress.mock.calls.map(([p]) => p);
expect(reported).toHaveLength(3);
expect(reported).toStrictEqual(expect.arrayContaining([33, 67, 100]));
});
});

View File

@@ -0,0 +1,4 @@
export const EXPORT_PAGE_SIZE = 50_000;
/** Pages fetched in parallel.*/
export const EXPORT_CONCURRENCY = 3;

View File

@@ -0,0 +1,143 @@
import { fetchExportData } from 'api/v1/download/downloadExportData';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
downloadFile,
getTimestampedFileName,
} from 'lib/exportData/downloadFile';
import { stitchCsvParts, stitchJsonlParts } from 'lib/exportData/stitchParts';
import { ExportFormat } from 'lib/exportData/types';
import store from 'store';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import { EXPORT_CONCURRENCY, EXPORT_PAGE_SIZE } from './constants';
export interface ChunkedExportArgs {
query: Query;
timeRange: { start: number; end: number };
// Total rows the query is expected to return; drives the page count.
totalRows: number;
fileNameBase: string;
}
interface RunChunkedExportProps extends ChunkedExportArgs {
format: ExportFormat;
signal: AbortSignal;
// 0100 whole number: parts completed over total parts.
onProgress: (progress: number) => void;
}
const MIME_BY_FORMAT: Record<ExportFormat, string> = {
[ExportFormat.Csv]: 'text/csv',
[ExportFormat.Jsonl]: 'application/x-ndjson',
};
/**
* Chunked server export: fetches export_raw_data pages through a bounded
* worker pool (EXPORT_CONCURRENCY wide — pages are independent since offsets
* are precomputable), stitches the parts in page order client-side, and saves
* a single file.
*/
export async function runChunkedExport({
query,
timeRange,
totalRows,
fileNameBase,
format,
signal,
onProgress,
}: RunChunkedExportProps): Promise<void> {
const totalParts = Math.max(1, Math.ceil(totalRows / EXPORT_PAGE_SIZE));
const workerCount = Math.min(Math.max(1, EXPORT_CONCURRENCY), totalParts);
// Internal controller so the first failed page (or a user cancel) stops the
// whole fleet instead of letting siblings run to completion for nothing.
const fleet = new AbortController();
const onExternalAbort = (): void => fleet.abort();
signal.addEventListener('abort', onExternalAbort);
if (signal.aborted) {
fleet.abort();
}
const buildPagePayload = (page: number): QueryRangePayloadV5 => {
const pageQuery: Query = {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((qd) => ({
...qd,
limit: EXPORT_PAGE_SIZE,
pageSize: EXPORT_PAGE_SIZE,
offset: page * EXPORT_PAGE_SIZE,
})),
},
};
return prepareQueryRangePayloadV5({
query: pageQuery,
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
// Read directly (not via hooks) — the absolute bounds below take
// precedence over the global picker in the payload anyway.
globalSelectedInterval: store.getState().globalTime.selectedTime,
start: timeRange.start,
end: timeRange.end,
}).queryPayload;
};
// Indexed by page so out-of-order completion can't scramble the stitch order.
const parts = new Array<Blob | undefined>(totalParts);
let nextPage = 0;
let completedParts = 0;
let serverRanDry = false;
const worker = async (): Promise<void> => {
while (!serverRanDry) {
const page = nextPage;
nextPage += 1;
if (page >= totalParts) {
return;
}
// eslint-disable-next-line no-await-in-loop
const part = await fetchExportData({
format,
body: buildPagePayload(page),
signal: fleet.signal,
});
// Empty part = the row count drifted and the server ran dry early;
// stop claiming further pages (later in-flight pages are empty too).
if (part.size === 0) {
serverRanDry = true;
return;
}
parts[page] = part;
completedParts += 1;
onProgress(Math.round((completedParts / totalParts) * 100));
}
};
try {
await Promise.all(Array.from({ length: workerCount }, () => worker()));
} catch (error) {
fleet.abort();
throw error;
} finally {
signal.removeEventListener('abort', onExternalAbort);
}
const orderedParts = parts.filter((part): part is Blob => Boolean(part));
const stitched =
format === ExportFormat.Csv
? await stitchCsvParts(orderedParts)
: await stitchJsonlParts(orderedParts);
downloadFile(
stitched,
getTimestampedFileName(fileNameBase, format),
MIME_BY_FORMAT[format],
);
}

View File

@@ -0,0 +1,100 @@
import 'tests/blob-polyfill';
import { stitchCsvParts, stitchJsonlParts } from '../stitchParts';
const blob = (content: string): Blob => new Blob([content]);
describe('stitchCsvParts', () => {
it('keeps a single part as-is', async () => {
const out = await stitchCsvParts([blob('h1,h2\na,b\n')]);
await expect(out.text()).resolves.toBe('h1,h2\na,b\n');
expect(out.type).toBe('text/csv');
});
it('strips the header row from parts after the first', async () => {
const out = await stitchCsvParts([
blob('h1,h2\na,b\n'),
blob('h1,h2\nc,d\n'),
blob('h1,h2\ne,f\n'),
]);
await expect(out.text()).resolves.toBe('h1,h2\na,b\nc,d\ne,f\n');
});
it('handles CRLF line endings (the \\r dies with the header)', async () => {
const out = await stitchCsvParts([
blob('h1,h2\r\na,b\r\n'),
blob('h1,h2\r\nc,d\r\n'),
]);
await expect(out.text()).resolves.toBe('h1,h2\r\na,b\r\nc,d\r\n');
});
it('inserts a newline at the seam when a part lacks a trailing one', async () => {
const out = await stitchCsvParts([blob('h1,h2\na,b'), blob('h1,h2\nc,d')]);
await expect(out.text()).resolves.toBe('h1,h2\na,b\nc,d\n');
});
it('preserves multi-byte characters around the header boundary', async () => {
const out = await stitchCsvParts([
blob('höader,h2\naä,b\n'),
blob('höader,h2\ncö,d\n'),
]);
await expect(out.text()).resolves.toBe('höader,h2\naä,b\ncö,d\n');
});
it('strips headers longer than the initial 8KB sniff window', async () => {
// Forces findFirstNewlineEnd through its window-doubling path.
const hugeHeader = Array.from({ length: 1200 }, (_, i) => `column_${i}`).join(
',',
);
expect(hugeHeader.length).toBeGreaterThan(8192);
const out = await stitchCsvParts([
blob(`${hugeHeader}\na,b\n`),
blob(`${hugeHeader}\nc,d\n`),
]);
await expect(out.text()).resolves.toBe(`${hugeHeader}\na,b\nc,d\n`);
});
it('skips empty and header-only parts', async () => {
const out = await stitchCsvParts([
blob('h1,h2\na,b\n'),
blob(''),
blob('h1,h2\n'),
blob('h1,h2'),
blob('h1,h2\nc,d\n'),
]);
await expect(out.text()).resolves.toBe('h1,h2\na,b\nc,d\n');
});
it('returns an empty blob for no parts', async () => {
const out = await stitchCsvParts([]);
expect(out.size).toBe(0);
});
});
describe('stitchJsonlParts', () => {
it('concatenates parts without stripping anything', async () => {
const out = await stitchJsonlParts([
blob('{"a":1}\n{"a":2}\n'),
blob('{"a":3}\n'),
]);
await expect(out.text()).resolves.toBe('{"a":1}\n{"a":2}\n{"a":3}\n');
expect(out.type).toBe('application/x-ndjson');
});
it('guards the seam when a part lacks a trailing newline', async () => {
const out = await stitchJsonlParts([blob('{"a":1}'), blob('{"a":2}')]);
const text = await out.text();
expect(text).toBe('{"a":1}\n{"a":2}\n');
// Every line must stay independently parseable.
const lines = text.trim().split('\n');
expect(lines.map((line) => JSON.parse(line))).toStrictEqual([
{ a: 1 },
{ a: 2 },
]);
});
it('skips empty parts', async () => {
const out = await stitchJsonlParts([blob(''), blob('{"a":1}\n'), blob('')]);
await expect(out.text()).resolves.toBe('{"a":1}\n');
});
});

View File

@@ -1,6 +1,6 @@
/** Triggers a browser download of in-memory string content as a file. */
/** Triggers a browser download of in-memory content (string or Blob) as a file. */
export function downloadFile(
content: string,
content: string | Blob,
fileName: string,
mime: string,
): void {

View File

@@ -0,0 +1,85 @@
/**
* Stitches sequentially-fetched export parts (Blobs) into one downloadable
* Blob. Pure blob surgery — parts are never parsed and `Blob.slice`/`new Blob`
* are zero-copy, so nothing here scales with the data size except the final
* browser-managed blob itself.
*/
const HEADER_SNIFF_BYTES = 8192;
const NEWLINE_BYTE = 0x0a;
/** Byte offset just past the first newline, or -1 when the blob has none. */
async function findFirstNewlineEnd(blob: Blob): Promise<number> {
let sniffBytes = HEADER_SNIFF_BYTES;
for (;;) {
// eslint-disable-next-line no-await-in-loop
const bytes = new Uint8Array(await blob.slice(0, sniffBytes).arrayBuffer());
const newlineIdx = bytes.indexOf(NEWLINE_BYTE);
if (newlineIdx !== -1) {
return newlineIdx + 1;
}
if (sniffBytes >= blob.size) {
return -1;
}
sniffBytes *= 2;
}
}
async function endsWithNewline(blob: Blob): Promise<boolean> {
const lastByte = new Uint8Array(await blob.slice(blob.size - 1).arrayBuffer());
return lastByte[0] === NEWLINE_BYTE;
}
async function stitchParts(
parts: Blob[],
{
stripHeaderAfterFirst,
mime,
}: { stripHeaderAfterFirst: boolean; mime: string },
): Promise<Blob> {
const pieces: (Blob | string)[] = [];
for (let index = 0; index < parts.length; index += 1) {
let piece = parts[index];
if (stripHeaderAfterFirst && index > 0) {
// Every part re-sends the header row; drop it on parts 2..N. Byte-level
// search (not text) so multi-byte characters can't skew the offset.
// ASSUMPTION: the first newline byte ends the header row — i.e. no
// column name contains an embedded (RFC 4180 quoted) newline. Header
// cells are telemetry field names, which can't carry newlines; quoted
// newlines in DATA rows are fine (we only search from byte 0).
// eslint-disable-next-line no-await-in-loop
const headerEnd = await findFirstNewlineEnd(piece);
// A part without any newline is header-only — nothing to keep.
piece = headerEnd === -1 ? piece.slice(piece.size) : piece.slice(headerEnd);
}
if (piece.size === 0) {
continue;
}
pieces.push(piece);
// Guard the seam: without a trailing newline the next part's first row
// would concatenate onto this part's last row.
// eslint-disable-next-line no-await-in-loop
if (!(await endsWithNewline(piece))) {
pieces.push('\n');
}
}
return new Blob(pieces, { type: mime });
}
/** Combines CSV parts: part 1 keeps its header, parts 2..N are decapitated. */
export async function stitchCsvParts(parts: Blob[]): Promise<Blob> {
return stitchParts(parts, { stripHeaderAfterFirst: true, mime: 'text/csv' });
}
/** Combines JSONL parts: plain concatenation with a newline guard per seam. */
export async function stitchJsonlParts(parts: Blob[]): Promise<Blob> {
return stitchParts(parts, {
stripHeaderAfterFirst: false,
mime: 'application/x-ndjson',
});
}

View File

@@ -1,11 +1,8 @@
import {
AuthtypesGettableRoleDTO,
AuthtypesRoleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
const orgId = '019ba2bb-2fa1-7b24-8159-cfca08617ef9';
export const managedRoles: AuthtypesGettableRoleDTO[] = [
export const managedRoles: AuthtypesRoleDTO[] = [
{
id: '019c24aa-2248-756f-9833-984f1ab63819',
createdAt: '2026-02-03T18:00:55.624356Z',
@@ -38,7 +35,7 @@ export const managedRoles: AuthtypesGettableRoleDTO[] = [
},
];
export const customRoles: AuthtypesGettableRoleDTO[] = [
export const customRoles: AuthtypesRoleDTO[] = [
{
id: '019c24aa-3333-0001-aaaa-111111111111',
createdAt: '2026-02-10T10:30:00.000Z',
@@ -59,24 +56,12 @@ export const customRoles: AuthtypesGettableRoleDTO[] = [
},
];
export const allRoles: AuthtypesGettableRoleDTO[] = [
...managedRoles,
...customRoles,
];
export const allRoles: AuthtypesRoleDTO[] = [...managedRoles, ...customRoles];
export const listRolesSuccessResponse = {
status: 'success',
data: allRoles,
};
const customRole: AuthtypesRoleDTO = {
...customRoles[0],
transactionGroups: [],
};
const managedRole: AuthtypesRoleDTO = {
...managedRoles[0],
transactionGroups: [],
};
export const customRoleResponse = { status: 'success', data: customRole };
export const managedRoleResponse = { status: 'success', data: managedRole };
export const customRoleResponse = { status: 'success', data: customRoles[0] };
export const managedRoleResponse = { status: 'success', data: managedRoles[0] };

View File

@@ -11,7 +11,6 @@ import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { CreateAlertProvider } from 'container/CreateAlertV2/context';
import { getCreateAlertLocalStateFromAlertDef } from 'container/CreateAlertV2/utils';
import { AlertDetectionTypes } from 'container/FormAlertRules';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { useAlertRule } from 'providers/Alert';
@@ -86,18 +85,11 @@ function AlertDetails(): JSX.Element {
return <Spinner />;
}
// Anomaly rules are stored with a metric alertType; the editor's alert
// type is derived from the ruleType so the anomaly condition is shown.
const initialAlertType =
alertRuleDetails?.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT
? AlertTypes.ANOMALY_BASED_ALERT
: (alertRuleDetails?.alertType as AlertTypes);
return (
<CreateAlertProvider
ruleId={ruleId || ''}
isEditMode
initialAlertType={initialAlertType}
initialAlertType={alertRuleDetails?.alertType as AlertTypes}
initialAlertState={initialAlertState}
>
<div

View File

@@ -21,6 +21,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceStore } from '../stores/traceStore';
import TraceDownloadPanel from './TraceDownloadPanel';
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
@@ -43,6 +44,7 @@ export interface TraceMetadataForHeader {
rootServiceEntryPoint: string;
rootSpanStatusCode: string;
hasMissingSpans: boolean;
totalSpansCount: number;
}
interface TraceDetailsHeaderProps {
@@ -168,6 +170,10 @@ function TraceDetailsHeader({
showTraceDetails={showTraceDetails}
onToggleTraceDetails={handleToggleTraceDetails}
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
traceId={traceID || ''}
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
totalSpansCount={traceMetadata?.totalSpansCount || 0}
/>
</div>
</TooltipProvider>
@@ -228,6 +234,8 @@ function TraceDetailsHeader({
onClose={(): void => setIsAnalyticsOpen(false)}
onTabChange={handleAnalyticsTabChange}
/>
<TraceDownloadPanel />
</div>
);
}

View File

@@ -0,0 +1,42 @@
.downloadPanel {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-4);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.title {
flex: 1;
display: flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--font-weight-medium);
color: var(--secondary-foreground);
white-space: nowrap;
}
.loader {
color: var(--bg-robin-500);
flex-shrink: 0;
}
.percent {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--font-weight-semibold);
color: var(--l2-foreground);
flex-shrink: 0;
}
.cancelBtn {
height: 24px;
width: 24px;
flex-shrink: 0;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,58 @@
import { useEffect } from 'react';
import { Button } from '@signozhq/ui/button';
import { Progress } from '@signozhq/ui/progress';
import { LoaderCircle, X } from '@signozhq/icons';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import { useTraceDownloadStore } from './traceDownloadStore';
import styles from './TraceDownloadPanel.module.scss';
const PANEL_WIDTH = 356;
const PANEL_HEIGHT = 76;
function TraceDownloadPanel(): JSX.Element {
const isDownloading = useTraceDownloadStore((s) => s.isDownloading);
const progress = useTraceDownloadStore((s) => s.progress);
const cancelDownload = useTraceDownloadStore((s) => s.cancelDownload);
useEffect(() => cancelDownload, [cancelDownload]);
const displayProgress = Math.max(1, progress);
return (
<FloatingPanel
isOpen={isDownloading}
width={PANEL_WIDTH}
height={PANEL_HEIGHT}
minWidth={PANEL_WIDTH}
minHeight={PANEL_HEIGHT}
enableResizing={false}
>
<div className={styles.downloadPanel} data-testid="trace-download-panel">
<div className={`${styles.header} floating-panel__drag-handle`}>
<span className={styles.title}>
Downloading trace
<LoaderCircle size={14} className={`animate-spin ${styles.loader}`} />
</span>
<span className={styles.percent} data-testid="trace-download-percent">
{displayProgress}%
</span>
<Button
variant="ghost"
size="icon"
color="secondary"
className={styles.cancelBtn}
onClick={cancelDownload}
aria-label="Cancel download"
data-testid="trace-download-cancel"
prefix={<X size={16} />}
/>
</div>
<Progress percent={displayProgress} status="active" showInfo={false} />
</div>
</FloatingPanel>
);
}
export default TraceDownloadPanel;

View File

@@ -12,8 +12,10 @@ import {
DropdownMenuTrigger,
} from '@signozhq/ui/dropdown-menu';
import { Settings2 } from '@signozhq/icons';
import { ExportFormat } from 'lib/exportData/types';
import { useTraceStore } from '../stores/traceStore';
import { useDownloadTrace } from './useDownloadTrace';
import styles from './TraceOptionsMenu.module.scss';
@@ -21,6 +23,10 @@ interface TraceOptionsMenuProps {
showTraceDetails: boolean;
onToggleTraceDetails: () => void;
onOpenPreviewFields: () => void;
traceId: string;
startTime: number;
endTime: number;
totalSpansCount: number;
}
// Composed from dropdown-menu primitives (instead of DropdownMenuSimple)
@@ -30,6 +36,10 @@ function TraceOptionsMenu({
showTraceDetails,
onToggleTraceDetails,
onOpenPreviewFields,
traceId,
startTime,
endTime,
totalSpansCount,
}: TraceOptionsMenuProps): JSX.Element {
const colorByField = useTraceStore((s) => s.colorByField);
const setColorByField = useTraceStore((s) => s.setColorByField);
@@ -37,6 +47,13 @@ function TraceOptionsMenu({
(s) => s.availableColorByOptions,
);
const { isDownloading, isExportDisabled, downloadTrace } = useDownloadTrace({
traceId,
startTime,
endTime,
totalSpansCount,
});
const handleColorByChange = (name: string): void => {
const next = availableColorByOptions.find((o) => o.field.name === name);
if (next) {
@@ -81,6 +98,32 @@ function TraceOptionsMenu({
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{!isExportDisabled && (
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={isDownloading}
data-testid="download-trace-submenu"
>
Download trace
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={styles.traceOptionsDropdown}>
<DropdownMenuItem
clickable
onSelect={(): void => downloadTrace(ExportFormat.Csv)}
testId="download-trace-csv"
>
CSV
</DropdownMenuItem>
<DropdownMenuItem
clickable
onSelect={(): void => downloadTrace(ExportFormat.Jsonl)}
testId="download-trace-jsonl"
>
JSONL
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -141,6 +141,7 @@ describe('TraceDetailsHeader trace metadata row', () => {
rootServiceEntryPoint: 'large-trace-root',
rootSpanStatusCode: '404',
hasMissingSpans: false,
totalSpansCount: 42,
};
it('renders the metadata (service, entry point, duration, status) when provided', () => {

View File

@@ -0,0 +1,228 @@
import 'tests/blob-polyfill';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { fetchExportData } from 'api/v1/download/downloadExportData';
import { downloadFile } from 'lib/exportData/downloadFile';
import { render } from 'tests/test-utils';
import TraceDownloadPanel from '../TraceDownloadPanel';
import TraceOptionsMenu from '../TraceOptionsMenu';
import { MAX_EXPORT_SPANS } from '../useDownloadTrace';
// Integration suite: menu → hook → store → runner → stitchers → panel all run
// for real; only the true boundaries are mocked (HTTP + file save).
jest.mock('api/v1/download/downloadExportData', () => ({
fetchExportData: jest.fn(),
}));
jest.mock('lib/exportData/downloadFile', () => ({
downloadFile: jest.fn(),
getTimestampedFileName: jest.fn(
(base: string, ext: string) => `${base}.${ext}`,
),
}));
const mockFetch = fetchExportData as jest.Mock;
const mockDownloadFile = downloadFile as jest.Mock;
const baseProps = {
showTraceDetails: true,
onToggleTraceDetails: jest.fn(),
onOpenPreviewFields: jest.fn(),
traceId: 'trace-123',
startTime: 1_000,
endTime: 2_000,
// 120k spans → 3 export pages of 50k.
totalSpansCount: 120_000,
};
function renderFeature(
props: Partial<typeof baseProps> = {},
): ReturnType<typeof render> {
return render(
<>
<TraceOptionsMenu {...baseProps} {...props} />
<TraceDownloadPanel />
</>,
);
}
// skipHover: simulated pointer travel fires pointerleave on the subtrigger and
// radix's grace-area math (zero rects in jsdom) closes the submenu.
// pointerEventsCheck 0: radix puts pointer-events:none on <body> while open.
function setupUser(): ReturnType<typeof userEvent.setup> {
return userEvent.setup({
delay: null,
skipHover: true,
pointerEventsCheck: 0,
});
}
async function startDownload(
format: 'csv' | 'jsonl' = 'csv',
): Promise<ReturnType<typeof userEvent.setup>> {
const user = setupUser();
await user.click(screen.getByRole('button', { name: /trace options/i }));
await user.click(await screen.findByTestId('download-trace-submenu'));
await user.click(await screen.findByTestId(`download-trace-${format}`));
return user;
}
async function savedFileText(): Promise<string> {
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
const [blob] = mockDownloadFile.mock.calls[0];
return (blob as Blob).text();
}
describe('trace download flow', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('downloads a multi-page trace as one stitched CSV', async () => {
mockFetch.mockImplementation(({ body }) =>
Promise.resolve(
new Blob([`h1,h2\nrow-${body.compositeQuery.queries[0].spec.offset}\n`]),
),
);
renderFeature();
await startDownload('csv');
await waitFor(() => expect(mockDownloadFile).toHaveBeenCalled());
// Real query building: trace scoping, page offsets, ±5min window (seconds→ms).
const bodies = mockFetch.mock.calls.map(([props]) => props.body);
const specs = bodies.map((b) => b.compositeQuery.queries[0].spec);
expect(specs[0].filter.expression).toBe("trace_id = 'trace-123'");
expect(specs.map((s) => s.offset).sort((a, b) => a - b)).toStrictEqual([
0, 50_000, 100_000,
]);
expect(bodies[0].start).toBe((baseProps.startTime - 300) * 1e3);
expect(bodies[0].end).toBe((baseProps.endTime + 300) * 1e3);
// Stitched in page order, headers deduped, saved under the trace's name.
await expect(savedFileText()).resolves.toBe(
'h1,h2\nrow-0\nrow-50000\nrow-100000\n',
);
expect(mockDownloadFile.mock.calls[0][1]).toBe('trace-trace-123.csv');
// Panel dismissed once the download completed.
await waitFor(() =>
expect(screen.queryByTestId('trace-download-panel')).not.toBeInTheDocument(),
);
});
it('downloads JSONL when that format is chosen', async () => {
mockFetch.mockResolvedValue(new Blob(['{"a":1}\n']));
renderFeature({ totalSpansCount: 10 });
await startDownload('jsonl');
await waitFor(() => expect(mockDownloadFile).toHaveBeenCalled());
expect(mockFetch.mock.calls[0][0].format).toBe('jsonl');
expect(mockDownloadFile.mock.calls[0][1]).toBe('trace-trace-123.jsonl');
});
it('shows the progress panel while downloading and cancels from its ✕', async () => {
// Fetches hang until aborted — the download stays in flight.
mockFetch.mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
);
}),
);
renderFeature();
const user = await startDownload();
// Panel appears with the 1% floor (no part finished yet).
const panel = await screen.findByTestId('trace-download-panel');
expect(panel).toBeInTheDocument();
expect(screen.getByTestId('trace-download-percent')).toHaveTextContent('1%');
await user.click(screen.getByTestId('trace-download-cancel'));
await waitFor(() =>
expect(screen.queryByTestId('trace-download-panel')).not.toBeInTheDocument(),
);
expect(mockDownloadFile).not.toHaveBeenCalled();
});
it('disables the submenu trigger while a download is running', async () => {
mockFetch.mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
signal.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
);
}),
);
renderFeature();
const user = await startDownload();
// Reopen the menu mid-download: trigger is disabled by REAL store state.
await user.click(screen.getByRole('button', { name: /trace options/i }));
const trigger = await screen.findByTestId('download-trace-submenu');
expect(trigger).toHaveAttribute('data-disabled');
// Cleanup: stop the in-flight download so the module-scoped guard resets.
await user.click(screen.getByTestId('trace-download-cancel'));
await waitFor(() =>
expect(screen.queryByTestId('trace-download-panel')).not.toBeInTheDocument(),
);
});
it('hides the download option entirely above the export cap', async () => {
renderFeature({ totalSpansCount: MAX_EXPORT_SPANS + 1 });
const user = setupUser();
await user.click(screen.getByRole('button', { name: /trace options/i }));
await expect(
screen.findByRole('menuitem', { name: /preview fields/i }),
).resolves.toBeInTheDocument();
expect(
screen.queryByTestId('download-trace-submenu'),
).not.toBeInTheDocument();
});
it('saves nothing and dismisses the panel when a page fails', async () => {
mockFetch.mockRejectedValue(new Error('boom'));
renderFeature();
await startDownload();
await waitFor(() =>
expect(screen.queryByTestId('trace-download-panel')).not.toBeInTheDocument(),
);
expect(mockDownloadFile).not.toHaveBeenCalled();
});
it('cancels the in-flight download when the panel unmounts (leaving the page)', async () => {
const seenSignals: AbortSignal[] = [];
mockFetch.mockImplementation(
({ signal }) =>
new Promise((_resolve, reject) => {
seenSignals.push(signal);
signal.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
);
}),
);
const { unmount } = renderFeature();
await startDownload();
await screen.findByTestId('trace-download-panel');
unmount();
await waitFor(() =>
expect(seenSignals.every((signal) => signal.aborted)).toBe(true),
);
expect(mockDownloadFile).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,63 @@
import {
ChunkedExportArgs,
runChunkedExport,
} from 'hooks/useExportData/runChunkedExport';
import { ExportFormat } from 'lib/exportData/types';
import { create } from 'zustand';
/** 'skipped' = a download was already in flight; the call was ignored. */
export type TraceDownloadOutcome = 'completed' | 'cancelled' | 'skipped';
interface TraceDownloadState {
isDownloading: boolean;
// 0100
progress: number;
startDownload: (
args: ChunkedExportArgs & { format: ExportFormat },
) => Promise<TraceDownloadOutcome>;
cancelDownload: () => void;
}
let abortController: AbortController | null = null;
export const useTraceDownloadStore = create<TraceDownloadState>((set) => ({
isDownloading: false,
progress: 0,
startDownload: async ({
format,
...args
}: ChunkedExportArgs & {
format: ExportFormat;
}): Promise<TraceDownloadOutcome> => {
if (abortController) {
return 'skipped';
}
const controller = new AbortController();
abortController = controller;
set({ isDownloading: true, progress: 0 });
try {
await runChunkedExport({
...args,
format,
signal: controller.signal,
onProgress: (progress: number): void => set({ progress }),
});
return 'completed';
} catch (error) {
if (controller.signal.aborted) {
return 'cancelled';
}
throw error;
} finally {
abortController = null;
set({ isDownloading: false });
}
},
cancelDownload: (): void => {
abortController?.abort();
},
}));

View File

@@ -0,0 +1,23 @@
import { listViewInitialTraceQuery } from 'constants/queryBuilder';
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export function getTraceExportQuery(traceId: string): Query {
const [baseQueryData] = listViewInitialTraceQuery.builder.queryData;
return {
...listViewInitialTraceQuery,
builder: {
...listViewInitialTraceQuery.builder,
queryData: [
{
...baseQueryData,
aggregateOperator: LogsAggregatorOperator.NOOP,
filter: { expression: `trace_id = '${traceId}'` },
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
selectColumns: [],
},
],
},
};
}

View File

@@ -0,0 +1,92 @@
import { useCallback, useMemo } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { ExportFormat } from 'lib/exportData/types';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceDownloadStore } from './traceDownloadStore';
import { getTraceExportQuery } from './traceExportQuery';
// Export window pad past the trace bounds so edge spans are never clipped
// (mirrors SpanDetailsPanel).
const FIVE_MINUTES_IN_SECONDS = 5 * 60;
// Span-count cap: traces above it hide their download option entirely.
export const MAX_EXPORT_SPANS = 500_000;
interface UseDownloadTraceProps {
traceId: string;
// Trace start/end in seconds.
startTime: number;
endTime: number;
totalSpansCount: number;
}
interface UseDownloadTraceReturn {
isDownloading: boolean;
isExportDisabled: boolean;
downloadTrace: (format: ExportFormat) => void;
}
export function useDownloadTrace({
traceId,
startTime,
endTime,
totalSpansCount,
}: UseDownloadTraceProps): UseDownloadTraceReturn {
const isDownloading = useTraceDownloadStore((s) => s.isDownloading);
const startDownload = useTraceDownloadStore((s) => s.startDownload);
const logTraceEvent = useTraceDetailLogEvent('v3', traceId);
const query = useMemo(() => getTraceExportQuery(traceId), [traceId]);
const timeRange = useMemo(
() => ({
start: startTime - FIVE_MINUTES_IN_SECONDS,
end: endTime + FIVE_MINUTES_IN_SECONDS,
}),
[startTime, endTime],
);
const downloadTrace = useCallback(
(format: ExportFormat): void => {
logTraceEvent(TraceDetailEvents.DownloadTriggered, {
[TraceDetailEventKeys.Format]: format,
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
});
const run = async (): Promise<void> => {
try {
const outcome = await startDownload({
query,
timeRange,
totalRows: totalSpansCount,
fileNameBase: `trace-${traceId}`,
format,
});
if (outcome === 'completed') {
toast.success('Export completed successfully');
} else if (outcome === 'cancelled') {
logTraceEvent(TraceDetailEvents.DownloadCancelled, {
[TraceDetailEventKeys.Format]: format,
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
});
toast.info('Export cancelled');
}
} catch (error) {
toast.error('Failed to download trace');
console.error(error);
}
};
void run();
},
[query, timeRange, totalSpansCount, traceId, startDownload, logTraceEvent],
);
return {
isDownloading,
isExportDisabled: totalSpansCount > MAX_EXPORT_SPANS,
downloadTrace,
};
}

View File

@@ -6,6 +6,8 @@ export enum TraceDetailEvents {
AnalyticsPanelToggled = 'Trace Detail: Analytics panel toggled',
AnalyticsTabChanged = 'Trace Detail: Analytics tab changed',
SpanPanelTabChanged = 'Trace Detail: Span panel tab changed',
DownloadTriggered = 'Trace Detail: Download triggered',
DownloadCancelled = 'Trace Detail: Download cancelled',
}
export enum TraceDetailEventKeys {
@@ -32,6 +34,8 @@ export enum TraceDetailEventKeys {
Tab = 'tab',
// Span panel tab changed
SpanId = 'spanId',
// Download triggered (reuses TotalSpansCount for trace size)
Format = 'format',
}
export type TraceDetailView = 'v2' | 'v3';

View File

@@ -346,6 +346,7 @@ function TraceDetailsV3(): JSX.Element {
rootServiceEntryPoint: payload.rootServiceEntryPoint,
rootSpanStatusCode: rootSpan?.response_status_code || '',
hasMissingSpans: payload.hasMissingSpans || false,
totalSpansCount: payload.totalSpansCount || 0,
};
}, [traceData?.payload]);

View File

@@ -0,0 +1,34 @@
/**
* jsdom's Blob lacks the modern read methods (arrayBuffer/text). Polyfill via
* FileReader (which jsdom does implement); guarded, so it no-ops once jsdom
* catches up. Side-effect module — import it at the top of any test touching
* Blob contents:
*
* import 'tests/blob-polyfill';
*/
if (typeof Blob.prototype.arrayBuffer === 'undefined') {
Blob.prototype.arrayBuffer = function arrayBuffer(
this: Blob,
): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (): void => resolve(reader.result as ArrayBuffer);
reader.onerror = (): void => reject(reader.error);
reader.readAsArrayBuffer(this);
});
};
}
if (typeof Blob.prototype.text === 'undefined') {
Blob.prototype.text = function text(this: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (): void => resolve(reader.result as string);
reader.onerror = (): void => reject(reader.error);
reader.readAsText(this);
});
};
}
export {};

View File

@@ -28,8 +28,6 @@ export interface PostableAlertRuleV2 {
absentFor?: number;
requireMinPoints?: boolean;
requiredNumPoints?: number;
algorithm?: string;
seasonality?: string;
};
evaluation?: {
kind?: 'rolling' | 'cumulative';

View File

@@ -1,8 +1,6 @@
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
// Mapping between panel types and explorer views
export const panelTypeToExplorerView: Record<PANEL_TYPES, ExplorerViews> = {
@@ -52,36 +50,3 @@ export const getExplorerViewFromUrl = (
export const getExplorerViewForPanelType = (
panelType: PANEL_TYPES,
): ExplorerViews => panelTypeToExplorerView[panelType];
export interface MetricsExplorerUrlParams {
query: Query;
relativeTime?: string;
startTimeMs?: number;
endTimeMs?: number;
}
export const getMetricsExplorerUrl = ({
query,
relativeTime,
startTimeMs,
endTimeMs,
}: MetricsExplorerUrlParams): string => {
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
if (relativeTime) {
params.set(QueryParams.relativeTime, relativeTime);
} else {
if (startTimeMs !== undefined) {
params.set(QueryParams.startTime, String(startTimeMs));
}
if (endTimeMs !== undefined) {
params.set(QueryParams.endTime, String(endTimeMs));
}
}
return `${ROUTES.METRICS_EXPLORER_EXPLORER}?${params.toString()}`;
};

View File

@@ -47,7 +47,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
Description: "This endpoint lists all roles",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.GettableRole, 0),
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -73,7 +73,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
Description: "This endpoint gets a role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.Role),
Response: new(authtypes.RoleWithTransactionGroups),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},

View File

@@ -221,15 +221,16 @@ func postableRuleExamples() []handler.OpenAPIExample {
},
{
Name: "metric_anomaly",
Summary: "Metric anomaly rule",
Description: "Wraps a builder query in the `anomaly` function with daily seasonality SigNoz compares each point against the forecast for that time of day. Thresholds compare against the anomaly z-score and are literal: a drop of more than 2 deviations below the forecast is expressed as `op: below` with `target: -2`. Fires when the anomaly score stays below the threshold for the entire window; `requireMinPoints` guards against noisy intervals.",
Summary: "Metric anomaly rule (v1 only)",
Description: "Anomaly rules are not yet supported under schemaVersion v2alpha1, so this example uses the v1 shape. Wraps a builder query in the `anomaly` function with daily seasonality SigNoz compares each point against the forecast for that time of day. Fires when the anomaly score stays below the threshold for the entire window; `requireMinPoints` guards against noisy intervals.",
Value: map[string]any{
"alert": "Anomalous drop in ingested spans",
"alertType": "METRIC_BASED_ALERT",
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
"ruleType": "anomaly_rule",
"version": "v5",
"schemaVersion": "v2alpha1",
"alert": "Anomalous drop in ingested spans",
"alertType": "METRIC_BASED_ALERT",
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
"ruleType": "anomaly_rule",
"version": "v5",
"evalWindow": "24h",
"frequency": "3h",
"condition": map[string]any{
"compositeQuery": map[string]any{
"queryType": "builder",
@@ -255,29 +256,17 @@ func postableRuleExamples() []handler.OpenAPIExample {
},
},
},
"selectedQueryName": "A",
"op": "below",
"matchType": "all_the_times",
"target": 2,
"algorithm": "standard",
"seasonality": "daily",
"selectedQueryName": "A",
"requireMinPoints": true,
"requiredNumPoints": 3,
"thresholds": map[string]any{
"kind": "basic",
"spec": []any{
map[string]any{
"name": "warning",
"op": "below",
"matchType": "all_the_times",
"target": -2,
"channels": []any{"slack-ingestion"},
},
},
},
},
"evaluation": rolling("24h", "3h"),
"notificationSettings": map[string]any{
"renotify": renotify("4h", "firing"),
},
"labels": map[string]any{"severity": "warning"},
"labels": map[string]any{"severity": "warning"},
"preferredChannels": []any{"slack-ingestion"},
"annotations": map[string]any{
"description": "Ingestion rate for tenant {{$tenant_id}} is anomalously low (z-score {{$value}}).",
"summary": "Span ingestion anomaly",

View File

@@ -30,14 +30,17 @@ type AuthZ interface {
// Write accepts the insertion tuples and the deletion tuples.
Write(context.Context, []*openfgav1.TupleKey, []*openfgav1.TupleKey) error
// ReadTuples reads tuples from the authorization server matching the given tuple key filter.
ReadTuples(context.Context, *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error)
// Lists the selectors for objects assigned to subject (s) with relation (r) on resource (s)
ListObjects(context.Context, string, authtypes.Relation, coretypes.Type) ([]*coretypes.Object, error)
// Creates the role with its transaction groups.
Create(context.Context, valuer.UUID, *authtypes.Role) error
Create(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
// Gets the role if it exists or creates one.
GetOrCreate(context.Context, valuer.UUID, *authtypes.Role) (*authtypes.Role, error)
// Updates the role's metadata and reconciles its transaction groups.
Update(context.Context, valuer.UUID, *authtypes.Role) error
Update(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
// Deletes the role and tuples in authorization server.
Delete(context.Context, valuer.UUID, valuer.UUID) error
@@ -45,6 +48,9 @@ type AuthZ interface {
// Gets the role
Get(context.Context, valuer.UUID, valuer.UUID) (*authtypes.Role, error)
// Gets the role with transaction groups
GetWithTransactionGroups(context.Context, valuer.UUID, valuer.UUID) (*authtypes.RoleWithTransactionGroups, error)
// Gets the role by org_id and name
GetByOrgIDAndName(context.Context, valuer.UUID, string) (*authtypes.Role, error)
@@ -74,6 +80,9 @@ type AuthZ interface {
// Bootstrap managed roles transactions and user assignments
CreateManagedUserRoleTransactions(context.Context, valuer.UUID, valuer.UUID) error
// ReadTuples reads tuples from the authorization server matching the given tuple key filter.
ReadTuples(context.Context, *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error)
}
// OnBeforeRoleDelete is a callback invoked before a role is deleted.

View File

@@ -83,6 +83,10 @@ func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.
return provider.store.Get(ctx, orgID, id)
}
func (provider *provider) GetWithTransactionGroups(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.RoleWithTransactionGroups, error) {
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*authtypes.Role, error) {
return provider.store.GetByOrgIDAndName(ctx, orgID, name)
}
@@ -177,11 +181,15 @@ func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context,
return provider.Grant(ctx, orgID, []string{authtypes.SigNozAdminRoleName}, authtypes.MustNewSubject(coretypes.NewResourceUser(), userID.String(), orgID, nil))
}
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.RoleWithTransactionGroups) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) Update(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
func (provider *provider) GetOrCreate(_ context.Context, _ valuer.UUID, _ *authtypes.Role) (*authtypes.Role, error) {
return nil, errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}
func (provider *provider) Update(_ context.Context, _ valuer.UUID, _ *authtypes.RoleWithTransactionGroups) error {
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
}

View File

@@ -36,14 +36,14 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
return
}
role := authtypes.NewRole(req.Name, req.Description, authtypes.RoleTypeCustom, valuer.MustNewUUID(claims.OrgID), req.TransactionGroups)
err = handler.authz.Create(ctx, valuer.MustNewUUID(claims.OrgID), role)
roleWithTransactionGroups := authtypes.NewRoleWithTransactionGroups(req.Name, req.Description, authtypes.RoleTypeCustom, valuer.MustNewUUID(claims.OrgID), req.TransactionGroups)
err = handler.authz.Create(ctx, valuer.MustNewUUID(claims.OrgID), roleWithTransactionGroups)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, types.Identifiable{ID: role.ID})
render.Success(rw, http.StatusCreated, types.Identifiable{ID: roleWithTransactionGroups.ID})
}
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
@@ -65,13 +65,13 @@ func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
return
}
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
roleWithTransactionGroups, err := handler.authz.GetWithTransactionGroups(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, role)
render.Success(rw, http.StatusOK, roleWithTransactionGroups)
}
func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
@@ -88,7 +88,7 @@ func (handler *handler) List(rw http.ResponseWriter, r *http.Request) {
return
}
render.Success(rw, http.StatusOK, authtypes.NewGettableRolesFromRoles(roles))
render.Success(rw, http.StatusOK, roles)
}
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
@@ -117,13 +117,14 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
return
}
err = role.Update(req.Description, req.TransactionGroups)
roleWithTransactionGroups := authtypes.MakeRoleWithTransactionGroups(role, nil)
err = roleWithTransactionGroups.Update(req.Description, req.TransactionGroups)
if err != nil {
render.Error(rw, err)
return
}
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), role)
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), roleWithTransactionGroups)
if err != nil {
render.Error(rw, err)
return

View File

@@ -218,7 +218,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewMigrateSSORoleMappingNamesFactory(sqlstore),
sqlmigration.NewAddMetricReductionRulesFactory(sqlstore, sqlschema),
sqlmigration.NewRemoveOrganizationTuplesFactory(sqlstore),
sqlmigration.NewAddRoleTransactionGroupsFactory(sqlstore, sqlschema),
)
}

View File

@@ -3,7 +3,6 @@ package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
@@ -19,30 +18,6 @@ type addManagedRoles struct {
sqlschema sqlschema.SQLSchema
}
type role struct {
bun.BaseModel `bun:"table:role"`
ID valuer.UUID `bun:"id,pk,type:text"`
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
Name string `bun:"name,type:string"`
Description string `bun:"description,type:string"`
Type string `bun:"type,type:string"`
OrgID valuer.UUID `bun:"org_id,type:string"`
}
func newManagedRole(name, description string, orgID valuer.UUID) *role {
return &role{
ID: valuer.GenerateUUID(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Name: name,
Description: description,
Type: authtypes.RoleTypeManaged.StringValue(),
OrgID: orgID,
}
}
func NewAddManagedRolesFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_managed_roles"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return newAddManagedRoles(ctx, ps, c, sqlstore, sqlschema)
@@ -79,19 +54,28 @@ func (migration *addManagedRoles) Up(ctx context.Context, db *bun.DB) error {
return err
}
managedRoles := []*role{}
managedRoles := []*authtypes.Role{}
for _, orgIDStr := range orgIDs {
orgID, err := valuer.NewUUID(orgIDStr)
if err != nil {
return err
}
managedRoles = append(managedRoles,
newManagedRole(authtypes.SigNozAdminRoleName, authtypes.SigNozAdminRoleDescription, orgID),
newManagedRole(authtypes.SigNozEditorRoleName, authtypes.SigNozEditorRoleDescription, orgID),
newManagedRole(authtypes.SigNozViewerRoleName, authtypes.SigNozViewerRoleDescription, orgID),
newManagedRole(authtypes.SigNozAnonymousRoleName, authtypes.SigNozAnonymousRoleDescription, orgID),
)
// signoz admin
signozAdminRole := authtypes.NewRole(authtypes.SigNozAdminRoleName, authtypes.SigNozAdminRoleDescription, authtypes.RoleTypeManaged, orgID)
managedRoles = append(managedRoles, signozAdminRole)
// signoz editor
signozEditorRole := authtypes.NewRole(authtypes.SigNozEditorRoleName, authtypes.SigNozEditorRoleDescription, authtypes.RoleTypeManaged, orgID)
managedRoles = append(managedRoles, signozEditorRole)
// signoz viewer
signozViewerRole := authtypes.NewRole(authtypes.SigNozViewerRoleName, authtypes.SigNozViewerRoleDescription, authtypes.RoleTypeManaged, orgID)
managedRoles = append(managedRoles, signozViewerRole)
// signoz anonymous
signozAnonymousRole := authtypes.NewRole(authtypes.SigNozAnonymousRoleName, authtypes.SigNozAnonymousRoleDescription, authtypes.RoleTypeManaged, orgID)
managedRoles = append(managedRoles, signozAnonymousRole)
}
if len(managedRoles) > 0 {

View File

@@ -1,191 +0,0 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addRoleTransactionGroups struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
type roles struct {
bun.BaseModel `bun:"table:role"`
ID string `bun:"id,pk"`
Name string `bun:"name"`
OrgID string `bun:"org_id"`
}
func NewAddRoleTransactionGroupsFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("add_role_transaction_groups"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addRoleTransactionGroups{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
},
)
}
func (migration *addRoleTransactionGroups) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addRoleTransactionGroups) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
table, uniqueConstraints, err := migration.sqlschema.GetTable(ctx, "role")
if err != nil {
return err
}
column := &sqlschema.Column{
Name: sqlschema.ColumnName("transaction_groups"),
DataType: sqlschema.DataTypeText,
Nullable: true,
}
sqls := migration.sqlschema.Operator().AddColumn(table, uniqueConstraints, column, nil)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
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
}
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)
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
for _, orgID := range orgIDs {
customRoles := make([]*roles, 0)
err = tx.NewSelect().
Model(&customRoles).
Column("id", "name").
Where("org_id = ?", orgID).
Where("type = ?", authtypes.RoleTypeCustom.StringValue()).
Where("transaction_groups IS NULL").
Scan(ctx)
if err != nil {
return err
}
for _, role := range customRoles {
tuples, err := migration.readRoleTuples(ctx, tx, isPG, storeID, orgID, role.Name)
if err != nil {
return err
}
groups := authtypes.MustNewTransactionGroupsFromTuples(tuples)
data, err := json.Marshal(groups)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Model(new(roles)).
Set("transaction_groups = ?", string(data)).
Where("id = ?", role.ID).
Exec(ctx); err != nil {
return err
}
}
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 *addRoleTransactionGroups) readRoleTuples(ctx context.Context, tx bun.Tx, isPG bool, storeID, orgID, roleName string) ([]*openfgav1.TupleKey, error) {
roleSubject := "organization/" + orgID + "/role/" + roleName
var tupleRows *sql.Rows
var err error
if isPG {
tupleRows, err = tx.QueryContext(ctx, `
SELECT object_type, object_id, relation FROM tuple
WHERE store = ? AND _user = ?`,
storeID, "role:"+roleSubject+"#assignee",
)
} else {
tupleRows, err = tx.QueryContext(ctx, `
SELECT object_type, object_id, relation FROM tuple
WHERE store = ? AND user_object_type = ? AND user_object_id = ? AND user_relation = ?`,
storeID, "role", roleSubject, "assignee",
)
}
if err != nil {
return nil, err
}
defer tupleRows.Close()
tuples := make([]*openfgav1.TupleKey, 0)
for tupleRows.Next() {
var objectType, objectID, relation string
if err := tupleRows.Scan(&objectType, &objectID, &relation); err != nil {
return nil, err
}
tuples = append(tuples, &openfgav1.TupleKey{Object: objectType + ":" + objectID, Relation: relation})
}
if err := tupleRows.Err(); err != nil {
return nil, err
}
return tuples, nil
}
func (migration *addRoleTransactionGroups) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -66,20 +66,15 @@ type Role struct {
types.Identifiable
types.TimeAuditable
Name string `bun:"name,type:string" json:"name" required:"true"`
Description string `bun:"description,type:string" json:"description" required:"true"`
Type valuer.String `bun:"type,type:string" json:"type" required:"true"`
OrgID valuer.UUID `bun:"org_id,type:string" json:"orgId" required:"true"`
TransactionGroups TransactionGroups `bun:"transaction_groups,nullzero,type:text" json:"transactionGroups" required:"true" nullable:"false"`
Name string `bun:"name,type:string" json:"name" required:"true"`
Description string `bun:"description,type:string" json:"description" required:"true"`
Type valuer.String `bun:"type,type:string" json:"type" required:"true"`
OrgID valuer.UUID `bun:"org_id,type:string" json:"orgId" required:"true"`
}
type GettableRole struct {
types.Identifiable
types.TimeAuditable
Name string `json:"name" required:"true"`
Description string `json:"description" required:"true"`
Type valuer.String `json:"type" required:"true"`
OrgID valuer.UUID `json:"orgId" required:"true"`
type RoleWithTransactionGroups struct {
*Role
TransactionGroups TransactionGroups `json:"transactionGroups" required:"true" nullable:"false"`
}
type PostableRole struct {
@@ -93,7 +88,7 @@ type UpdatableRole struct {
TransactionGroups TransactionGroups `json:"transactionGroups" required:"true" nullable:"false"`
}
func NewRole(name, description string, roleType valuer.String, orgID valuer.UUID, transactionGroups TransactionGroups) *Role {
func NewRole(name, description string, roleType valuer.String, orgID valuer.UUID) *Role {
return &Role{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
@@ -102,37 +97,37 @@ func NewRole(name, description string, roleType valuer.String, orgID valuer.UUID
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Name: name,
Description: description,
Type: roleType,
OrgID: orgID,
Name: name,
Description: description,
Type: roleType,
OrgID: orgID,
}
}
func NewRoleWithTransactionGroups(name, description string, roleType valuer.String, orgID valuer.UUID, transactionGroups TransactionGroups) *RoleWithTransactionGroups {
role := NewRole(name, description, roleType, orgID)
return &RoleWithTransactionGroups{
Role: role,
TransactionGroups: transactionGroups,
}
}
func NewGettableRolesFromRoles(roles []*Role) []*GettableRole {
gettableRoles := make([]*GettableRole, len(roles))
for index, role := range roles {
gettableRoles[index] = &GettableRole{
Identifiable: role.Identifiable,
TimeAuditable: role.TimeAuditable,
Name: role.Name,
Description: role.Description,
Type: role.Type,
OrgID: role.OrgID,
}
func MakeRoleWithTransactionGroups(role *Role, transactionGroups TransactionGroups) *RoleWithTransactionGroups {
return &RoleWithTransactionGroups{
Role: role,
TransactionGroups: transactionGroups,
}
return gettableRoles
}
func NewManagedRoles(orgID valuer.UUID) []*Role {
return []*Role{
NewRole(SigNozAdminRoleName, SigNozAdminRoleDescription, RoleTypeManaged, orgID, NewTransactionGroupsFromTransactions(coretypes.ManagedRoleToTransactions[SigNozAdminRoleName])),
NewRole(SigNozEditorRoleName, SigNozEditorRoleDescription, RoleTypeManaged, orgID, NewTransactionGroupsFromTransactions(coretypes.ManagedRoleToTransactions[SigNozEditorRoleName])),
NewRole(SigNozViewerRoleName, SigNozViewerRoleDescription, RoleTypeManaged, orgID, NewTransactionGroupsFromTransactions(coretypes.ManagedRoleToTransactions[SigNozViewerRoleName])),
NewRole(SigNozAnonymousRoleName, SigNozAnonymousRoleDescription, RoleTypeManaged, orgID, NewTransactionGroupsFromTransactions(coretypes.ManagedRoleToTransactions[SigNozAnonymousRoleName])),
NewRole(SigNozAdminRoleName, SigNozAdminRoleDescription, RoleTypeManaged, orgID),
NewRole(SigNozEditorRoleName, SigNozEditorRoleDescription, RoleTypeManaged, orgID),
NewRole(SigNozViewerRoleName, SigNozViewerRoleDescription, RoleTypeManaged, orgID),
NewRole(SigNozAnonymousRoleName, SigNozAnonymousRoleDescription, RoleTypeManaged, orgID),
}
}
func NewStatsFromRoles(roles []*Role) map[string]any {
@@ -149,7 +144,7 @@ func NewStatsFromRoles(roles []*Role) map[string]any {
return stats
}
func (role *Role) Update(description string, transactionGroups TransactionGroups) error {
func (role *RoleWithTransactionGroups) Update(description string, transactionGroups TransactionGroups) error {
err := role.ErrIfManaged()
if err != nil {
return err

View File

@@ -1,7 +1,6 @@
package authtypes
import (
"database/sql/driver"
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
@@ -71,28 +70,6 @@ func NewTransactionGroups(data []byte) (TransactionGroups, error) {
return groups, nil
}
func NewTransactionGroupsFromTransactions(transactions []coretypes.Transaction) TransactionGroups {
objectsByVerb := make(map[string][]*coretypes.Object)
for _, transaction := range transactions {
object := transaction.Object
objectsByVerb[transaction.Verb.StringValue()] = append(objectsByVerb[transaction.Verb.StringValue()], &object)
}
groups := make(TransactionGroups, 0)
for _, verb := range coretypes.Verbs {
objects := objectsByVerb[verb.StringValue()]
if len(objects) == 0 {
continue
}
for _, objectGroup := range coretypes.NewObjectGroupsFromObjects(objects) {
groups = append(groups, &TransactionGroup{Relation: Relation{Verb: verb}, ObjectGroup: *objectGroup})
}
}
return groups
}
func NewGettableTransaction(results []*TransactionWithAuthorization) []*GettableTransaction {
gettableTransactions := make([]*GettableTransaction, len(results))
for i, result := range results {
@@ -110,40 +87,6 @@ func (groups TransactionGroups) Diff(desired TransactionGroups) (additions, dele
return desired.subtract(groups), groups.subtract(desired)
}
func (groups TransactionGroups) Value() (driver.Value, error) {
data, err := json.Marshal(groups)
if err != nil {
return nil, err
}
return string(data), nil
}
func (groups *TransactionGroups) Scan(value any) error {
if value == nil {
*groups = make(TransactionGroups, 0)
return nil
}
var data []byte
switch typed := value.(type) {
case string:
data = []byte(typed)
case []byte:
data = typed
default:
return errors.Newf(errors.TypeInternal, errors.CodeInternal, "unsupported type %T for transaction groups", value)
}
parsed, err := NewTransactionGroups(data)
if err != nil {
return errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, "failed to scan transactionGroups")
}
*groups = parsed
return nil
}
func (transaction *Transaction) UnmarshalJSON(data []byte) error {
var shadow = struct {
Relation Relation

View File

@@ -68,7 +68,7 @@ func NewTuplesFromTransactionGroups(name string, orgID valuer.UUID, transactionG
return tuples, nil
}
func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) TransactionGroups {
func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) []*TransactionGroup {
objectsByRelation := make(map[string][]*coretypes.Object)
for _, tuple := range tuples {
@@ -81,7 +81,7 @@ func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) Transactio
objectsByRelation[verb.StringValue()] = append(objectsByRelation[verb.StringValue()], object)
}
transactionGroups := make(TransactionGroups, 0)
transactionGroups := make([]*TransactionGroup, 0)
for _, verb := range coretypes.Verbs {
objects := objectsByRelation[verb.StringValue()]
if len(objects) == 0 {

View File

@@ -727,39 +727,6 @@ func TestValidate_V2Alpha1(t *testing.T) {
json: validV2Alpha1Builder(),
},
// anomaly rules are supported under v2alpha1; thresholds are literal,
// so a drop of 2 deviations below the forecast carries target -2
{
name: "valid v2alpha1 anomaly rule",
json: `{
"alert": "Test", "version": "v5", "schemaVersion": "v2alpha1", "ruleType": "anomaly_rule",
"condition": {
"compositeQuery": {"queryType": "builder", "queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "metrics", "aggregations": [{"metricName": "cpu", "spaceAggregation": "p50"}], "stepInterval": "5m"}}]},
"algorithm": "standard",
"seasonality": "daily",
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": -2.0, "matchType": "2", "op": "2", "channels": ["slack"]}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "1h", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`,
},
{
name: "v2alpha1 anomaly rule with invalid seasonality",
json: `{
"alert": "Test", "version": "v5", "schemaVersion": "v2alpha1", "ruleType": "anomaly_rule",
"condition": {
"compositeQuery": {"queryType": "builder", "queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "metrics", "aggregations": [{"metricName": "cpu", "spaceAggregation": "p50"}], "stepInterval": "5m"}}]},
"algorithm": "standard",
"seasonality": "yearly",
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": -2.0, "matchType": "2", "op": "2", "channels": ["slack"]}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "1h", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`,
wantErr: true,
errSubstr: "seasonality",
},
// missing required fields
{
name: "missing thresholds",