mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-14 18:40:26 +01:00
Compare commits
6 Commits
feat/expor
...
anomaly-v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6356b3b8c | ||
|
|
9476d8f70f | ||
|
|
69a240fdb2 | ||
|
|
6975c4d90c | ||
|
|
9bfa6e6d59 | ||
|
|
90512748fb |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -189,7 +189,9 @@ 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
|
||||
|
||||
@@ -543,6 +543,31 @@ 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:
|
||||
@@ -694,43 +719,6 @@ 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
|
||||
@@ -758,6 +746,18 @@ 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/AuthtypesRole'
|
||||
$ref: '#/components/schemas/AuthtypesGettableRole'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
@@ -12206,7 +12206,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesRoleWithTransactionGroups'
|
||||
$ref: '#/components/schemas/AuthtypesRole'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
|
||||
@@ -119,10 +119,6 @@ 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)
|
||||
}
|
||||
@@ -131,10 +127,6 @@ 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)
|
||||
}
|
||||
@@ -183,7 +175,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.RoleWithTransactionGroups) error {
|
||||
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *authtypes.Role) 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())
|
||||
@@ -208,70 +200,31 @@ func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *a
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.store.Create(ctx, role.Role); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return provider.store.Create(ctx, role)
|
||||
}
|
||||
|
||||
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) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*authtypes.Role, error) {
|
||||
return provider.store.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
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 {
|
||||
func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updatedRole *authtypes.Role) 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.GetWithTransactionGroups(ctx, orgID, updatedRole.ID)
|
||||
existingRole, err := provider.Get(ctx, orgID, updatedRole.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
additions, deletions := existingRole.TransactionGroups.Diff(updatedRole.TransactionGroups)
|
||||
existingTuples, err := provider.readAllTuplesForRole(ctx, existingRole.Name, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existingGroups := authtypes.MustNewTransactionGroupsFromTuples(existingTuples)
|
||||
additions, deletions := existingGroups.Diff(updatedRole.TransactionGroups)
|
||||
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -287,7 +240,7 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
|
||||
return err
|
||||
}
|
||||
|
||||
return provider.store.Update(ctx, orgID, updatedRole.Role)
|
||||
return provider.store.Update(ctx, orgID, updatedRole)
|
||||
}
|
||||
|
||||
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
@@ -296,7 +249,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.GetWithTransactionGroups(ctx, orgID, id)
|
||||
role, err := provider.Get(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -312,7 +265,7 @@ func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valu
|
||||
}
|
||||
}
|
||||
|
||||
tuples, err := authtypes.NewTuplesFromTransactionGroups(role.Name, orgID, role.TransactionGroups)
|
||||
tuples, err := provider.readAllTuplesForRole(ctx, role.Name, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -324,6 +277,24 @@ 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{}
|
||||
|
||||
@@ -375,21 +346,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -105,10 +105,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -21,7 +21,6 @@ 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,
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
"@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",
|
||||
@@ -128,7 +127,7 @@
|
||||
"timestamp-nano": "^1.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"uplot": "1.6.31",
|
||||
"uuid": "^8.3.2",
|
||||
"uuid": "14.0.1",
|
||||
"vite": "npm:rolldown-vite@7.3.1",
|
||||
"vite-plugin-html": "3.2.2",
|
||||
"zod": "4.3.6",
|
||||
@@ -222,34 +221,5 @@
|
||||
"*.(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
1864
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,53 @@
|
||||
trustPolicy: no-downgrade
|
||||
blockExoticSubdeps: true
|
||||
minimumReleaseAge: 2880 # 2d
|
||||
minimumReleaseAgeStrict: true
|
||||
minimumReleaseAgeExclude:
|
||||
- '@signozhq/*'
|
||||
blockExoticSubdeps: true
|
||||
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 consumer’s 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'
|
||||
|
||||
@@ -2082,6 +2082,39 @@ 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
|
||||
@@ -2325,39 +2358,6 @@ 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: AuthtypesRoleDTO[];
|
||||
data: AuthtypesGettableRoleDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -10636,7 +10636,7 @@ export type GetRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetRole200 = {
|
||||
data: AuthtypesRoleWithTransactionGroupsDTO;
|
||||
data: AuthtypesRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -1,50 +1,26 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { AxiosError } 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 postExportRawData(props);
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(
|
||||
|
||||
1
frontend/src/auto-import-registry.d.ts
vendored
1
frontend/src/auto-import-registry.d.ts
vendored
@@ -12,5 +12,4 @@
|
||||
|
||||
import '@signozhq/design-tokens';
|
||||
import '@signozhq/icons';
|
||||
import '@signozhq/resizable';
|
||||
import '@signozhq/ui';
|
||||
|
||||
@@ -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 { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesGettableRoleDTO } 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: AuthtypesRoleDTO[];
|
||||
roles: AuthtypesGettableRoleDTO[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: APIError | undefined;
|
||||
@@ -33,7 +33,7 @@ export function useRoles(): {
|
||||
}
|
||||
|
||||
export function getRoleOptions(
|
||||
roles: AuthtypesRoleDTO[],
|
||||
roles: AuthtypesGettableRoleDTO[],
|
||||
valueField: 'id' | 'name',
|
||||
): RoleOption[] {
|
||||
return roles.map((role) => ({
|
||||
@@ -79,7 +79,7 @@ interface BaseProps {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
|
||||
roles?: AuthtypesRoleDTO[];
|
||||
roles?: AuthtypesGettableRoleDTO[];
|
||||
loading?: boolean;
|
||||
isError?: boolean;
|
||||
error?: APIError;
|
||||
|
||||
@@ -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 { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesGettableRoleDTO } 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: AuthtypesRoleDTO[];
|
||||
availableRoles: AuthtypesGettableRoleDTO[];
|
||||
rolesLoading?: boolean;
|
||||
rolesError?: boolean;
|
||||
rolesErrorObj?: APIError | undefined;
|
||||
|
||||
@@ -7,11 +7,13 @@ 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',
|
||||
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -20,7 +22,7 @@ jest.mock('react-router-dom-v5-compat', () => ({
|
||||
useNavigationType: jest.fn(() => 'PUSH'),
|
||||
useLocation: jest.fn(() => ({
|
||||
pathname: '/alerts/new',
|
||||
search: 'ruleType=anomaly_rule',
|
||||
search: 'ruleType=anomaly_rule&showClassicCreateAlertsPage=true',
|
||||
hash: '',
|
||||
state: null,
|
||||
})),
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('CreateAlertRule', () => {
|
||||
expect(screen.getByText(AlertTypes.METRICS_BASED_ALERT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render classic flow when ruleType is anomaly_rule even if showClassicCreateAlertsPage is not true', () => {
|
||||
it('should render new flow when ruleType is anomaly_rule', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.showClassicCreateAlertsPage) {
|
||||
return 'false';
|
||||
@@ -163,8 +163,8 @@ describe('CreateAlertRule', () => {
|
||||
return null;
|
||||
});
|
||||
render(<CreateAlertRule />);
|
||||
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use alertType from URL when provided', () => {
|
||||
|
||||
@@ -100,10 +100,9 @@ function CreateRules(): JSX.Element {
|
||||
return <SelectAlertType onSelect={handleSelectType} />;
|
||||
}
|
||||
|
||||
if (
|
||||
showClassicCreateAlertsPageFlag ||
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT
|
||||
) {
|
||||
// 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) {
|
||||
return (
|
||||
<FormAlertRules
|
||||
alertType={alertType}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useQuery } from 'react-query';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import getAllChannels from 'api/channels/getAll';
|
||||
import classNames from 'classnames';
|
||||
import { ChartLine } from '@signozhq/icons';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { Activity, ChartLine } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
@@ -19,6 +21,7 @@ import './styles.scss';
|
||||
|
||||
function AlertCondition(): JSX.Element {
|
||||
const { alertType, setAlertType } = useCreateAlertState();
|
||||
const { featureFlags } = useAppContext();
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -30,9 +33,15 @@ 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 ||
|
||||
alertType === AlertTypes.METRICS_BASED_ALERT;
|
||||
(isAnomalyDetectionEnabled && alertType === AlertTypes.METRICS_BASED_ALERT);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
@@ -40,16 +49,15 @@ function AlertCondition(): JSX.Element {
|
||||
icon: <ChartLine size={14} data-testid="threshold-view" />,
|
||||
value: AlertTypes.METRICS_BASED_ALERT,
|
||||
},
|
||||
// Hide anomaly tab for now
|
||||
// ...(showMultipleTabs
|
||||
// ? [
|
||||
// {
|
||||
// label: 'Anomaly',
|
||||
// icon: <Activity size={14} data-testid="anomaly-view" />,
|
||||
// value: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
// },
|
||||
// ]
|
||||
// : []),
|
||||
...(showMultipleTabs
|
||||
? [
|
||||
{
|
||||
label: 'Anomaly',
|
||||
icon: <Activity size={14} data-testid="anomaly-view" />,
|
||||
value: AlertTypes.ANOMALY_BASED_ALERT,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const handleAlertTypeChange = (value: AlertTypes): void => {
|
||||
|
||||
@@ -188,7 +188,7 @@ function AnomalyThreshold({
|
||||
}}
|
||||
options={ANOMALY_SEASONALITY_OPTIONS}
|
||||
/>
|
||||
{notificationSettings.routingPolicies ? (
|
||||
{!notificationSettings.routingPolicies ? (
|
||||
<>
|
||||
<Typography.Text
|
||||
data-testid="seasonality-text"
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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';
|
||||
@@ -96,6 +100,23 @@ 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> => {
|
||||
@@ -113,6 +134,10 @@ 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(
|
||||
@@ -125,10 +150,9 @@ describe('AlertCondition', () => {
|
||||
|
||||
// Verify default alertType is METRICS_BASED_ALERT (shows AlertThreshold component)
|
||||
expect(screen.getByTestId(ALERT_THRESHOLD_TEST_ID)).toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(
|
||||
// screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
|
||||
// ).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(ANOMALY_THRESHOLD_TEST_ID),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Verify threshold tab is active by default
|
||||
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
|
||||
@@ -136,8 +160,7 @@ describe('AlertCondition', () => {
|
||||
|
||||
// Verify both tabs are visible (METRICS_BASED_ALERT supports multiple tabs)
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
// TODO: uncomment this when anomaly tab is implemented
|
||||
// expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders threshold tab by default', () => {
|
||||
@@ -152,13 +175,27 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('renders anomaly tab when alert type supports multiple tabs', () => {
|
||||
it('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();
|
||||
@@ -167,8 +204,7 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('shows AnomalyThreshold component when alert type is anomaly based', () => {
|
||||
it('shows AnomalyThreshold component when alert type is anomaly based', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
// Click on anomaly tab to switch to anomaly-based alert
|
||||
@@ -179,8 +215,7 @@ describe('AlertCondition', () => {
|
||||
expect(screen.queryByTestId(ALERT_THRESHOLD_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('switches between threshold and anomaly tabs', () => {
|
||||
it('switches between threshold and anomaly tabs', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
// Initially shows threshold component
|
||||
@@ -205,8 +240,7 @@ describe('AlertCondition', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TODO: Unskip this when anomaly tab is implemented
|
||||
it.skip('applies active tab styling correctly', () => {
|
||||
it('applies active tab styling correctly', () => {
|
||||
renderAlertCondition();
|
||||
|
||||
const thresholdTab = screen.getByText(THRESHOLD_TAB_TEXT);
|
||||
@@ -227,11 +261,10 @@ 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', () => {
|
||||
@@ -239,9 +272,8 @@ describe('AlertCondition', () => {
|
||||
|
||||
expect(screen.getByText(THRESHOLD_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(THRESHOLD_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();
|
||||
expect(screen.getByText(ANOMALY_TAB_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(ANOMALY_VIEW_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows only threshold tab for LOGS_BASED_ALERT', () => {
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import classNames from 'classnames';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { RotateCcw } from '@signozhq/icons';
|
||||
import { useAlertRuleOptional } from 'providers/Alert';
|
||||
import { Labels } from 'types/api/alerts/def';
|
||||
|
||||
@@ -22,8 +15,6 @@ function CreateAlertHeader(): JSX.Element {
|
||||
const alertRuleContext = useAlertRuleOptional();
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const groupByLabels = useMemo(() => {
|
||||
const labels = new Array<string>();
|
||||
@@ -46,14 +37,6 @@ function CreateAlertHeader(): JSX.Element {
|
||||
[groupByLabels],
|
||||
);
|
||||
|
||||
const handleSwitchToClassicExperience = useCallback(() => {
|
||||
logEvent('Alert: Switch to classic experience button clicked', {});
|
||||
|
||||
urlQuery.set(QueryParams.showClassicCreateAlertsPage, 'true');
|
||||
const url = `${ROUTES.ALERTS_NEW}?${urlQuery.toString()}`;
|
||||
safeNavigate(url, { replace: true });
|
||||
}, [safeNavigate, urlQuery]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('alert-header', { 'edit-alert-header': isEditMode })}
|
||||
@@ -61,15 +44,6 @@ function CreateAlertHeader(): JSX.Element {
|
||||
{!isEditMode && (
|
||||
<div className="alert-header__tab-bar">
|
||||
<div className="alert-header__tab">New Alert Rule</div>
|
||||
<Button
|
||||
prefix={<RotateCcw size={12} />}
|
||||
onClick={handleSwitchToClassicExperience}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Switch to Classic Experience
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="alert-header__content">
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
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,
|
||||
@@ -106,34 +98,8 @@ describe('CreateAlertHeader', () => {
|
||||
).toHaveValue('TEST_ALERT');
|
||||
});
|
||||
|
||||
it('should navigate to classic experience when button is clicked', () => {
|
||||
it('should not render "switch to classic experience" button', () => {
|
||||
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();
|
||||
|
||||
@@ -14,10 +14,7 @@ import APIError from 'types/api/error';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
buildCreateThresholdAlertRulePayload,
|
||||
validateCreateAlertState,
|
||||
} from './utils';
|
||||
import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
|
||||
|
||||
import './styles.scss';
|
||||
import {
|
||||
@@ -85,7 +82,7 @@ function Footer(): JSX.Element {
|
||||
);
|
||||
|
||||
const handleTestNotification = useCallback((): void => {
|
||||
const payload = buildCreateThresholdAlertRulePayload({
|
||||
const payload = buildCreateAlertRulePayload({
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
@@ -122,7 +119,7 @@ function Footer(): JSX.Element {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const handleSaveAlert = useCallback((): void => {
|
||||
const payload = buildCreateThresholdAlertRulePayload({
|
||||
const payload = buildCreateAlertRulePayload({
|
||||
alertType,
|
||||
basicAlertState,
|
||||
thresholdState,
|
||||
|
||||
@@ -18,6 +18,8 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { BuildCreateAlertRulePayloadArgs } from '../types';
|
||||
import {
|
||||
buildCreateAlertRulePayload,
|
||||
buildCreateAnomalyAlertRulePayload,
|
||||
buildCreateThresholdAlertRulePayload,
|
||||
getAlertOnAbsentProps,
|
||||
getEnforceMinimumDatapointsProps,
|
||||
@@ -550,4 +552,115 @@ 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -11,9 +12,11 @@ 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
|
||||
@@ -288,16 +291,15 @@ 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;
|
||||
|
||||
@@ -313,19 +315,55 @@ 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:
|
||||
alertType === AlertTypes.ANOMALY_BASED_ALERT
|
||||
? AlertTypes.METRICS_BASED_ALERT
|
||||
: alertType,
|
||||
condition: {
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: thresholds,
|
||||
},
|
||||
compositeQuery,
|
||||
selectedQueryName: thresholdState.selectedQuery,
|
||||
algorithm: thresholdState.algorithm,
|
||||
seasonality: thresholdState.seasonality,
|
||||
...alertOnAbsentProps,
|
||||
...enforceMinimumDatapointsProps,
|
||||
},
|
||||
@@ -335,9 +373,25 @@ export function buildCreateAnomalyAlertRulePayload(
|
||||
summary: notificationSettings.description,
|
||||
},
|
||||
notificationSettings: notificationSettingsProps,
|
||||
evaluation: evaluationProps,
|
||||
version: '',
|
||||
schemaVersion: '',
|
||||
evaluation: {
|
||||
kind: 'rolling',
|
||||
spec: {
|
||||
evalWindow: thresholdState.evaluationWindow,
|
||||
frequency,
|
||||
},
|
||||
},
|
||||
version: 'v5',
|
||||
schemaVersion: 'v2alpha1',
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -316,6 +316,60 @@ 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],
|
||||
|
||||
@@ -239,6 +239,39 @@ 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 = [
|
||||
{
|
||||
|
||||
@@ -124,6 +124,12 @@ 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':
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
|
||||
@@ -303,13 +304,19 @@ 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: threshold.target,
|
||||
thresholdValue: isAnomalyRule
|
||||
? Math.abs(threshold.target)
|
||||
: threshold.target,
|
||||
recoveryThresholdValue: null,
|
||||
unit: threshold.targetUnit,
|
||||
color: getColorForThreshold(threshold.name),
|
||||
@@ -321,6 +328,18 @@ 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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,28 @@
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
@@ -19,6 +21,7 @@ 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';
|
||||
|
||||
@@ -204,9 +207,31 @@ function EntityMetrics<T>({
|
||||
key={entityWidgetInfo[idx].title}
|
||||
className={styles.entityMetricsCol}
|
||||
>
|
||||
<span className={styles.entityMetricsTitle}>
|
||||
{entityWidgetInfo[idx].title}
|
||||
</span>
|
||||
<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>
|
||||
<div className={styles.entityMetricsCard} ref={graphRef}>
|
||||
{renderCardContent(query, idx)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -295,17 +296,19 @@ const renderEntityMetrics = (overrides = {}): any => {
|
||||
};
|
||||
|
||||
return render(
|
||||
<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}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -334,8 +337,8 @@ const mockTableData: (import('../utils').MetricsTableData[] | null)[] = [
|
||||
];
|
||||
|
||||
const mockQueryPayloads = [
|
||||
{ graphType: 'graph' }, // time_series
|
||||
{ graphType: 'table' }, // table
|
||||
{ graphType: 'graph', query: { queryType: 'builder' } }, // time_series
|
||||
{ graphType: 'table', query: { queryType: 'builder' } }, // table
|
||||
];
|
||||
|
||||
describe('EntityMetrics', () => {
|
||||
@@ -442,6 +445,34 @@ 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(
|
||||
|
||||
@@ -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 { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AuthtypesGettableRoleDTO } 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: AuthtypesRoleDTO };
|
||||
| { type: 'role'; role: AuthtypesGettableRoleDTO };
|
||||
|
||||
interface RolesListContentProps {
|
||||
searchQuery: string;
|
||||
@@ -176,7 +176,7 @@ function RolesListContent({ searchQuery }: RolesListContentProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
|
||||
const renderRow = (role: AuthtypesGettableRoleDTO): JSX.Element => (
|
||||
<div
|
||||
key={role.id}
|
||||
className={cx(styles.tableRow, {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from 'react-query';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import type {
|
||||
AuthtypesPostableRoleDTO,
|
||||
AuthtypesRoleWithTransactionGroupsDTO,
|
||||
AuthtypesRoleDTO,
|
||||
AuthtypesTransactionGroupDTO,
|
||||
AuthtypesUpdatableRoleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -133,7 +133,7 @@ export function transformTransactionGroupsToResourcePermissions(
|
||||
}
|
||||
|
||||
export function transformApiToRolePermissions(
|
||||
role: AuthtypesRoleWithTransactionGroupsDTO,
|
||||
role: AuthtypesRoleDTO,
|
||||
): RolePermissionsData {
|
||||
return {
|
||||
roleId: role.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
getGetRolesByUserIDQueryKey,
|
||||
useGetRolesByUserID,
|
||||
@@ -21,11 +21,11 @@ export interface MemberRoleUpdateFailure {
|
||||
}
|
||||
|
||||
interface UseMemberRoleManagerResult {
|
||||
currentRoles: AuthtypesRoleDTO[];
|
||||
currentRoles: AuthtypesGettableRoleDTO[];
|
||||
isLoading: boolean;
|
||||
applyDiff: (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
availableRoles: AuthtypesGettableRoleDTO[],
|
||||
) => Promise<MemberRoleUpdateFailure[]>;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useMemberRoleManager(
|
||||
{ query: { enabled: !!userId && enabled } },
|
||||
);
|
||||
|
||||
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
|
||||
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
|
||||
() => data?.data ?? [],
|
||||
[data?.data],
|
||||
);
|
||||
@@ -61,7 +61,7 @@ export function useMemberRoleManager(
|
||||
const applyDiff = useCallback(
|
||||
async (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
availableRoles: AuthtypesGettableRoleDTO[],
|
||||
): Promise<MemberRoleUpdateFailure[]> => {
|
||||
const currentRoleIds = new Set(
|
||||
currentRoles.map((r) => r.id).filter(Boolean),
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useDeleteServiceAccountRoleDeprecated,
|
||||
useGetServiceAccountRoles,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { retryOn429 } from 'utils/errorUtils';
|
||||
|
||||
const enum PromiseStatus {
|
||||
@@ -21,11 +21,11 @@ export interface RoleUpdateFailure {
|
||||
}
|
||||
|
||||
interface UseServiceAccountRoleManagerResult {
|
||||
currentRoles: AuthtypesRoleDTO[];
|
||||
currentRoles: AuthtypesGettableRoleDTO[];
|
||||
isLoading: boolean;
|
||||
applyDiff: (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
availableRoles: AuthtypesGettableRoleDTO[],
|
||||
) => Promise<RoleUpdateFailure[]>;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useServiceAccountRoleManager(
|
||||
{ query: { enabled: options?.enabled ?? true } },
|
||||
);
|
||||
|
||||
const currentRoles = useMemo<AuthtypesRoleDTO[]>(
|
||||
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
|
||||
() => data?.data ?? [],
|
||||
[data?.data],
|
||||
);
|
||||
@@ -64,7 +64,7 @@ export function useServiceAccountRoleManager(
|
||||
const applyDiff = useCallback(
|
||||
async (
|
||||
localRoleIds: string[],
|
||||
availableRoles: AuthtypesRoleDTO[],
|
||||
availableRoles: AuthtypesGettableRoleDTO[],
|
||||
): Promise<RoleUpdateFailure[]> => {
|
||||
const currentRoleIds = new Set(
|
||||
currentRoles.map((r) => r.id).filter(Boolean),
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
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]));
|
||||
});
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
export const EXPORT_PAGE_SIZE = 50_000;
|
||||
|
||||
/** Pages fetched in parallel.*/
|
||||
export const EXPORT_CONCURRENCY = 3;
|
||||
@@ -1,143 +0,0 @@
|
||||
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;
|
||||
// 0–100 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],
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Triggers a browser download of in-memory content (string or Blob) as a file. */
|
||||
/** Triggers a browser download of in-memory string content as a file. */
|
||||
export function downloadFile(
|
||||
content: string | Blob,
|
||||
content: string,
|
||||
fileName: string,
|
||||
mime: string,
|
||||
): void {
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* 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',
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AuthtypesGettableRoleDTO,
|
||||
AuthtypesRoleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const orgId = '019ba2bb-2fa1-7b24-8159-cfca08617ef9';
|
||||
|
||||
export const managedRoles: AuthtypesRoleDTO[] = [
|
||||
export const managedRoles: AuthtypesGettableRoleDTO[] = [
|
||||
{
|
||||
id: '019c24aa-2248-756f-9833-984f1ab63819',
|
||||
createdAt: '2026-02-03T18:00:55.624356Z',
|
||||
@@ -35,7 +38,7 @@ export const managedRoles: AuthtypesRoleDTO[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const customRoles: AuthtypesRoleDTO[] = [
|
||||
export const customRoles: AuthtypesGettableRoleDTO[] = [
|
||||
{
|
||||
id: '019c24aa-3333-0001-aaaa-111111111111',
|
||||
createdAt: '2026-02-10T10:30:00.000Z',
|
||||
@@ -56,12 +59,24 @@ export const customRoles: AuthtypesRoleDTO[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const allRoles: AuthtypesRoleDTO[] = [...managedRoles, ...customRoles];
|
||||
export const allRoles: AuthtypesGettableRoleDTO[] = [
|
||||
...managedRoles,
|
||||
...customRoles,
|
||||
];
|
||||
|
||||
export const listRolesSuccessResponse = {
|
||||
status: 'success',
|
||||
data: allRoles,
|
||||
};
|
||||
|
||||
export const customRoleResponse = { status: 'success', data: customRoles[0] };
|
||||
export const managedRoleResponse = { status: 'success', data: managedRoles[0] };
|
||||
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 };
|
||||
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
@@ -85,11 +86,18 @@ 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={alertRuleDetails?.alertType as AlertTypes}
|
||||
initialAlertType={initialAlertType}
|
||||
initialAlertState={initialAlertState}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -21,7 +21,6 @@ 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';
|
||||
@@ -44,7 +43,6 @@ export interface TraceMetadataForHeader {
|
||||
rootServiceEntryPoint: string;
|
||||
rootSpanStatusCode: string;
|
||||
hasMissingSpans: boolean;
|
||||
totalSpansCount: number;
|
||||
}
|
||||
|
||||
interface TraceDetailsHeaderProps {
|
||||
@@ -170,10 +168,6 @@ function TraceDetailsHeader({
|
||||
showTraceDetails={showTraceDetails}
|
||||
onToggleTraceDetails={handleToggleTraceDetails}
|
||||
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
|
||||
traceId={traceID || ''}
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
totalSpansCount={traceMetadata?.totalSpansCount || 0}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
@@ -234,8 +228,6 @@ function TraceDetailsHeader({
|
||||
onClose={(): void => setIsAnalyticsOpen(false)}
|
||||
onTabChange={handleAnalyticsTabChange}
|
||||
/>
|
||||
|
||||
<TraceDownloadPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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;
|
||||
@@ -12,10 +12,8 @@ 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';
|
||||
|
||||
@@ -23,10 +21,6 @@ interface TraceOptionsMenuProps {
|
||||
showTraceDetails: boolean;
|
||||
onToggleTraceDetails: () => void;
|
||||
onOpenPreviewFields: () => void;
|
||||
traceId: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
totalSpansCount: number;
|
||||
}
|
||||
|
||||
// Composed from dropdown-menu primitives (instead of DropdownMenuSimple)
|
||||
@@ -36,10 +30,6 @@ function TraceOptionsMenu({
|
||||
showTraceDetails,
|
||||
onToggleTraceDetails,
|
||||
onOpenPreviewFields,
|
||||
traceId,
|
||||
startTime,
|
||||
endTime,
|
||||
totalSpansCount,
|
||||
}: TraceOptionsMenuProps): JSX.Element {
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const setColorByField = useTraceStore((s) => s.setColorByField);
|
||||
@@ -47,13 +37,6 @@ 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) {
|
||||
@@ -98,32 +81,6 @@ 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>
|
||||
);
|
||||
|
||||
@@ -141,7 +141,6 @@ 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', () => {
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
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;
|
||||
// 0–100
|
||||
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();
|
||||
},
|
||||
}));
|
||||
@@ -1,23 +0,0 @@
|
||||
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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -6,8 +6,6 @@ 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 {
|
||||
@@ -34,8 +32,6 @@ 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';
|
||||
|
||||
@@ -346,7 +346,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
rootServiceEntryPoint: payload.rootServiceEntryPoint,
|
||||
rootSpanStatusCode: rootSpan?.response_status_code || '',
|
||||
hasMissingSpans: payload.hasMissingSpans || false,
|
||||
totalSpansCount: payload.totalSpansCount || 0,
|
||||
};
|
||||
}, [traceData?.payload]);
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* 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 {};
|
||||
@@ -28,6 +28,8 @@ export interface PostableAlertRuleV2 {
|
||||
absentFor?: number;
|
||||
requireMinPoints?: boolean;
|
||||
requiredNumPoints?: number;
|
||||
algorithm?: string;
|
||||
seasonality?: string;
|
||||
};
|
||||
evaluation?: {
|
||||
kind?: 'rolling' | 'cumulative';
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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> = {
|
||||
@@ -50,3 +52,36 @@ 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()}`;
|
||||
};
|
||||
|
||||
@@ -47,7 +47,7 @@ func (provider *provider) addRoleRoutes(router *mux.Router) error {
|
||||
Description: "This endpoint lists all roles",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*authtypes.Role, 0),
|
||||
Response: make([]*authtypes.GettableRole, 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.RoleWithTransactionGroups),
|
||||
Response: new(authtypes.Role),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
|
||||
@@ -221,16 +221,15 @@ func postableRuleExamples() []handler.OpenAPIExample {
|
||||
},
|
||||
{
|
||||
Name: "metric_anomaly",
|
||||
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.",
|
||||
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.",
|
||||
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",
|
||||
"evalWindow": "24h",
|
||||
"frequency": "3h",
|
||||
"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",
|
||||
"condition": map[string]any{
|
||||
"compositeQuery": map[string]any{
|
||||
"queryType": "builder",
|
||||
@@ -256,17 +255,29 @@ func postableRuleExamples() []handler.OpenAPIExample {
|
||||
},
|
||||
},
|
||||
},
|
||||
"op": "below",
|
||||
"matchType": "all_the_times",
|
||||
"target": 2,
|
||||
"selectedQueryName": "A",
|
||||
"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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"labels": map[string]any{"severity": "warning"},
|
||||
"preferredChannels": []any{"slack-ingestion"},
|
||||
"evaluation": rolling("24h", "3h"),
|
||||
"notificationSettings": map[string]any{
|
||||
"renotify": renotify("4h", "firing"),
|
||||
},
|
||||
"labels": map[string]any{"severity": "warning"},
|
||||
"annotations": map[string]any{
|
||||
"description": "Ingestion rate for tenant {{$tenant_id}} is anomalously low (z-score {{$value}}).",
|
||||
"summary": "Span ingestion anomaly",
|
||||
|
||||
@@ -30,17 +30,14 @@ type AuthZ interface {
|
||||
// Write accepts the insertion tuples and the deletion tuples.
|
||||
Write(context.Context, []*openfgav1.TupleKey, []*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)
|
||||
// ReadTuples reads tuples from the authorization server matching the given tuple key filter.
|
||||
ReadTuples(context.Context, *openfgav1.ReadRequestTupleKey) ([]*openfgav1.TupleKey, error)
|
||||
|
||||
// Creates the role with its transaction groups.
|
||||
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)
|
||||
Create(context.Context, valuer.UUID, *authtypes.Role) error
|
||||
|
||||
// Updates the role's metadata and reconciles its transaction groups.
|
||||
Update(context.Context, valuer.UUID, *authtypes.RoleWithTransactionGroups) error
|
||||
Update(context.Context, valuer.UUID, *authtypes.Role) error
|
||||
|
||||
// Deletes the role and tuples in authorization server.
|
||||
Delete(context.Context, valuer.UUID, valuer.UUID) error
|
||||
@@ -48,9 +45,6 @@ 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)
|
||||
|
||||
@@ -80,9 +74,6 @@ 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.
|
||||
|
||||
@@ -83,10 +83,6 @@ 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)
|
||||
}
|
||||
@@ -181,15 +177,11 @@ 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.RoleWithTransactionGroups) error {
|
||||
func (setter *provider) Create(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
|
||||
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
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 {
|
||||
func (provider *provider) Update(_ context.Context, _ valuer.UUID, _ *authtypes.Role) error {
|
||||
return errors.Newf(errors.TypeUnsupported, authtypes.ErrCodeRoleUnsupported, "not implemented")
|
||||
}
|
||||
|
||||
|
||||
@@ -36,14 +36,14 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, types.Identifiable{ID: roleWithTransactionGroups.ID})
|
||||
render.Success(rw, http.StatusCreated, types.Identifiable{ID: role.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
|
||||
}
|
||||
|
||||
roleWithTransactionGroups, err := handler.authz.GetWithTransactionGroups(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
|
||||
role, err := handler.authz.Get(ctx, valuer.MustNewUUID(claims.OrgID), roleID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, roleWithTransactionGroups)
|
||||
render.Success(rw, http.StatusOK, role)
|
||||
}
|
||||
|
||||
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, roles)
|
||||
render.Success(rw, http.StatusOK, authtypes.NewGettableRolesFromRoles(roles))
|
||||
}
|
||||
|
||||
func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -117,14 +117,13 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
roleWithTransactionGroups := authtypes.MakeRoleWithTransactionGroups(role, nil)
|
||||
err = roleWithTransactionGroups.Update(req.Description, req.TransactionGroups)
|
||||
err = role.Update(req.Description, req.TransactionGroups)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), roleWithTransactionGroups)
|
||||
err = handler.authz.Update(ctx, valuer.MustNewUUID(claims.OrgID), role)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -218,6 +218,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewMigrateSSORoleMappingNamesFactory(sqlstore),
|
||||
sqlmigration.NewAddMetricReductionRulesFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewRemoveOrganizationTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddRoleTransactionGroupsFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package sqlmigration
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
@@ -18,6 +19,30 @@ 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)
|
||||
@@ -54,28 +79,19 @@ func (migration *addManagedRoles) Up(ctx context.Context, db *bun.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
managedRoles := []*authtypes.Role{}
|
||||
managedRoles := []*role{}
|
||||
for _, orgIDStr := range orgIDs {
|
||||
orgID, err := valuer.NewUUID(orgIDStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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)
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
if len(managedRoles) > 0 {
|
||||
|
||||
191
pkg/sqlmigration/099_add_role_transaction_groups.go
Normal file
191
pkg/sqlmigration/099_add_role_transaction_groups.go
Normal file
@@ -0,0 +1,191 @@
|
||||
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
|
||||
}
|
||||
@@ -66,15 +66,20 @@ 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type RoleWithTransactionGroups struct {
|
||||
*Role
|
||||
TransactionGroups TransactionGroups `json:"transactionGroups" required:"true" nullable:"false"`
|
||||
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 PostableRole struct {
|
||||
@@ -88,7 +93,7 @@ type UpdatableRole struct {
|
||||
TransactionGroups TransactionGroups `json:"transactionGroups" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
func NewRole(name, description string, roleType valuer.String, orgID valuer.UUID) *Role {
|
||||
func NewRole(name, description string, roleType valuer.String, orgID valuer.UUID, transactionGroups TransactionGroups) *Role {
|
||||
return &Role{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
@@ -97,37 +102,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,
|
||||
}
|
||||
}
|
||||
|
||||
func NewRoleWithTransactionGroups(name, description string, roleType valuer.String, orgID valuer.UUID, transactionGroups TransactionGroups) *RoleWithTransactionGroups {
|
||||
role := NewRole(name, description, roleType, orgID)
|
||||
|
||||
return &RoleWithTransactionGroups{
|
||||
Role: role,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Type: roleType,
|
||||
OrgID: orgID,
|
||||
TransactionGroups: transactionGroups,
|
||||
}
|
||||
}
|
||||
|
||||
func MakeRoleWithTransactionGroups(role *Role, transactionGroups TransactionGroups) *RoleWithTransactionGroups {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
return gettableRoles
|
||||
}
|
||||
|
||||
func NewManagedRoles(orgID valuer.UUID) []*Role {
|
||||
return []*Role{
|
||||
NewRole(SigNozAdminRoleName, SigNozAdminRoleDescription, RoleTypeManaged, orgID),
|
||||
NewRole(SigNozEditorRoleName, SigNozEditorRoleDescription, RoleTypeManaged, orgID),
|
||||
NewRole(SigNozViewerRoleName, SigNozViewerRoleDescription, RoleTypeManaged, orgID),
|
||||
NewRole(SigNozAnonymousRoleName, SigNozAnonymousRoleDescription, RoleTypeManaged, orgID),
|
||||
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])),
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func NewStatsFromRoles(roles []*Role) map[string]any {
|
||||
@@ -144,7 +149,7 @@ func NewStatsFromRoles(roles []*Role) map[string]any {
|
||||
return stats
|
||||
}
|
||||
|
||||
func (role *RoleWithTransactionGroups) Update(description string, transactionGroups TransactionGroups) error {
|
||||
func (role *Role) Update(description string, transactionGroups TransactionGroups) error {
|
||||
err := role.ErrIfManaged()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package authtypes
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -70,6 +71,28 @@ 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 {
|
||||
@@ -87,6 +110,40 @@ 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
|
||||
|
||||
@@ -68,7 +68,7 @@ func NewTuplesFromTransactionGroups(name string, orgID valuer.UUID, transactionG
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) []*TransactionGroup {
|
||||
func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) TransactionGroups {
|
||||
objectsByRelation := make(map[string][]*coretypes.Object)
|
||||
|
||||
for _, tuple := range tuples {
|
||||
@@ -81,7 +81,7 @@ func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) []*Transac
|
||||
objectsByRelation[verb.StringValue()] = append(objectsByRelation[verb.StringValue()], object)
|
||||
}
|
||||
|
||||
transactionGroups := make([]*TransactionGroup, 0)
|
||||
transactionGroups := make(TransactionGroups, 0)
|
||||
for _, verb := range coretypes.Verbs {
|
||||
objects := objectsByRelation[verb.StringValue()]
|
||||
if len(objects) == 0 {
|
||||
|
||||
@@ -727,6 +727,39 @@ 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",
|
||||
|
||||
Reference in New Issue
Block a user