Compare commits

..

1 Commits

Author SHA1 Message Date
Gaurav Tewari
db8fd45280 test(query-builder): deflake recent-searches click test
userEvent.click awaits between pointer events, leaving gaps where the
completion can close before the tooltip's mousedown handler runs (it
destructures a null `open`), and its selection handling needs Range
APIs the CodeMirror DOM mocks don't provide. Apply the recent via a
synchronous fireEvent.mouseDown located and dispatched in a single
waitFor pass, and fold the repeated label assertions into a helper
that re-opens the popup on each retry.

Assisted-by: Claude Fable 5
2026-08-13 20:36:07 +05:30
237 changed files with 2431 additions and 10110 deletions

View File

@@ -464,50 +464,27 @@ components:
type: string
type: object
AuthtypesAuthDomainConfig:
discriminator:
mapping:
google: '#/components/schemas/AuthtypesAuthDomainConfigGoogle'
oidc: '#/components/schemas/AuthtypesAuthDomainConfigOIDC'
saml: '#/components/schemas/AuthtypesAuthDomainConfigSAML'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/AuthtypesAuthDomainConfigSAML'
- $ref: '#/components/schemas/AuthtypesAuthDomainConfigGoogle'
- $ref: '#/components/schemas/AuthtypesAuthDomainConfigOIDC'
type: object
AuthtypesAuthDomainConfigGoogle:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
properties:
kind:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
spec:
googleAuthConfig:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
required:
- kind
- spec
type: object
AuthtypesAuthDomainConfigOIDC:
properties:
kind:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
spec:
oidcConfig:
$ref: '#/components/schemas/AuthtypesOIDCConfig'
required:
- kind
- spec
type: object
AuthtypesAuthDomainConfigSAML:
properties:
kind:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
spec:
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
samlConfig:
$ref: '#/components/schemas/AuthtypesSamlConfig'
required:
- kind
- spec
ssoEnabled:
type: boolean
ssoType:
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: object
AuthtypesAuthNProvider:
enum:
- google
- google_auth
- saml
- email_password
- oidc
@@ -554,16 +531,12 @@ components:
createdAt:
format: date-time
type: string
enabled:
type: boolean
id:
type: string
name:
type: string
orgId:
type: string
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
updatedAt:
format: date-time
type: string
@@ -628,7 +601,6 @@ components:
clientId:
type: string
clientSecret:
format: password
type: string
domainToAdminEmail:
additionalProperties:
@@ -640,12 +612,10 @@ components:
type: boolean
insecureSkipEmailVerified:
type: boolean
serviceAccountJson:
format: password
redirectURI:
type: string
serviceAccountJson:
type: string
required:
- clientId
- clientSecret
type: object
AuthtypesOIDCConfig:
properties:
@@ -654,7 +624,6 @@ components:
clientId:
type: string
clientSecret:
format: password
type: string
getUserInfo:
type: boolean
@@ -664,10 +633,6 @@ components:
type: string
issuerAlias:
type: string
required:
- issuer
- clientId
- clientSecret
type: object
AuthtypesOrgSessionContext:
properties:
@@ -689,15 +654,8 @@ components:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
enabled:
type: boolean
name:
type: string
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
required:
- name
- config
type: object
AuthtypesPostableEmailPasswordSession:
properties:
@@ -804,18 +762,14 @@ components:
properties:
attributeMapping:
$ref: '#/components/schemas/AuthtypesAttributeMapping'
certificate:
type: string
entityId:
type: string
insecureSkipAuthNRequestsSigned:
type: boolean
location:
samlCert:
type: string
samlEntity:
type: string
samlIdp:
type: string
required:
- entityId
- location
- certificate
type: object
AuthtypesSessionContext:
properties:
@@ -855,12 +809,6 @@ components:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
enabled:
type: boolean
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
required:
- config
type: object
AuthtypesUpdatableRole:
properties:
@@ -8805,7 +8753,6 @@ components:
- metric
- log
- span
- trace
- resource
- attribute
- body
@@ -9116,158 +9063,6 @@ info:
version: ""
openapi: 3.0.3
paths:
/api/v1/ai_observability/fields/keys:
get:
deprecated: false
description: This endpoint returns the field keys the AI observability explorer
can filter on, including the computed per-trace aggregates
operationId: GetAIObservabilityFieldsKeys
parameters:
- in: query
name: searchText
schema:
type: string
- in: query
name: fieldContext
schema:
$ref: '#/components/schemas/TelemetrytypesFieldContext'
- in: query
name: fieldDataType
schema:
$ref: '#/components/schemas/TelemetrytypesFieldDataType'
- in: query
name: startUnixMilli
schema:
format: int64
type: integer
- in: query
name: endUnixMilli
schema:
format: int64
type: integer
- in: query
name: limit
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TelemetrytypesGettableFieldKeys'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get AI observability field keys
tags:
- ai_observability
/api/v1/ai_observability/fields/values:
get:
deprecated: false
description: This endpoint returns the values the AI observability explorer
can filter a field key on
operationId: GetAIObservabilityFieldsValues
parameters:
- in: query
name: searchText
schema:
type: string
- in: query
name: fieldContext
schema:
$ref: '#/components/schemas/TelemetrytypesFieldContext'
- in: query
name: fieldDataType
schema:
$ref: '#/components/schemas/TelemetrytypesFieldDataType'
- in: query
name: startUnixMilli
schema:
format: int64
type: integer
- in: query
name: endUnixMilli
schema:
format: int64
type: integer
- in: query
name: limit
schema:
type: integer
- in: query
name: name
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TelemetrytypesGettableFieldValues'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get AI observability field values
tags:
- ai_observability
/api/v1/alerts:
get:
deprecated: false
@@ -10746,6 +10541,275 @@ paths:
summary: Update public dashboard
tags:
- dashboard
/api/v1/domains:
get:
deprecated: false
description: This endpoint lists all auth domains
operationId: ListAuthDomains
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: List all auth domains
tags:
- authdomains
post:
deprecated: false
description: This endpoint creates an auth domain
operationId: CreateAuthDomain
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPostableAuthDomain'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create auth domain
tags:
- authdomains
/api/v1/domains/{id}:
delete:
deprecated: false
description: This endpoint deletes an auth domain
operationId: DeleteAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete auth domain
tags:
- authdomains
get:
deprecated: false
description: This endpoint returns an auth domain by ID
operationId: GetAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get auth domain by ID
tags:
- authdomains
put:
deprecated: false
description: This endpoint updates an auth domain
operationId: UpdateAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesUpdatableAuthDomain'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update auth domain
tags:
- authdomains
/api/v1/downtime_schedules:
get:
deprecated: false
@@ -14334,275 +14398,6 @@ paths:
summary: Update user preference
tags:
- preferences
/api/v2/auth_domains:
get:
deprecated: false
description: This endpoint lists all auth domains
operationId: ListAuthDomains
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: List all auth domains
tags:
- authdomains
post:
deprecated: false
description: This endpoint creates an auth domain
operationId: CreateAuthDomain
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPostableAuthDomain'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create auth domain
tags:
- authdomains
/api/v2/auth_domains/{id}:
delete:
deprecated: false
description: This endpoint deletes an auth domain
operationId: DeleteAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete auth domain
tags:
- authdomains
get:
deprecated: false
description: This endpoint returns an auth domain by ID
operationId: GetAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get auth domain by ID
tags:
- authdomains
put:
deprecated: false
description: This endpoint updates an auth domain
operationId: UpdateAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesUpdatableAuthDomain'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update auth domain
tags:
- authdomains
/api/v2/dashboard_views:
get:
deprecated: false

View File

@@ -349,7 +349,7 @@ func (Step) JSONSchema() (jsonschema.Schema, error) {
### `oneOf` with a discriminator
For a sum type whose variants are keyed by a property (e.g. `kind`), expose the variants via `JSONSchemaOneOf()` and add a discriminator. Without it, code generators intersect the variants (`A & B & C`) instead of producing a clean discriminated union (`A | B | C`).
For a sum type whose variants are keyed by a property (e.g. `kind`), expose the variants via `JSONSchemaOneOf()` and add a discriminator. Without it, code generators intersect the variants (`A & B & C`) instead of producing a clean discriminated union (`A | B | C`). How to model the sum type itself is covered in [types.md](types.md#sum-types-the-kindspec-envelope) — this section is only about its schema.
The parent keeps its `JSONSchemaOneOf()` (the `oneOf` itself) and *additionally* tags it via `PrepareJSONSchema` with the `x-signoz-discriminator` extension; `signoz.attachDiscriminators` then promotes that marker to a real OpenAPI 3 `discriminator` (and strips the duplicate parent properties) after reflection.

View File

@@ -61,37 +61,31 @@ type Channel struct {
```go
type AuthDomain struct {
storableAuthDomain *StorableAuthDomain
storableAuthDomainConfig *StorableAuthDomainConfig
storableAuthDomain *StorableAuthDomain
authDomainConfig *AuthDomainConfig
}
type StorableAuthDomain struct {
bun.BaseModel `bun:"table:auth_domain"`
types.Identifiable
Name string `bun:"name"`
Data string `bun:"data"` // StorableAuthDomainConfig serialized as JSON
Data string `bun:"data"` // AuthDomainConfig serialized as JSON
OrgID valuer.UUID `bun:"org_id"`
types.TimeAuditable
}
type PostableAuthDomain struct {
Name string `json:"name" required:"true"`
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config" required:"true"`
RoleMapping *RoleMapping `json:"roleMapping"`
Config AuthDomainConfig `json:"config"`
Name string `json:"name"`
}
type UpdatableAuthDomain struct {
Enabled bool `json:"enabled"` // Name intentionally absent
Config AuthDomainConfig `json:"config" required:"true"`
RoleMapping *RoleMapping `json:"roleMapping"`
type UpdateableAuthDomain struct {
Config AuthDomainConfig `json:"config"` // Name intentionally absent
}
type GettableAuthDomain struct {
StorableAuthDomain
Enabled bool `json:"enabled"`
Config AuthDomainConfig `json:"config"`
RoleMapping *RoleMapping `json:"roleMapping"`
*StorableAuthDomain
*AuthDomainConfig
AuthNProviderInfo *AuthNProviderInfo `json:"authNProviderInfo"`
}
```
@@ -99,11 +93,11 @@ type GettableAuthDomain struct {
Each flavor exists for a concrete reason:
- `StorableAuthDomain` stores the typed config as an opaque `Data string` column, so the schema does not need to migrate every time a config field is added.
- `PostableAuthDomain` carries the config as a structured object (not a string) for the request; `AuthDomainConfig` is a kind/spec envelope.
- `UpdatableAuthDomain` excludes `Name` because a domain's name cannot change after creation.
- `PostableAuthDomain` carries the config as a structured object (not a string) for the request.
- `UpdateableAuthDomain` excludes `Name` because a domain's name cannot change after creation.
- `GettableAuthDomain` adds `AuthNProviderInfo`, which is derived at read time and never persisted.
The core `AuthDomain` holds the two live halves — `storableAuthDomain` and `storableAuthDomainConfig` — and owns business methods such as `Update(updatable)` and `Patch(patchable)`. Conversions use the `New<Output>From<Input>` form: `NewAuthDomainFromPostableAuthDomain`, `NewAuthDomainFromStorableAuthDomain`, `NewGettableAuthDomainFromAuthDomain`.
The core `AuthDomain` holds the two live halves — `storableAuthDomain` and `authDomainConfig` — and owns business methods such as `Update(config)`. Conversions use the `New<Output>From<Input>` form: `NewAuthDomainFromConfig`, `NewAuthDomainFromStorableAuthDomain`, `NewGettableAuthDomainFromAuthDomain`.
## Sum types: the kind/spec envelope

View File

@@ -53,6 +53,10 @@ func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSett
}
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderOIDC {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not oidc")
}
_, oauth2Config, err := a.oidcProviderAndoauth2Config(ctx, siteURL, authDomain)
if err != nil {
return "", err
@@ -81,11 +85,6 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
oidcConfig, err := authDomain.Config().OIDCConfig()
if err != nil {
return nil, err
}
oidcProvider, oauth2Config, err := a.oidcProviderAndoauth2Config(ctx, state.URL, authDomain)
if err != nil {
return nil, err
@@ -107,14 +106,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, err
}
if claims == nil && oidcConfig.GetUserInfo {
if claims == nil && authDomain.AuthDomainConfig().OIDC.GetUserInfo {
claims, err = a.claimsFromUserInfo(ctx, oidcProvider, token)
if err != nil {
return nil, err
}
}
emailClaim, ok := claims[oidcConfig.ClaimMapping.Email].(string)
emailClaim, ok := claims[authDomain.AuthDomainConfig().OIDC.ClaimMapping.Email].(string)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email in claims")
}
@@ -124,7 +123,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to parse email").WithAdditional(err.Error())
}
if !oidcConfig.InsecureSkipEmailVerified {
if !authDomain.AuthDomainConfig().OIDC.InsecureSkipEmailVerified {
emailVerifiedClaim, ok := claims["email_verified"].(bool)
if !ok {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email_verified in claims")
@@ -136,14 +135,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
name := ""
if nameClaim := oidcConfig.ClaimMapping.Name; nameClaim != "" {
if nameClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Name; nameClaim != "" {
if n, ok := claims[nameClaim].(string); ok {
name = n
}
}
var groups []string
if groupsClaim := oidcConfig.ClaimMapping.Groups; groupsClaim != "" {
if groupsClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Groups; groupsClaim != "" {
if claimValue, exists := claims[groupsClaim]; exists {
switch g := claimValue.(type) {
case []any:
@@ -162,7 +161,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
role := ""
if roleClaim := oidcConfig.ClaimMapping.Role; roleClaim != "" {
if roleClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Role; roleClaim != "" {
if r, ok := claims[roleClaim].(string); ok {
role = r
}
@@ -178,16 +177,11 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
}
func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (*oidc.Provider, *oauth2.Config, error) {
oidcConfig, err := authDomain.Config().OIDCConfig()
if err != nil {
return nil, nil, err
if authDomain.AuthDomainConfig().OIDC.IssuerAlias != "" {
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().OIDC.IssuerAlias)
}
if oidcConfig.IssuerAlias != "" {
ctx = oidc.InsecureIssuerURLContext(ctx, oidcConfig.IssuerAlias)
}
oidcProvider, err := oidc.NewProvider(ctx, oidcConfig.Issuer)
oidcProvider, err := oidc.NewProvider(ctx, authDomain.AuthDomainConfig().OIDC.Issuer)
if err != nil {
return nil, nil, err
}
@@ -195,13 +189,13 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
scopes := make([]string, len(defaultScopes))
copy(scopes, defaultScopes)
if authDomain.RoleMapping() != nil && len(authDomain.RoleMapping().GroupMappings) > 0 {
if authDomain.AuthDomainConfig().RoleMapping != nil && len(authDomain.AuthDomainConfig().RoleMapping.GroupMappings) > 0 {
scopes = append(scopes, "groups")
}
return oidcProvider, &oauth2.Config{
ClientID: oidcConfig.ClientID,
ClientSecret: oidcConfig.ClientSecret,
ClientID: authDomain.AuthDomainConfig().OIDC.ClientID,
ClientSecret: authDomain.AuthDomainConfig().OIDC.ClientSecret,
Endpoint: oidcProvider.Endpoint(),
Scopes: scopes,
RedirectURL: (&url.URL{
@@ -218,12 +212,7 @@ func (a *AuthN) claimsFromIDToken(ctx context.Context, authDomain *authtypes.Aut
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "oidc: no id_token in token response")
}
oidcConfig, err := authDomain.Config().OIDCConfig()
if err != nil {
return nil, err
}
verifier := provider.Verifier(&oidc.Config{ClientID: oidcConfig.ClientID})
verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().OIDC.ClientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "oidc: failed to verify token").WithAdditional(err.Error())

View File

@@ -40,6 +40,10 @@ func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Li
}
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderSAML {
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "saml: domain type is not saml")
}
sp, err := a.serviceProvider(siteURL, authDomain)
if err != nil {
return "", err
@@ -69,11 +73,6 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
samlConfig, err := authDomain.Config().SamlConfig()
if err != nil {
return nil, err
}
sp, err := a.serviceProvider(state.URL, authDomain)
if err != nil {
return nil, err
@@ -102,19 +101,19 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
}
name := ""
if nameAttribute := samlConfig.AttributeMapping.Name; nameAttribute != "" {
if nameAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Name; nameAttribute != "" {
if val := assertionInfo.Values.Get(nameAttribute); val != "" {
name = val
}
}
var groups []string
if groupAttribute := samlConfig.AttributeMapping.Groups; groupAttribute != "" {
if groupAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Groups; groupAttribute != "" {
groups = assertionInfo.Values.GetAll(groupAttribute)
}
role := ""
if roleAttribute := samlConfig.AttributeMapping.Role; roleAttribute != "" {
if roleAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Role; roleAttribute != "" {
if val := assertionInfo.Values.Get(roleAttribute); val != "" {
role = val
}
@@ -132,12 +131,7 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
}
func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDomain) (*saml2.SAMLServiceProvider, error) {
samlConfig, err := authDomain.Config().SamlConfig()
if err != nil {
return nil, err
}
certStore, err := a.getCertificateStore(samlConfig)
certStore, err := a.getCertificateStore(authDomain)
if err != nil {
return nil, err
}
@@ -148,32 +142,32 @@ func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDoma
// The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak.
// For AWSSSO, this is the value of Application SAML audience.
return &saml2.SAMLServiceProvider{
IdentityProviderSSOURL: samlConfig.Location,
IdentityProviderIssuer: samlConfig.EntityID,
IdentityProviderSSOURL: authDomain.AuthDomainConfig().SAML.SamlIdp,
IdentityProviderIssuer: authDomain.AuthDomainConfig().SAML.SamlEntity,
ServiceProviderIssuer: siteURL.Host,
AssertionConsumerServiceURL: acsURL.String(),
SignAuthnRequests: !samlConfig.InsecureSkipAuthNRequestsSigned,
SignAuthnRequests: !authDomain.AuthDomainConfig().SAML.InsecureSkipAuthNRequestsSigned,
AllowMissingAttributes: true,
IDPCertificateStore: certStore,
SPKeyStore: dsig.RandomKeyStoreForTest(),
}, nil
}
func (a *AuthN) getCertificateStore(samlConfig authtypes.SamlConfig) (dsig.X509CertificateStore, error) {
func (a *AuthN) getCertificateStore(authDomain *authtypes.AuthDomain) (dsig.X509CertificateStore, error) {
certStore := &dsig.MemoryX509CertificateStore{
Roots: []*x509.Certificate{},
}
var certBytes []byte
if strings.Contains(samlConfig.Certificate, "-----BEGIN CERTIFICATE-----") {
block, _ := pem.Decode([]byte(samlConfig.Certificate))
if strings.Contains(authDomain.AuthDomainConfig().SAML.SamlCert, "-----BEGIN CERTIFICATE-----") {
block, _ := pem.Decode([]byte(authDomain.AuthDomainConfig().SAML.SamlCert))
if block == nil {
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no valid pem cert found")
}
certBytes = block.Bytes
} else {
certData, err := base64.StdEncoding.DecodeString(samlConfig.Certificate)
certData, err := base64.StdEncoding.DecodeString(authDomain.AuthDomainConfig().SAML.SamlCert)
if err != nil {
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to read certificate: %s", err.Error())
}

View File

@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
transformIgnorePatterns: [
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

View File

@@ -88,7 +88,6 @@
"jest": "30.2.0",
"js-base64": "^3.7.2",
"lodash-es": "^4.17.21",
"monaco-editor": "0.55.1",
"motion": "12.4.13",
"nuqs": "2.8.8",
"overlayscrollbars": "^2.16.0",
@@ -197,7 +196,7 @@
"oxfmt": "0.54.0",
"oxlint": "1.69.0",
"oxlint-tsgolint": "0.23.0",
"postcss": "8.5.26",
"postcss": "8.5.14",
"postcss-scss": "4.0.9",
"react-resizable": "3.0.4",
"redux-mock-store": "1.5.4",
@@ -238,4 +237,4 @@
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
}
}
}

373
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,16 +13,7 @@ overrides:
'@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.3.1 <5'
# via: babel-plugin-istanbul > test-exclude@6 > minimatch@3.1.5 (^1.1.7); also glob@7
# remove: blocked — babel-plugin-istanbul pins test-exclude@6, which pins minimatch@3
brace-expansion@<1.1.18: '>=1.1.18 <2'
# via: jest > glob@10 > minimatch@9.0.9 (^2.0.1); also minimatch@5.1.9 (^2.0.1)
# remove: blocked — jest 30 resolves glob@10 > minimatch@9 internally
'brace-expansion@>=2.0.0 <2.1.4': '>=2.1.4 <3'
# via: eslint-plugin-sonarjs@4.0.2 (minimatch ^10.2.4) > minimatch@10.2.5 (^5.0.5)
# remove: blocked — minimatch@10.2.6 (latest) only widens to ^5.0.8, still vulnerable
'brace-expansion@>=5.0.0 <5.0.9': '>=5.0.9 <6'
'@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)
@@ -36,26 +27,13 @@ overrides:
# 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: @commitlint/cli > @commitlint/config-validator > ajv@8 (fast-uri ^3.0.1)
# remove: blocked — ajv@8 caps fast-uri at ^3, and only 3.1.5 carries the fix
fast-uri@<3.1.5: '>=3.1.5 <4'
# via: direct devDep sass@1.97.3 (immutable ^5.0.2)
# remove: blocked — plain sass bumps stay within ^5, so the floor is what pulls 5.1.8
immutable@<5.1.8: '>=5.1.8 <6'
# 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.3.1': '>=4.3.1 <5'
# via: typescript-plugin-css-modules@5.2.0 (less ^4.2.0) > less@4.4.0 (image-size ~0.5.0)
# remove: bump typescript-plugin-css-modules once it floors less itself; image-size has
# no patched release at all, so dropping the dep is the only fix — less@4.5.0 did
less@<4.5.0: '>=4.5.0 <5'
# via: direct dep postcss (nanoid ^3.3.17)
# remove: blocked — postcss 8.5.26 (latest) caps nanoid at ^3, fix landed in 3.3.18
nanoid@<3.3.18: '>=3.3.18 <4'
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'

View File

@@ -1,236 +0,0 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useQuery } from 'react-query';
import type {
InvalidateOptions,
QueryClient,
QueryFunction,
QueryKey,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
GetAIObservabilityFieldsKeys200,
GetAIObservabilityFieldsKeysParams,
GetAIObservabilityFieldsValues200,
GetAIObservabilityFieldsValuesParams,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType } from '../../../generatedAPIInstance';
/**
* This endpoint returns the field keys the AI observability explorer can filter on, including the computed per-trace aggregates
* @summary Get AI observability field keys
*/
export const getAIObservabilityFieldsKeys = (
params?: GetAIObservabilityFieldsKeysParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetAIObservabilityFieldsKeys200>({
url: `/api/v1/ai_observability/fields/keys`,
method: 'GET',
params,
signal,
});
};
export const getGetAIObservabilityFieldsKeysQueryKey = (
params?: GetAIObservabilityFieldsKeysParams,
) => {
return [
`/api/v1/ai_observability/fields/keys`,
...(params ? [params] : []),
] as const;
};
export const getGetAIObservabilityFieldsKeysQueryOptions = <
TData = Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: GetAIObservabilityFieldsKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetAIObservabilityFieldsKeysQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>
> = ({ signal }) => getAIObservabilityFieldsKeys(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetAIObservabilityFieldsKeysQueryResult = NonNullable<
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>
>;
export type GetAIObservabilityFieldsKeysQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get AI observability field keys
*/
export function useGetAIObservabilityFieldsKeys<
TData = Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: GetAIObservabilityFieldsKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetAIObservabilityFieldsKeysQueryOptions(
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get AI observability field keys
*/
export const invalidateGetAIObservabilityFieldsKeys = async (
queryClient: QueryClient,
params?: GetAIObservabilityFieldsKeysParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetAIObservabilityFieldsKeysQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint returns the values the AI observability explorer can filter a field key on
* @summary Get AI observability field values
*/
export const getAIObservabilityFieldsValues = (
params?: GetAIObservabilityFieldsValuesParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetAIObservabilityFieldsValues200>({
url: `/api/v1/ai_observability/fields/values`,
method: 'GET',
params,
signal,
});
};
export const getGetAIObservabilityFieldsValuesQueryKey = (
params?: GetAIObservabilityFieldsValuesParams,
) => {
return [
`/api/v1/ai_observability/fields/values`,
...(params ? [params] : []),
] as const;
};
export const getGetAIObservabilityFieldsValuesQueryOptions = <
TData = Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: GetAIObservabilityFieldsValuesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetAIObservabilityFieldsValuesQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>
> = ({ signal }) => getAIObservabilityFieldsValues(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetAIObservabilityFieldsValuesQueryResult = NonNullable<
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>
>;
export type GetAIObservabilityFieldsValuesQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get AI observability field values
*/
export function useGetAIObservabilityFieldsValues<
TData = Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: GetAIObservabilityFieldsValuesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetAIObservabilityFieldsValuesQueryOptions(
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get AI observability field values
*/
export const invalidateGetAIObservabilityFieldsValues = async (
queryClient: QueryClient,
params?: GetAIObservabilityFieldsValuesParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetAIObservabilityFieldsValuesQueryKey(params) },
options,
);
return queryClient;
};

View File

@@ -38,14 +38,14 @@ import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
*/
export const listAuthDomains = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListAuthDomains200>({
url: `/api/v2/auth_domains`,
url: `/api/v1/domains`,
method: 'GET',
signal,
});
};
export const getListAuthDomainsQueryKey = () => {
return [`/api/v2/auth_domains`] as const;
return [`/api/v1/domains`] as const;
};
export const getListAuthDomainsQueryOptions = <
@@ -125,7 +125,7 @@ export const createAuthDomain = (
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateAuthDomain201>({
url: `/api/v2/auth_domains`,
url: `/api/v1/domains`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: authtypesPostableAuthDomainDTO,
@@ -208,7 +208,7 @@ export const deleteAuthDomain = (
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/auth_domains/${id}`,
url: `/api/v1/domains/${id}`,
method: 'DELETE',
signal,
});
@@ -287,7 +287,7 @@ export const getAuthDomain = (
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetAuthDomain200>({
url: `/api/v2/auth_domains/${id}`,
url: `/api/v1/domains/${id}`,
method: 'GET',
signal,
});
@@ -296,7 +296,7 @@ export const getAuthDomain = (
export const getGetAuthDomainQueryKey = ({
id,
}: GetAuthDomainPathParameters) => {
return [`/api/v2/auth_domains/${id}`] as const;
return [`/api/v1/domains/${id}`] as const;
};
export const getGetAuthDomainQueryOptions = <
@@ -389,7 +389,7 @@ export const updateAuthDomain = (
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/auth_domains/${id}`,
url: `/api/v1/domains/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: authtypesUpdatableAuthDomainDTO,

View File

@@ -1861,19 +1861,8 @@ export interface AuthtypesAttributeMappingDTO {
role?: string;
}
export enum AuthtypesAuthDomainConfigSAMLDTOKind {
saml = 'saml',
}
export interface AuthtypesSamlConfigDTO {
attributeMapping?: AuthtypesAttributeMappingDTO;
/**
* @type string
*/
certificate: string;
/**
* @type string
*/
entityId: string;
/**
* @type boolean
*/
@@ -1881,21 +1870,17 @@ export interface AuthtypesSamlConfigDTO {
/**
* @type string
*/
location: string;
}
export interface AuthtypesAuthDomainConfigSAMLDTO {
samlCert?: string;
/**
* @type string
* @enum saml
*/
kind: AuthtypesAuthDomainConfigSAMLDTOKind;
spec: AuthtypesSamlConfigDTO;
samlEntity?: string;
/**
* @type string
*/
samlIdp?: string;
}
export enum AuthtypesAuthDomainConfigGoogleDTOKind {
google = 'google',
}
export type AuthtypesGoogleConfigDTODomainToAdminEmail = {
[key: string]: string;
};
@@ -1908,12 +1893,11 @@ export interface AuthtypesGoogleConfigDTO {
/**
* @type string
*/
clientId: string;
clientId?: string;
/**
* @type string
* @format password
*/
clientSecret: string;
clientSecret?: string;
/**
* @type object
*/
@@ -1932,34 +1916,24 @@ export interface AuthtypesGoogleConfigDTO {
insecureSkipEmailVerified?: boolean;
/**
* @type string
* @format password
*/
redirectURI?: string;
/**
* @type string
*/
serviceAccountJson?: string;
}
export interface AuthtypesAuthDomainConfigGoogleDTO {
/**
* @type string
* @enum google
*/
kind: AuthtypesAuthDomainConfigGoogleDTOKind;
spec: AuthtypesGoogleConfigDTO;
}
export enum AuthtypesAuthDomainConfigOIDCDTOKind {
oidc = 'oidc',
}
export interface AuthtypesOIDCConfigDTO {
claimMapping?: AuthtypesAttributeMappingDTO;
/**
* @type string
*/
clientId: string;
clientId?: string;
/**
* @type string
* @format password
*/
clientSecret: string;
clientSecret?: string;
/**
* @type boolean
*/
@@ -1971,33 +1945,79 @@ export interface AuthtypesOIDCConfigDTO {
/**
* @type string
*/
issuer: string;
issuer?: string;
/**
* @type string
*/
issuerAlias?: string;
}
export interface AuthtypesAuthDomainConfigOIDCDTO {
export type AuthtypesRoleMappingDTOGroupMappingsAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type AuthtypesRoleMappingDTOGroupMappings =
AuthtypesRoleMappingDTOGroupMappingsAnyOf | null;
export interface AuthtypesRoleMappingDTO {
/**
* @type string
* @enum oidc
*/
kind: AuthtypesAuthDomainConfigOIDCDTOKind;
spec: AuthtypesOIDCConfigDTO;
defaultRole?: string;
/**
* @type object,null
*/
groupMappings?: AuthtypesRoleMappingDTOGroupMappings;
/**
* @type boolean
*/
useRoleAttribute?: boolean;
}
export type AuthtypesAuthDomainConfigDTO =
| AuthtypesAuthDomainConfigSAMLDTO
| AuthtypesAuthDomainConfigGoogleDTO
| AuthtypesAuthDomainConfigOIDCDTO;
export enum AuthtypesAuthNProviderDTO {
google = 'google',
google_auth = 'google_auth',
saml = 'saml',
email_password = 'email_password',
oidc = 'oidc',
}
export type AuthtypesAuthDomainConfigDTO =
| (AuthtypesSamlConfigDTO & {
googleAuthConfig?: AuthtypesGoogleConfigDTO;
oidcConfig?: AuthtypesOIDCConfigDTO;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
})
| (AuthtypesGoogleConfigDTO & {
googleAuthConfig?: AuthtypesGoogleConfigDTO;
oidcConfig?: AuthtypesOIDCConfigDTO;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
})
| (AuthtypesOIDCConfigDTO & {
googleAuthConfig?: AuthtypesGoogleConfigDTO;
oidcConfig?: AuthtypesOIDCConfigDTO;
roleMapping?: AuthtypesRoleMappingDTO;
samlConfig?: AuthtypesSamlConfigDTO;
/**
* @type boolean
*/
ssoEnabled?: boolean;
ssoType?: AuthtypesAuthNProviderDTO;
});
export interface AuthtypesAuthNProviderInfoDTO {
/**
* @type string,null
@@ -2035,31 +2055,6 @@ export interface AuthtypesDeprecatedPostableUserRoleDTO {
id: string;
}
export type AuthtypesRoleMappingDTOGroupMappingsAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type AuthtypesRoleMappingDTOGroupMappings =
AuthtypesRoleMappingDTOGroupMappingsAnyOf | null;
export interface AuthtypesRoleMappingDTO {
/**
* @type string
*/
defaultRole?: string;
/**
* @type object,null
*/
groupMappings?: AuthtypesRoleMappingDTOGroupMappings;
/**
* @type boolean
*/
useRoleAttribute?: boolean;
}
export interface AuthtypesGettableAuthDomainDTO {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
config?: AuthtypesAuthDomainConfigDTO;
@@ -2068,10 +2063,6 @@ export interface AuthtypesGettableAuthDomainDTO {
* @format date-time
*/
createdAt?: string;
/**
* @type boolean
*/
enabled?: boolean;
/**
* @type string
*/
@@ -2084,7 +2075,6 @@ export interface AuthtypesGettableAuthDomainDTO {
* @type string
*/
orgId?: string;
roleMapping?: AuthtypesRoleMappingDTO;
/**
* @type string
* @format date-time
@@ -2281,16 +2271,11 @@ export interface AuthtypesOrgSessionContextDTO {
}
export interface AuthtypesPostableAuthDomainDTO {
config: AuthtypesAuthDomainConfigDTO;
/**
* @type boolean
*/
enabled?: boolean;
config?: AuthtypesAuthDomainConfigDTO;
/**
* @type string
*/
name: string;
roleMapping?: AuthtypesRoleMappingDTO;
name?: string;
}
export interface AuthtypesPostableEmailPasswordSessionDTO {
@@ -2423,12 +2408,7 @@ export interface AuthtypesTransactionDTO {
}
export interface AuthtypesUpdatableAuthDomainDTO {
config: AuthtypesAuthDomainConfigDTO;
/**
* @type boolean
*/
enabled?: boolean;
roleMapping?: AuthtypesRoleMappingDTO;
config?: AuthtypesAuthDomainConfigDTO;
}
export interface AuthtypesUpdatableRoleDTO {
@@ -3490,7 +3470,6 @@ export enum TelemetrytypesFieldContextDTO {
metric = 'metric',
log = 'log',
span = 'span',
trace = 'trace',
resource = 'resource',
attribute = 'attribute',
body = 'body',
@@ -10176,93 +10155,6 @@ export interface ZeustypesPostableProfileDTO {
where_did_you_discover_signoz: string;
}
export type GetAIObservabilityFieldsKeysParams = {
/**
* @type string
* @description undefined
*/
searchText?: string;
/**
* @description undefined
*/
fieldContext?: TelemetrytypesFieldContextDTO;
/**
* @description undefined
*/
fieldDataType?: TelemetrytypesFieldDataTypeDTO;
/**
* @type integer
* @format int64
* @description undefined
*/
startUnixMilli?: number;
/**
* @type integer
* @format int64
* @description undefined
*/
endUnixMilli?: number;
/**
* @type integer
* @description undefined
*/
limit?: number;
};
export type GetAIObservabilityFieldsKeys200 = {
data: TelemetrytypesGettableFieldKeysDTO;
/**
* @type string
*/
status: string;
};
export type GetAIObservabilityFieldsValuesParams = {
/**
* @type string
* @description undefined
*/
searchText?: string;
/**
* @description undefined
*/
fieldContext?: TelemetrytypesFieldContextDTO;
/**
* @description undefined
*/
fieldDataType?: TelemetrytypesFieldDataTypeDTO;
/**
* @type integer
* @format int64
* @description undefined
*/
startUnixMilli?: number;
/**
* @type integer
* @format int64
* @description undefined
*/
endUnixMilli?: number;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type string
* @description undefined
*/
name?: string;
};
export type GetAIObservabilityFieldsValues200 = {
data: TelemetrytypesGettableFieldValuesDTO;
/**
* @type string
*/
status: string;
};
export type GetAlerts200 = {
/**
* @type array
@@ -10525,6 +10417,42 @@ export type CreatePublicDashboard201 = {
export type UpdatePublicDashboardPathParameters = {
id: string;
};
export type ListAuthDomains200 = {
/**
* @type array
*/
data: AuthtypesGettableAuthDomainDTO[];
/**
* @type string
*/
status: string;
};
export type CreateAuthDomain201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteAuthDomainPathParameters = {
id: string;
};
export type GetAuthDomainPathParameters = {
id: string;
};
export type GetAuthDomain200 = {
data: AuthtypesGettableAuthDomainDTO;
/**
* @type string
*/
status: string;
};
export type UpdateAuthDomainPathParameters = {
id: string;
};
export type ListDowntimeSchedulesParams = {
/**
* @type boolean,null
@@ -11160,42 +11088,6 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type ListAuthDomains200 = {
/**
* @type array
*/
data: AuthtypesGettableAuthDomainDTO[];
/**
* @type string
*/
status: string;
};
export type CreateAuthDomain201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteAuthDomainPathParameters = {
id: string;
};
export type GetAuthDomainPathParameters = {
id: string;
};
export type GetAuthDomain200 = {
data: AuthtypesGettableAuthDomainDTO;
/**
* @type string
*/
status: string;
};
export type UpdateAuthDomainPathParameters = {
id: string;
};
export type ListDashboardViews200 = {
data: DashboardtypesListableDashboardViewDTO;
/**

View File

@@ -8,19 +8,12 @@ import {
import ChangelogRenderer from '../components/ChangelogRenderer';
// Mock react-markdown to render children as plain text and a sample
// anchor through the `components.a` override
// Mock react-markdown to just render children as plain text
jest.mock(
'react-markdown',
() =>
function ReactMarkdown({ children, components }: any) {
const Anchor = components?.a;
return (
<div>
{children}
{Anchor && <Anchor href="https://signoz.io/docs">docs</Anchor>}
</div>
);
function ReactMarkdown({ children }: any) {
return <div>{children}</div>;
},
);
@@ -69,14 +62,4 @@ describe('ChangelogRenderer', () => {
expect(screen.getByAltText('Media')).toBeInTheDocument();
expect(screen.getByText('Description for feature 1')).toBeInTheDocument();
});
it('renders markdown links that open in a new tab', () => {
render(<ChangelogRenderer changelog={mockChangelog} />);
const links = screen.getAllByRole('link', { name: 'docs' });
expect(links.length).toBeGreaterThan(0);
links.forEach((link) => {
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
});
});

View File

@@ -13,19 +13,6 @@ interface Props {
changelog: ChangelogSchema;
}
interface LinkProps {
href?: string;
children?: React.ReactNode;
}
function Link({ href, children }: LinkProps): JSX.Element {
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
}
function renderMedia(media: Media): JSX.Element | null {
if (SupportedImageTypes.includes(media.ext)) {
return (
@@ -75,9 +62,7 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div key={feature.id}>
<div className="changelog-renderer-section-title">{feature.title}</div>
{feature.media && renderMedia(feature.media)}
<ReactMarkdown components={{ a: Link }}>
{feature.description}
</ReactMarkdown>
<ReactMarkdown>{feature.description}</ReactMarkdown>
</div>
))}
</div>
@@ -86,9 +71,7 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div className="changelog-renderer-bug-fixes">
<div className="changelog-renderer-section-title">Bug Fixes</div>
{changelog.bug_fixes && (
<ReactMarkdown components={{ a: Link }}>
{changelog.bug_fixes}
</ReactMarkdown>
<ReactMarkdown>{changelog.bug_fixes}</ReactMarkdown>
)}
</div>
)}
@@ -96,9 +79,7 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div className="changelog-renderer-maintenance">
<div className="changelog-renderer-section-title">Maintenance</div>
{changelog.maintenance && (
<ReactMarkdown components={{ a: Link }}>
{changelog.maintenance}
</ReactMarkdown>
<ReactMarkdown>{changelog.maintenance}</ReactMarkdown>
)}
</div>
)}

View File

@@ -41,7 +41,8 @@
.ant-drawer-body {
display: flex;
flex-direction: column;
padding: 0 16px;
padding: 16px;
padding-bottom: 0;
}
.title {
@@ -78,7 +79,6 @@
align-items: center;
gap: 4px;
position: relative;
margin-top: 16px;
.log-body {
font-family: 'SF Mono';
@@ -123,19 +123,6 @@
}
}
.log-detail-drawer__section-divider {
height: 8px;
margin: 12px 0;
background-image:
radial-gradient(circle, var(--l3-border) 1px, transparent 1px),
radial-gradient(circle, var(--l3-border) 1px, transparent 1px);
background-size: 6px 2px;
background-position:
left top,
left bottom;
background-repeat: repeat-x;
}
.tabs-and-search {
display: flex;
justify-content: space-between;

View File

@@ -11,16 +11,10 @@ jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
// DataViewer pulls in react-json-tree (ESM) + Monaco; mock it (as trace's tests
// do). These drawer tests assert the header/highlights, not the Overview body.
jest.mock('periscope/components/DataViewer', () => ({
__esModule: true,
DataViewer: (): JSX.Element => <div data-testid="overview-data-viewer" />,
}));
// Force v2 for these tests regardless of route.
jest.mock('../useIsLogDetailsV2', () => ({
useIsLogDetailsV2: (): boolean => true,
// The flag to be removed later
jest.mock('../constants', () => ({
...jest.requireActual('../constants'),
isLogDetailsV2: true,
}));
const mockLog: ILog = {
@@ -72,12 +66,6 @@ describe('LogDetail drawer — header (isLogDetailsV2)', () => {
expect(screen.getByTestId('log-details-header-next')).toBeInTheDocument();
});
it('renders the DataViewer in the Overview tab', () => {
renderDrawer();
expect(screen.getByTestId('overview-data-viewer')).toBeInTheDocument();
});
it('shows the log timestamp formatted (DASH_DATETIME) in the header', () => {
// Pin the timezone to UTC so the formatted output is deterministic across
// machines/CI (Jest doesn't fix a TZ).

View File

@@ -1,3 +1,10 @@
import getLocalStorage from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
// Temp feature flag before actual roll-out
export const isLogDetailsV2 =
getLocalStorage(LOCALSTORAGE.LOG_DETAILS_V2) === 'true';
export const VIEW_TYPES = {
OVERVIEW: 'OVERVIEW',
JSON: 'JSON',

View File

@@ -51,12 +51,11 @@ import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
import { isLogDetailsV2, RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
import LogHighlights from './LogHighlights/LogHighlights';
import { useIsLogDetailsV2 } from './useIsLogDetailsV2';
import './LogDetails.styles.scss';
@@ -93,8 +92,6 @@ function LogDetailInner({
const [isEdit, setIsEdit] = useState<boolean>(false);
const { stagedQuery } = useQueryBuilder();
const isLogDetailsV2 = useIsLogDetailsV2();
// Handle clicks outside to close drawer, except on explicitly ignored regions
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {
@@ -103,7 +100,6 @@ function LogDetailInner({
// Don't close if clicking on drawer content, overlays, or portal elements
if (
target.closest('[data-log-detail-ignore="true"]') ||
target.closest('.log-detail-drawer') ||
target.closest('.cm-tooltip-autocomplete') ||
target.closest('.drawer-popover') ||
target.closest('.query-status-popover') ||
@@ -406,8 +402,6 @@ function LogDetailInner({
{isLogDetailsV2 && <LogHighlights log={log} />}
{isLogDetailsV2 && <div className="log-detail-drawer__section-divider" />}
<div className="tabs-and-search">
<ToggleGroupSimple
type="single"
@@ -424,21 +418,15 @@ function LogDetailInner({
</div>
),
},
// V2's DataViewer has its own Pretty/JSON toggle, so the separate
// JSON tab is redundant.
...(isLogDetailsV2
? []
: [
{
value: VIEW_TYPES.JSON,
label: (
<div className="view-title">
<Braces size={14} />
JSON
</div>
),
},
]),
{
value: VIEW_TYPES.JSON,
label: (
<div className="view-title">
<Braces size={14} />
JSON
</div>
),
},
{
value: VIEW_TYPES.CONTEXT,
label: (
@@ -521,7 +509,7 @@ function LogDetailInner({
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
{!isLogDetailsV2 && selectedView === VIEW_TYPES.JSON && (
{selectedView === VIEW_TYPES.JSON && (
<JsonView data={LogJsonData} height="68vh" />
)}

View File

@@ -1,9 +0,0 @@
import ROUTES from 'constants/routes';
import { useLocation } from 'react-router-dom';
// v2 is rolled out only on the logs explorer route for now; every other surface
// (dashboards, infra monitoring, etc.) keeps the v1 log details view.
export function useIsLogDetailsV2(): boolean {
const { pathname } = useLocation();
return pathname === ROUTES.LOGS_EXPLORER;
}

View File

@@ -107,38 +107,30 @@ async function renderAndFocus(
return editor;
}
// Re-requests completions while waiting: typing and the async fetches can close the popup.
function waitForRecents(
assertLabels: (labels: string[]) => void,
): Promise<void> {
function ensureCompletionOpen(): void {
const view = getEditorView();
if (view && !isCompletionOpen()) {
startCompletion(view);
}
}
function openRecents(): Promise<void> {
return waitFor(
() => {
const view = getEditorView();
if (view && !isCompletionOpen()) {
startCompletion(view);
}
assertLabels(getRecentLabels());
ensureCompletionOpen();
expect(getRecentLabels().length).toBeGreaterThan(0);
},
{ timeout: 3000 },
);
}
function openRecents(): Promise<void> {
return waitForRecents((labels) => expect(labels.length).toBeGreaterThan(0));
}
function waitForPopupElement(
find: () => HTMLElement | null | undefined,
): Promise<HTMLElement> {
// userEvent.type can close the popup (jsdom blur / closeOnBlur). Re-open on each
// retry so we assert filtered recents, not "dropdown still closed".
function waitForRecentLabels(expected: string[]): Promise<void> {
return waitFor(
() => {
const view = getEditorView();
if (view && !isCompletionOpen()) {
startCompletion(view);
}
const node = find();
expect(node).toBeTruthy();
return node as HTMLElement;
ensureCompletionOpen();
expect(getRecentLabels()).toStrictEqual(expected);
},
{ timeout: 3000 },
);
@@ -154,11 +146,7 @@ describe('QuerySearch recent searches', () => {
saveLogsRecent(FRONTEND_FILTER);
await renderAndFocus();
await openRecents();
await waitForRecents((labels) =>
expect(labels).toStrictEqual([FRONTEND_FILTER]),
);
await waitForRecentLabels([FRONTEND_FILTER]);
const view = getEditorView() as EditorView;
const [recent] = currentCompletions(view.state);
@@ -172,10 +160,7 @@ describe('QuerySearch recent searches', () => {
const editor = await renderAndFocus();
await openRecents();
await userEvent.type(editor, 'status_code');
await waitForRecents((labels) =>
expect(labels).toStrictEqual([STATUS_CODE_FILTER]),
);
await waitForRecentLabels([STATUS_CODE_FILTER]);
});
it('does not surface recents saved under a different signal', async () => {
@@ -186,11 +171,7 @@ describe('QuerySearch recent searches', () => {
saveLogsRecent(FRONTEND_FILTER);
await renderAndFocus();
await openRecents();
await waitForRecents((labels) =>
expect(labels).toStrictEqual([FRONTEND_FILTER]),
);
await waitForRecentLabels([FRONTEND_FILTER]);
});
it('excludes a recent that exactly matches the current editor text', async () => {
@@ -201,10 +182,7 @@ describe('QuerySearch recent searches', () => {
const editor = await renderAndFocus();
await openRecents();
await userEvent.type(editor, FRONTEND_FILTER);
await waitForRecents((labels) =>
expect(labels).toStrictEqual([supersetFilter]),
);
await waitForRecentLabels([supersetFilter]);
});
it('caps the dropdown at RECENTS_DISPLAY_CAP entries, newest first', async () => {
@@ -216,11 +194,7 @@ describe('QuerySearch recent searches', () => {
const expectedLabels = [...filters].reverse().slice(0, RECENTS_DISPLAY_CAP);
await renderAndFocus();
await openRecents();
await waitForRecents((labels) =>
expect(labels).toStrictEqual(expectedLabels),
);
await waitForRecentLabels(expectedLabels);
});
it('applies the full expression to the editor when a recent is clicked', async () => {
@@ -230,13 +204,22 @@ describe('QuerySearch recent searches', () => {
await renderAndFocus(onChange);
await openRecents();
const option = await waitForPopupElement(() =>
Array.from(
document.querySelectorAll<HTMLElement>(COMPLETION_LABEL_SELECTOR),
).find((element) => element.textContent === FRONTEND_FILTER),
// Locate and mousedown in one synchronous waitFor pass. userEvent.click awaits
// between pointer events, leaving gaps where the completion can close (its
// tooltip mousedown handler then throws on a null `open`), and its selection
// handling needs Range APIs the CodeMirror DOM mocks don't provide. CodeMirror
// applies the completion on mousedown alone.
await waitFor(
() => {
ensureCompletionOpen();
const node = Array.from(
document.querySelectorAll<HTMLElement>(COMPLETION_LABEL_SELECTOR),
).find((element) => element.textContent === FRONTEND_FILTER);
expect(node).toBeDefined();
fireEvent.mouseDown(node as HTMLElement);
},
{ timeout: 3000 },
);
// fireEvent: userEvent's pointerdown blurs the editor, closing the popup before CM's mousedown apply.
fireEvent.mouseDown(option);
await waitFor(
() => {
@@ -261,8 +244,13 @@ describe('QuerySearch recent searches', () => {
await renderAndFocus();
await openRecents();
const deleteButton = await waitForPopupElement(() =>
document.querySelector<HTMLElement>(DELETE_BUTTON_SELECTOR),
const deleteButton = await waitFor(
() => {
const button = document.querySelector(DELETE_BUTTON_SELECTOR);
expect(button).toBeInTheDocument();
return button as HTMLElement;
},
{ timeout: 3000 },
);
// fireEvent: the button preventDefaults pointerdown, which makes userEvent.click drop the mouse chain.

View File

@@ -1,7 +1,4 @@
import {
listRolesSuccessResponse,
managedRoles,
} from 'mocks-server/__mockdata__/roles';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
@@ -21,24 +18,17 @@ const ROLES_ENDPOINT = '*/api/v1/roles';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_DELETE_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_ROLES_ENDPOINT = '*/api/v1/service_account_roles';
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_account_roles/:id';
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_accounts/:id/roles/:rid';
const activeAccountResponse = {
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
serviceAccountRoles: [
{
id: 'sar-admin-1',
serviceAccountId: 'sa-1',
roleId: managedRoles[0].id,
role: { ...managedRoles[0], transactionGroups: [] },
},
],
};
function renderDrawer(
@@ -68,13 +58,22 @@ function setupBaseHandlers(): void {
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
ctx.status(200),
ctx.json({
data: listRolesSuccessResponse.data.filter(
(r) => r.name === 'signoz-admin',
),
}),
),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => res(ctx.status(204))),
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
);
}

View File

@@ -1,7 +1,4 @@
import {
listRolesSuccessResponse,
managedRoles,
} from 'mocks-server/__mockdata__/roles';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
@@ -13,33 +10,23 @@ const ROLES_ENDPOINT = '*/api/v1/roles';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_DELETE_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_ROLES_ENDPOINT = '*/api/v1/service_account_roles';
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_account_roles/:id';
const ADMIN_ASSIGNMENT_ID = 'sar-admin-1';
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_accounts/:id/roles/:rid';
const activeAccountResponse = {
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
serviceAccountRoles: [
{
id: ADMIN_ASSIGNMENT_ID,
serviceAccountId: 'sa-1',
roleId: managedRoles[0].id,
role: { ...managedRoles[0], transactionGroups: [] },
},
],
};
const deletedAccountResponse = {
...activeAccountResponse,
id: 'sa-2',
status: 'DELETED',
serviceAccountRoles: [],
};
function renderDrawer(
@@ -71,13 +58,22 @@ describe('ServiceAccountDrawer', () => {
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
ctx.status(200),
ctx.json({
data: listRolesSuccessResponse.data.filter(
(r) => r.name === 'signoz-admin',
),
}),
),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => res(ctx.status(204))),
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
setupAuthzAdmin(),
);
});
@@ -140,14 +136,11 @@ describe('ServiceAccountDrawer', () => {
server.use(
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
roleSpy(await req.json());
return res(
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
);
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => {
deleteSpy();
return res(ctx.status(204));
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
@@ -166,8 +159,7 @@ describe('ServiceAccountDrawer', () => {
await waitFor(() => {
expect(roleSpy).toHaveBeenCalledWith(
expect.objectContaining({
serviceAccountId: 'sa-1',
roleId: '019c24aa-2248-7585-a129-4188b3473c27',
id: '019c24aa-2248-7585-a129-4188b3473c27',
}),
);
expect(deleteSpy).not.toHaveBeenCalled();
@@ -182,14 +174,11 @@ describe('ServiceAccountDrawer', () => {
server.use(
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
roleSpy(await req.json());
return res(
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
);
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (req, res, ctx) => {
deleteSpy(req.params.id);
return res(ctx.status(204));
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => {
deleteSpy();
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
@@ -209,7 +198,7 @@ describe('ServiceAccountDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(deleteSpy).toHaveBeenCalledWith(ADMIN_ASSIGNMENT_ID);
expect(deleteSpy).toHaveBeenCalled();
expect(roleSpy).not.toHaveBeenCalled();
});
});
@@ -257,6 +246,9 @@ describe('ServiceAccountDrawer', () => {
rest.get('*/api/v1/service_accounts/sa-2/keys', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get('*/api/v1/service_accounts/sa-2/roles', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
);
renderDrawer({ account: 'sa-2' });
@@ -320,13 +312,22 @@ describe('ServiceAccountDrawer save-error UX', () => {
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
ctx.status(200),
ctx.json({
data: listRolesSuccessResponse.data.filter(
(r) => r.name === 'signoz-admin',
),
}),
),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => res(ctx.status(204))),
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
setupAuthzAdmin(),
);
});
@@ -409,17 +410,14 @@ describe('ServiceAccountDrawer save-error UX', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let roleAddCallCount = 0;
// First call → 429, second call → 201
// First call → 429, second call → 200
server.use(
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) => {
roleAddCallCount += 1;
if (roleAddCallCount === 1) {
return res(ctx.status(429), ctx.json({ message: 'Too Many Requests' }));
}
return res(
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
);
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);

View File

@@ -13,6 +13,7 @@ export enum LOCALSTORAGE {
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOG_DETAILS_V2 = 'LOG_DETAILS_V2',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
LOGGED_IN_USER_EMAIL = 'LOGGED_IN_USER_EMAIL',
CHAT_SUPPORT = 'CHAT_SUPPORT',

View File

@@ -9,7 +9,6 @@ const fieldContextToSuggestionMap: Record<
[TelemetrytypesFieldContextDTO.span]: 'span',
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
// no maps for the following values on suggestion context
[TelemetrytypesFieldContextDTO.trace]: undefined,
[TelemetrytypesFieldContextDTO.body]: undefined,
[TelemetrytypesFieldContextDTO.metric]: undefined,
[TelemetrytypesFieldContextDTO.log]: undefined,

View File

@@ -1,91 +0,0 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import useUpdatedQuery from '../useResolveQuery';
const mockGetSubstituteVars = jest.fn();
const mockDynamicVariables: unknown[] = [];
jest.mock('api/dashboard/substitute_vars', () => ({
getSubstituteVars: (...args: unknown[]): unknown =>
mockGetSubstituteVars(...args),
}));
jest.mock('api/v5/v5', () => ({
prepareQueryRangePayloadV5: (): { queryPayload: unknown } => ({
queryPayload: { start: 0, end: 1 },
}),
}));
jest.mock(
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
() => ({
mapQueryDataFromApi: (): Query => ({ resolved: true }) as unknown as Query,
}),
);
jest.mock('hooks/dashboard/useDashboardVariablesByType', () => ({
useDashboardVariablesByType: (): unknown[] => mockDynamicVariables,
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (): unknown => ({
selectedTime: 'GLOBAL_TIME',
}),
}));
const QUERY = { builder: { queryData: [] } } as unknown as Query;
const WIDGET_CONFIG = {
query: QUERY,
panelTypes: PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME' as const,
};
describe('useResolveQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDynamicVariables.length = 0;
});
it('skips the substitute_vars round-trip when there are no variables', async () => {
const { result } = renderHook(() => useUpdatedQuery(), {
wrapper: MockQueryClientProvider,
});
const resolved = await result.current.getUpdatedQuery({
widgetConfig: WIDGET_CONFIG,
});
expect(mockGetSubstituteVars).not.toHaveBeenCalled();
expect(resolved).toBe(QUERY);
});
it('resolves through substitute_vars when the dashboard has variables', async () => {
mockGetSubstituteVars.mockResolvedValue({
httpStatusCode: 200,
data: { compositeQuery: {} },
});
const { result } = renderHook(() => useUpdatedQuery(), {
wrapper: MockQueryClientProvider,
});
const resolved = await result.current.getUpdatedQuery({
widgetConfig: WIDGET_CONFIG,
dashboardData: {
data: {
variables: {
env: { name: 'env', selectedValue: 'prod' },
},
},
},
});
expect(mockGetSubstituteVars).toHaveBeenCalledTimes(1);
expect(resolved).toStrictEqual({ resolved: true });
});
});

View File

@@ -2,7 +2,6 @@ import { useCallback } from 'react';
import { useMutation } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { isEmpty } from 'lodash-es';
import { getSubstituteVars } from 'api/dashboard/substitute_vars';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -47,21 +46,13 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
widgetConfig,
dashboardData,
}: UseUpdatedQueryOptions): Promise<Query> => {
const variables = getDashboardVariables(dashboardData?.data?.variables);
// `/substitute_vars` only rewrites `$variable` references, so on surfaces with no
// dashboard behind them (APM, Celery, API monitoring) the round-trip is a no-op.
if (isEmpty(variables) && isEmpty(dashboardDynamicVariables)) {
return widgetConfig.query;
}
// Prepare query payload with resolved variables
const { queryPayload } = prepareQueryRangePayloadV5({
query: widgetConfig.query,
graphType: getGraphType(widgetConfig.panelTypes),
selectedTime: widgetConfig.timePreferance,
globalSelectedInterval,
variables,
variables: getDashboardVariables(dashboardData?.data?.variables),
originalGraphType: widgetConfig.panelTypes,
dynamicVariables: dashboardDynamicVariables,
});

View File

@@ -139,6 +139,11 @@ export default function AlertRules({
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = record.condition.compositeQuery.panelType;
if (panelType) {
params.set(QueryParams.panelTypes, panelType);
}
params.set(QueryParams.ruleId, record.id);
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);

View File

@@ -51,5 +51,4 @@
gap: var(--spacing-3);
max-width: 500px;
padding: 24px;
max-height: 100%;
}

View File

@@ -37,7 +37,7 @@ import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferences
import styles from './K8sExpandedRow.module.scss';
import { buildExpressionFromGroupMeta } from './utils';
import { logInfraColumnSortedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { getUnstableCurrentSearchParams } from 'container/TopNav/DateTimeSelectionV2/utils/getUnstableCurrentSearchParams';
import { QueryParams } from 'constants/query';
const EXPANDED_ROW_LIMIT = 10;

View File

@@ -9,11 +9,7 @@ function Overview(): JSX.Element {
return (
<div className={styles.overview} data-testid="llm-observability-overview">
<DashboardContainer
dashboard={dashboard}
refetch={refetch}
canEditDashboardOverride={false}
/>
<DashboardContainer dashboard={dashboard} refetch={refetch} />
</div>
);
}

View File

@@ -1,7 +1,7 @@
{
"id": "llm-observability-overview",
"orgId": "",
"locked": false,
"locked": true,
"name": "AI Observability Overview",
"schemaVersion": "v6",
"source": "system",
@@ -1146,4 +1146,4 @@
}
]
}
}
}

View File

@@ -26,7 +26,7 @@ describe('ListAlertRules — row click navigation', () => {
const [url] = safeNavigateMock.mock.calls[0];
expect(url).toContain('/alerts/overview?');
expect(url).toContain('ruleId=rule-1');
expect(url).not.toContain('panelTypes');
expect(url).toContain('panelTypes=graph');
expect(url).toContain('compositeQuery=');
});

View File

@@ -36,6 +36,11 @@ export function useAlertRulesHandlers(
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = rule.condition.compositeQuery.panelType;
if (panelType) {
params.set(QueryParams.panelTypes, panelType);
}
params.set(QueryParams.ruleId, rule.id);
return `${ROUTES.ALERT_OVERVIEW}?${params.toString()}`;

View File

@@ -2806,8 +2806,8 @@ export const hostWidgetInfo = [
'The 1m, 5m and 15m load averages together; 1m above 15m means load is building.',
},
{
title: 'Network usage',
yAxisUnit: 'binBps',
title: 'Network usage (bytes)',
yAxisUnit: 'bytes',
docPath: '/infrastructure-monitoring/host-monitoring/#network-usage-bytes',
description:
'Throughput in bytes/s per interface and direction, to spot NICs nearing rated bandwidth.',
@@ -2841,8 +2841,8 @@ export const hostWidgetInfo = [
'Active connection counts per protocol and state (ESTABLISHED, TIME_WAIT, SYN_RECV) to spot leaks and churn.',
},
{
title: 'System disk IO',
yAxisUnit: 'binBps',
title: 'System disk io (bytes transferred)',
yAxisUnit: 'bytes',
docPath: '/infrastructure-monitoring/host-monitoring/#system-disk-io-bytes',
description:
'Disk throughput in bytes/s per device and direction, tracking heavy file I/O or database flushes.',

View File

@@ -1,21 +1,4 @@
.overview-container {
.data-viewer {
min-height: 500px;
}
.pretty-view__search-wrapper {
background: var(--l2-background);
}
.pretty-view__search-input {
background: var(--l2-background) !important;
}
.log-body-value {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.tag {
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);

View File

@@ -13,28 +13,15 @@ import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ChevronDown, ChevronRight, Search } from '@signozhq/icons';
import { useIsLogDetailsV2 } from 'components/LogDetail/useIsLogDetailsV2';
import { DataViewer } from 'periscope/components/DataViewer';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { ActionItemProps } from './ActionItem';
import { useLogAttributeActions } from './hooks/useLogAttributeActions';
import TableView from './TableView';
import {
aggregateAttributesResourcesToObject,
buildPrettyViewData,
getBodyDisplayString,
getSanitizedLogBody,
removeEscapeCharacters,
} from './utils';
import { getBodyDisplayString, removeEscapeCharacters } from './utils';
import './Overview.styles.scss';
// Skip body sanitization above this size. sanitization is expensive and fails
// for large bodies
const MAX_BODY_SANITIZE_CHARS = 64 * 1024;
interface OverviewProps {
logData: ILog;
isListViewPanel?: boolean;
@@ -64,54 +51,6 @@ function Overview({
const isDarkMode = useIsDarkMode();
const { actions, visibleActions } = useLogAttributeActions({
handleChangeSelectedView,
isListViewPanel,
});
const isLogDetailsV2 = useIsLogDetailsV2();
if (isLogDetailsV2) {
const raw = aggregateAttributesResourcesToObject(logData);
const prettyData = buildPrettyViewData(raw);
return (
<div className="overview-container">
<DataViewer
data={prettyData}
drawerKey="logs-details"
fontSize={13}
prettyViewProps={{
actions,
visibleActions,
renderLeafValue: (value, keyPath): ReactNode | undefined => {
// Sanitize (unescape + ANSI→color) string values under `body`.
// Skip huge ones (render raw, still safe) to avoid the sanitize
// choke;
if (
typeof value !== 'string' ||
keyPath[keyPath.length - 1] !== 'body' ||
value.length > MAX_BODY_SANITIZE_CHARS
) {
return undefined;
}
return (
<span
className="log-body-value"
// Safe: getSanitizedLogBody runs the value through dompurify.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(value, { shouldEscapeHtml: true }),
}}
/>
);
},
}}
jsonString={JSON.stringify(raw, null, 2)}
/>
</div>
);
}
const options: EditorProps['options'] = {
automaticLayout: true,
readOnly: true,

View File

@@ -1,13 +0,0 @@
export enum LogAttributeBucket {
ATTRIBUTES = 'attributes',
RESOURCES = 'resource',
SCOPE = 'scope',
}
export enum LogDetailsAction {
COPY = 'copy',
FILTER_IN = 'filter-in',
FILTER_OUT = 'filter-out',
GROUP_BY = 'group-by',
REPLACE_FILTER = 'replace-filter',
}

View File

@@ -1,232 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import {
FieldContext,
PrettyViewAction,
VisibleActionsConfig,
} from 'periscope/components/PrettyView/PrettyView';
import { useAppContext } from 'providers/App/App';
import { LogDetailsAction } from '../constants';
import {
buildLogFilterTarget,
getFilterQueryData,
getGroupByQueryData,
getReplaceFilterQueryData,
} from '../logAttributeActions.utils';
interface UseLogAttributeActionsParams {
handleChangeSelectedView?: ChangeViewFunctionType;
isListViewPanel?: boolean;
}
interface UseLogAttributeActionsResult {
actions: PrettyViewAction[];
visibleActions: VisibleActionsConfig;
}
const COPY_ONLY_ACTIONS = [LogDetailsAction.COPY];
const ALL_LEAF_ACTIONS = [
LogDetailsAction.COPY,
LogDetailsAction.FILTER_IN,
LogDetailsAction.FILTER_OUT,
LogDetailsAction.GROUP_BY,
LogDetailsAction.REPLACE_FILTER,
];
/**
* PrettyView filter/group-by/replace actions for the log-details drawer (keys mapped via
* buildLogFilterTarget). Also owns `visibleActions` (leaf/nested + list-panel copy-only).
*/
export function useLogAttributeActions({
handleChangeSelectedView,
isListViewPanel = false,
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
const { pathname } = useLocation();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const { featureFlags } = useAppContext();
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const isBodyJsonQueryEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
const isOldExplorerOrLive =
pathname === ROUTES.OLD_LOGS_EXPLORER || pathname === ROUTES.LIVE_LOGS;
const filterFor = useCallback(
(context: FieldContext, isFilterIn: boolean): void => {
if (!stagedQuery) {
return;
}
const target = buildLogFilterTarget(
context.fieldKeyPath,
context.fieldValue,
isBodyJsonQueryEnabled,
);
const operator = isFilterIn
? target.filterInOperator
: target.filterOutOperator;
const updatedQuery = updateQueriesData(
stagedQuery,
'queryData',
(item, index) =>
index === 0
? getFilterQueryData(item, target, context.fieldValue, operator)
: item,
);
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
},
[
stagedQuery,
isBodyJsonQueryEnabled,
updateQueriesData,
viewName,
handleChangeSelectedView,
],
);
const groupBy = useCallback(
(context: FieldContext): void => {
if (!stagedQuery) {
return;
}
const target = buildLogFilterTarget(
context.fieldKeyPath,
context.fieldValue,
isBodyJsonQueryEnabled,
);
if (!target.groupBySupported || !target.groupByKey) {
return;
}
const updatedQuery = updateQueriesData(
stagedQuery,
'queryData',
(item, index) => (index === 0 ? getGroupByQueryData(item, target) : item),
);
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.TIMESERIES, queryData);
},
[
stagedQuery,
isBodyJsonQueryEnabled,
updateQueriesData,
viewName,
handleChangeSelectedView,
],
);
const replaceFilter = useCallback(
(context: FieldContext): void => {
if (!stagedQuery) {
return;
}
const target = buildLogFilterTarget(
context.fieldKeyPath,
context.fieldValue,
isBodyJsonQueryEnabled,
);
const updatedQuery = updateQueriesData(
stagedQuery,
'queryData',
(item, index) =>
index === 0
? getReplaceFilterQueryData(item, target, context.fieldValue)
: item,
);
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
},
[
stagedQuery,
isBodyJsonQueryEnabled,
updateQueriesData,
viewName,
handleChangeSelectedView,
],
);
const actions: PrettyViewAction[] = useMemo(() => {
const isRestricted = (fieldKeyPath: (string | number)[]): boolean =>
buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.isRestricted;
return [
{
key: LogDetailsAction.FILTER_IN,
label: 'Filter for value',
icon: <CirclePlus size={12} />,
onClick: (context): void => filterFor(context, true),
shouldHide: (_key, fieldKeyPath): boolean => isRestricted(fieldKeyPath),
},
{
key: LogDetailsAction.FILTER_OUT,
label: 'Filter out value',
icon: <CircleMinus size={12} />,
onClick: (context): void => filterFor(context, false),
shouldHide: (_key, fieldKeyPath): boolean => isRestricted(fieldKeyPath),
},
{
key: LogDetailsAction.GROUP_BY,
label: 'Group by field',
icon: <Layers size={12} />,
onClick: groupBy,
shouldHide: (_key, fieldKeyPath): boolean =>
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.groupBySupported || isOldExplorerOrLive,
},
{
key: LogDetailsAction.REPLACE_FILTER,
label: 'Replace filters with this value',
icon: <RefreshCw size={12} />,
onClick: replaceFilter,
shouldHide: (_key, fieldKeyPath): boolean =>
isRestricted(fieldKeyPath) || isOldExplorerOrLive,
},
];
}, [
filterFor,
groupBy,
replaceFilter,
isBodyJsonQueryEnabled,
isOldExplorerOrLive,
]);
const visibleActions = useMemo<VisibleActionsConfig>(
() => ({
leaf: isListViewPanel ? COPY_ONLY_ACTIONS : ALL_LEAF_ACTIONS,
nested: COPY_ONLY_ACTIONS,
}),
[isListViewPanel],
);
return { actions, visibleActions };
}

View File

@@ -1,246 +0,0 @@
import { MetricsType } from 'container/MetricsApplication/constant';
import {
buildLogFilterTarget,
toTypedFilterValue,
} from './logAttributeActions.utils';
describe('buildLogFilterTarget', () => {
describe('attributes / resources / scope / top-level scalars', () => {
it('maps a top-level scalar to its bare key with =/!=, groupable', () => {
const t = buildLogFilterTarget(['severity_text'], 'ERROR', true);
expect(t).toMatchObject({
fieldKey: 'severity_text',
filterInOperator: '=',
filterOutOperator: '!=',
groupBySupported: true,
groupByKey: 'severity_text',
isRestricted: false,
});
expect(t.metricsType).toBeUndefined();
});
it('strips the `attributes` root to a bare dotted key + Tag type', () => {
expect(
buildLogFilterTarget(['attributes', 'http.method'], 'GET', true),
).toMatchObject({
fieldKey: 'http.method',
filterInOperator: '=',
metricsType: MetricsType.Tag,
groupBySupported: true,
});
});
it('maps `resources` with Resource type', () => {
expect(
buildLogFilterTarget(['resource', 'service.name'], 'api', true),
).toMatchObject({
fieldKey: 'service.name',
metricsType: MetricsType.Resource,
});
});
it('maps `scope` with Scope type', () => {
expect(
buildLogFilterTarget(['scope', 'name'], 'my-scope', true),
).toMatchObject({ fieldKey: 'name', metricsType: MetricsType.Scope });
});
it('offers group-by for attributes now', () => {
expect(
buildLogFilterTarget(['attributes', 'k'], 'v', true).groupBySupported,
).toBe(true);
});
});
describe('nested attribute values (parsed JSON)', () => {
it('marks a sub-field of a parsed attribute copy-only (restricted, no group-by)', () => {
const t = buildLogFilterTarget(['attributes', 'payload', 'x'], 1, true);
expect(t.isRestricted).toBe(true);
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
});
it('leaves a top-level attribute (depth 2) filterable', () => {
const t = buildLogFilterTarget(['attributes', 'payload'], 'v', true);
expect(t.isRestricted).toBe(false);
expect(t.groupBySupported).toBe(true);
});
it('does not restrict nested resource/scope values', () => {
expect(
buildLogFilterTarget(['resource', 'k8s', 'pod'], 'p', true).isRestricted,
).toBe(false);
expect(
buildLogFilterTarget(['scope', 'a', 'b'], 'v', true).isRestricted,
).toBe(false);
});
});
describe('restricted fields (timestamp / id)', () => {
it.each(['timestamp', 'id'])(
'marks %s restricted with no group-by',
(key) => {
const t = buildLogFilterTarget([key], 'v', true);
expect(t.isRestricted).toBe(true);
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
},
);
});
describe('group-by-restricted fields (trace_id)', () => {
it('allows filtering but not group-by on top-level trace_id', () => {
const t = buildLogFilterTarget(['trace_id'], 'abc123', true);
expect(t.isRestricted).toBe(false);
expect(t.filterInOperator).toBe('=');
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
});
it.each([
['resource', ['resource', 'trace_id']],
['attributes', ['attributes', 'trace_id']],
])(
'blocks group-by on a %s field named trace_id, keeping filter',
(_bucket, path) => {
const t = buildLogFilterTarget(path as string[], 'abc123', true);
expect(t.isRestricted).toBe(false);
expect(t.filterInOperator).toBe('=');
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
},
);
});
describe('body scalars', () => {
it('maps a top-level body scalar to body.<key> with =/!=, groupable when json body on', () => {
const t = buildLogFilterTarget(['body', 'message'], 'hello', true);
expect(t).toMatchObject({
fieldKey: 'body.message',
filterInOperator: '=',
filterOutOperator: '!=',
groupBySupported: true,
groupByKey: 'body.message',
isRestricted: false,
});
expect(t.dataType).toBeDefined();
expect(t.metricsType).toBeUndefined();
});
it('maps a nested body scalar to a dotted body key', () => {
expect(buildLogFilterTarget(['body', 'a', 'b'], 'x', true)).toMatchObject({
fieldKey: 'body.a.b',
groupBySupported: true,
groupByKey: 'body.a.b',
});
});
it('does not offer group-by when USE_JSON_BODY is off', () => {
const t = buildLogFilterTarget(['body', 'message'], 'hello', false);
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
expect(t.fieldKey).toBe('body.message');
});
it('filters the whole `body` field when body is an unparsed string leaf', () => {
expect(buildLogFilterTarget(['body'], 'raw text', true)).toMatchObject({
fieldKey: 'body',
filterInOperator: '=',
groupBySupported: false,
});
});
it('restricts a body leaf named `timestamp` (no filter / group-by)', () => {
const t = buildLogFilterTarget(['body', 'timestamp'], '2026-01-01', true);
expect(t.fieldKey).toBe('body.timestamp');
expect(t.isRestricted).toBe(true);
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
});
it('restricts a nested body leaf named `timestamp`', () => {
const t = buildLogFilterTarget(['body', 'obj', 'timestamp'], 'x', true);
expect(t.isRestricted).toBe(true);
expect(t.groupBySupported).toBe(false);
});
it('restricts a body leaf named `id` (uses RESTRICTED_SELECTED_FIELDS)', () => {
expect(buildLogFilterTarget(['body', 'id'], 'abc', true).isRestricted).toBe(
true,
);
});
it('does not restrict an ordinary body leaf', () => {
expect(
buildLogFilterTarget(['body', 'message'], 'hello', true).isRestricted,
).toBe(false);
});
});
describe('body arrays', () => {
it('uses has()/!has() on the array key for a primitive array element', () => {
const t = buildLogFilterTarget(['body', 'tags', 0], 'urgent', true);
expect(t).toMatchObject({
fieldKey: 'body.tags',
filterInOperator: 'has',
groupBySupported: false,
});
expect(t.filterOutOperator).toContain('has');
expect(t.filterOutOperator).not.toBe('has');
});
it('collapses deep array-element paths to a []-marked has() key', () => {
expect(
buildLogFilterTarget(
['body', 'config', 'features', 1, 'items', 0, 'variants', 2],
'ballpen',
true,
),
).toMatchObject({
fieldKey: 'body.config.features[].items[].variants',
filterInOperator: 'has',
});
});
it('maps a field nested inside an array element with =/!= and a []-marked key, no group-by', () => {
expect(
buildLogFilterTarget(['body', 'items', 2, 'sku'], 'ABC', true),
).toMatchObject({
fieldKey: 'body.items[].sku',
filterInOperator: '=',
filterOutOperator: '!=',
groupBySupported: false,
});
});
it('uses the [*] string-body marker for an array element when USE_JSON_BODY is off', () => {
expect(
buildLogFilterTarget(['body', 'tags', 0], 'urgent', false),
).toMatchObject({ fieldKey: 'body.tags[*]', filterInOperator: 'has' });
});
it('uses [*] for a field nested inside an array element when USE_JSON_BODY is off', () => {
expect(
buildLogFilterTarget(['body', 'items', 2, 'sku'], 'ABC', false),
).toMatchObject({ fieldKey: 'body.items[*].sku', filterInOperator: '=' });
});
});
});
describe('toTypedFilterValue', () => {
const run = (value: unknown): unknown => toTypedFilterValue(value);
it('keeps numbers/booleans as their JS type (so the expression stays unquoted)', () => {
expect(run(848)).toBe(848);
expect(typeof run(848)).toBe('number');
expect(run(1.1)).toBe(1.1);
expect(run(true)).toBe(true);
expect(typeof run(true)).toBe('boolean');
});
it('passes strings through unchanged (no numeric inference)', () => {
expect(run('unknown_service')).toBe('unknown_service');
expect(typeof run('12345')).toBe('string');
});
});

View File

@@ -1,241 +0,0 @@
import { v4 as uuid } from 'uuid';
import {
negateOperator,
OPERATORS,
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { OPERATORS as QUERY_BUILDER_OPERATORS } from 'constants/queryBuilder';
import {
RESTRICTED_GROUP_BY_FIELDS,
RESTRICTED_SELECTED_FIELDS,
} from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { LogAttributeBucket } from './constants';
import { generateFieldKeyForArray, getDataTypes } from './utils';
export const toTypedFilterValue = (value: unknown): string =>
typeof value === 'number' || typeof value === 'boolean'
? (value as unknown as string)
: String(value);
export interface LogFilterTarget {
fieldKey: string;
filterInOperator: string;
filterOutOperator: string;
dataType?: DataTypes;
metricsType?: MetricsType;
groupBySupported: boolean;
groupByKey?: string;
isRestricted: boolean;
}
// Collapse a body forward path into the query-builder key segment; array indices become
// `[]` (json body on) or `[*]` (string body off — a distinct operator/search path).
// ['items', 2, 'sku'] -> 'items[].sku' (json on) / 'items[*].sku' (off)
// ['tags', 0] -> 'tags[]' (json on) / 'tags[*]' (off)
const collapseBodyPath = (
subpath: (string | number)[],
isBodyJsonQueryEnabled: boolean,
): string => {
const arrayMarker = isBodyJsonQueryEnabled ? '[]' : '[*]';
let out = '';
subpath.forEach((seg) => {
if (typeof seg === 'number') {
out += arrayMarker;
} else {
out += out ? `.${seg}` : seg;
}
});
return out;
};
const metricsTypeForRoot = (root: string | number): MetricsType | undefined => {
if (root === LogAttributeBucket.ATTRIBUTES) {
return MetricsType.Tag;
}
if (root === LogAttributeBucket.RESOURCES) {
return MetricsType.Resource;
}
if (root === LogAttributeBucket.SCOPE) {
return MetricsType.Scope;
}
return undefined;
};
/**
* Map a PrettyView leaf (forward keyPath) to its query-builder filter/group-by target:
* scalars → `=`/`!=`, body array elements → `has`/`!has`; group-by gated by
* `groupBySupported`. Attribute/resource/scope carry a `metricsType` (Tag/Resource/Scope).
*/
export const buildLogFilterTarget = (
fieldKeyPath: (string | number)[],
value: unknown,
isBodyJsonQueryEnabled: boolean,
): LogFilterTarget => {
const root = fieldKeyPath[0];
// Attributes / resources / scope / top-level scalars: bare dotted key, =/!=.
if (root !== 'body') {
const fieldKey =
fieldKeyPath.length > 1 ? fieldKeyPath.slice(1).join('.') : String(root);
// Temporarily removing filter/group-by support for nested attributes.
// This will be removed once backend starts to support these actions.
const isNestedAttributeValue =
root === LogAttributeBucket.ATTRIBUTES && fieldKeyPath.length > 2;
const isRestricted =
RESTRICTED_SELECTED_FIELDS.includes(fieldKey) || isNestedAttributeValue;
const groupBySupported =
!isRestricted && !RESTRICTED_GROUP_BY_FIELDS.includes(fieldKey);
return {
fieldKey,
filterInOperator: OPERATORS['='],
filterOutOperator: OPERATORS['!='],
dataType: getDataTypes(value),
metricsType: metricsTypeForRoot(root),
groupBySupported,
groupByKey: groupBySupported ? fieldKey : undefined,
isRestricted,
};
}
const subpath = fieldKeyPath.slice(1);
// Whole-body leaf (unparsed string body): filter on the `body` field itself.
if (subpath.length === 0) {
return {
fieldKey: 'body',
filterInOperator: OPERATORS['='],
filterOutOperator: OPERATORS['!='],
dataType: getDataTypes(value),
groupBySupported: false,
isRestricted: false,
};
}
const collapsed = collapseBodyPath(subpath, isBodyJsonQueryEnabled);
const isArrayElement = typeof subpath[subpath.length - 1] === 'number';
if (isArrayElement) {
// has(body.<array>, value): generateFieldKeyForArray strips the trailing value
// segment + `[]` exactly as the old BodyTitleRenderer.filterHandler did.
const fieldKey = generateFieldKeyForArray(
`${collapsed}.${String(value)}`,
getDataTypes(value),
isBodyJsonQueryEnabled,
);
return {
fieldKey,
filterInOperator: QUERY_BUILDER_FUNCTIONS.HAS,
filterOutOperator: negateOperator(QUERY_BUILDER_FUNCTIONS.HAS),
dataType: getDataTypes([value]),
groupBySupported: false,
isRestricted: false,
};
}
const fieldKey = `body.${collapsed}`;
// Restrict body leaves whose own key is a restricted field (e.g. a JSON body's own
// `timestamp`/`id`/`date`) — hides filter / group-by, same as top-level fields.
const leafKey = String(subpath[subpath.length - 1]);
const isRestricted = RESTRICTED_SELECTED_FIELDS.includes(leafKey);
// Group by only for plain body scalars (no array anywhere in the path) with json
// body on — mirrors isGroupBySupported in the old BodyTitleRenderer.
const groupBySupported =
isBodyJsonQueryEnabled && !collapsed.includes('[]') && !isRestricted;
return {
fieldKey,
filterInOperator: OPERATORS['='],
filterOutOperator: OPERATORS['!='],
dataType: getDataTypes(value),
groupBySupported,
groupByKey: groupBySupported ? fieldKey : undefined,
isRestricted,
};
};
const normalizeDataType = (
dataType: DataTypes | undefined,
): DataTypes | undefined =>
dataType && Object.values(DataTypes).includes(dataType) ? dataType : undefined;
// Append a filter item (filter-in / filter-out) to a query-data item. The autocomplete
// key is fabricated locally (empty source list), so no click-time getAggregateKeys fetch.
export const getFilterQueryData = (
item: IBuilderQuery,
target: LogFilterTarget,
value: unknown,
operator: string,
): IBuilderQuery => {
const filterKey = chooseAutocompleteFromCustomValue(
[],
target.fieldKey,
target.dataType,
target.metricsType,
);
return {
...item,
filters: {
items: [
...(item.filters?.items || []),
{
id: uuid(),
key: filterKey,
op: getOperatorValue(operator),
value: toTypedFilterValue(value),
},
],
op: item.filters?.op || 'AND',
},
};
};
// Append a group-by. Caller guards groupBySupported / groupByKey.
export const getGroupByQueryData = (
item: IBuilderQuery,
target: LogFilterTarget,
): IBuilderQuery => {
const newGroupByItem: BaseAutocompleteData = {
key: target.groupByKey || '',
type: target.metricsType || '',
dataType: normalizeDataType(target.dataType),
};
return { ...item, groupBy: [...(item.groupBy || []), newGroupByItem] };
};
// Replace all filters with a single IN filter on this value.
export const getReplaceFilterQueryData = (
item: IBuilderQuery,
target: LogFilterTarget,
value: unknown,
): IBuilderQuery => {
const newFilterItem: BaseAutocompleteData = {
key: target.fieldKey,
type: target.metricsType || '',
dataType: normalizeDataType(target.dataType),
};
return {
...item,
filters: {
items: [
{
id: '',
key: newFilterItem,
op: QUERY_BUILDER_OPERATORS.IN,
value: [toTypedFilterValue(value)],
},
],
op: 'AND',
},
filter: { expression: '' },
};
};

View File

@@ -1,121 +1,12 @@
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
aggregateAttributesResourcesToObject,
buildPrettyViewData,
flattenObject,
getDataTypes,
getSanitizedLogBody,
parseJsonStringValue,
recursiveParseJSON,
} from './utils';
describe('parseJsonStringValue', () => {
it('parses a JSON-object string into an object', () => {
expect(parseJsonStringValue('{"a":1,"b":{"c":2}}')).toStrictEqual({
a: 1,
b: { c: 2 },
});
});
it('parses a JSON-array string into an array', () => {
expect(parseJsonStringValue('[1,2,3]')).toStrictEqual([1, 2, 3]);
});
it('returns a plain (non-JSON) string unchanged', () => {
expect(parseJsonStringValue('plain log line')).toBe('plain log line');
});
it('returns a string that is not object/array-looking unchanged', () => {
expect(parseJsonStringValue('42')).toBe('42');
});
it('returns an invalid JSON string unchanged', () => {
expect(parseJsonStringValue('{not valid}')).toBe('{not valid}');
});
it('returns an already-object value unchanged (same reference)', () => {
const value = { message: 'hi', a: 1 };
expect(parseJsonStringValue(value)).toBe(value);
});
it('leaves a value larger than the 128KB parse guard as a string', () => {
const huge = `{"x":"${'a'.repeat(130 * 1024)}"}`;
expect(parseJsonStringValue(huge)).toBe(huge);
});
});
describe('buildPrettyViewData', () => {
const baseRaw = {
id: 'log-1',
timestamp: 1234,
body: 'hello',
attributes: {},
resource: {},
scope: {},
} as any;
it('parses a JSON-string body into a tree', () => {
const result = buildPrettyViewData({ ...baseRaw, body: '{"a":1}' });
expect(result.body).toStrictEqual({ a: 1 });
});
it('parses attribute values that are JSON strings, leaves others as-is', () => {
const result = buildPrettyViewData({
...baseRaw,
attributes: { payload: '{"x":1}', name: 'cart', count: 3 },
});
expect(result.attributes).toStrictEqual({
payload: { x: 1 },
name: 'cart',
count: 3,
});
});
it('drops undefined fields so they do not render as empty rows', () => {
const result = buildPrettyViewData({ ...baseRaw, trace_id: undefined });
expect('trace_id' in result).toBe(false);
});
});
describe('aggregateAttributesResourcesToObject', () => {
const mockLog = {
id: 'log-1',
timestamp: 1234,
body: 'hello',
severity_text: 'INFO',
severity_number: 9,
attributes_string: { 'http.method': 'GET' },
attributes_int: { retries: 3 },
resources_string: { 'service.name': 'cart' },
scope_string: { lib: 'otel' },
} as unknown as ILog;
it('merges attributes_/resources_/scope_ maps and keeps scalars + body', () => {
const result = aggregateAttributesResourcesToObject(mockLog);
expect(result.attributes).toStrictEqual({
'http.method': 'GET',
retries: 3,
});
expect(result.resource).toStrictEqual({ 'service.name': 'cart' });
expect(result.scope).toStrictEqual({ lib: 'otel' });
expect(result.body).toBe('hello');
expect(result.id).toBe('log-1');
expect(result.severity_text).toBe('INFO');
});
it('does not parse a JSON-string body (leaves it raw)', () => {
const result = aggregateAttributesResourcesToObject({
...mockLog,
body: '{"a":1}',
} as unknown as ILog);
expect(result.body).toBe('{"a":1}');
});
});
describe('recursiveParseJSON', () => {
it('should return an empty object if the input is not valid JSON', () => {
const result = recursiveParseJSON('not valid JSON');

View File

@@ -1,4 +1,3 @@
import * as Sentry from '@sentry/react';
import Convert from 'ansi-to-html';
import type { DataNode } from 'antd/es/tree';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
@@ -263,11 +262,10 @@ export const filterKeyForField = (field: string): string => {
return fieldAttribs?.newField || field;
};
export const aggregateAttributesResourcesToObject = (
logData: ILog,
): ILogAggregateAttributesResources => {
export const aggregateAttributesResourcesToString = (logData: ILog): string => {
const outputJson: ILogAggregateAttributesResources = {
body: logData.body,
date: logData.date,
id: logData.id,
severityNumber: logData.severityNumber,
severityText: logData.severityText,
@@ -276,94 +274,29 @@ export const aggregateAttributesResourcesToObject = (
traceFlags: logData.traceFlags,
traceId: logData.traceId,
attributes: {},
resource: {},
resources: {},
scope: {},
severity_text: logData.severity_text,
severity_number: logData.severity_number,
};
Object.keys(logData).forEach((key) => {
if (key === 'date') {
return;
}
if (key.startsWith('attributes_')) {
outputJson.attributes = outputJson.attributes || {};
Object.assign(outputJson.attributes, logData[key as keyof ILog]);
} else if (key.startsWith('resources_')) {
outputJson.resource = outputJson.resource || {};
Object.assign(outputJson.resource, logData[key as keyof ILog]);
outputJson.resources = outputJson.resources || {};
Object.assign(outputJson.resources, logData[key as keyof ILog]);
} else if (key.startsWith('scope_string')) {
outputJson.scope = outputJson.scope || {};
Object.assign(outputJson.scope, logData[key as keyof ILog]);
} else {
// @ts-expect-error dynamic top-level copy
// @ts-expect-error
outputJson[key] = logData[key as keyof ILog];
}
});
// Show `timestamp` first and `id` last in the details view.
const { timestamp, id, ...rest } = outputJson;
return { timestamp, ...rest, id };
};
export const aggregateAttributesResourcesToString = (logData: ILog): string => {
try {
return JSON.stringify(aggregateAttributesResourcesToObject(logData), null, 2);
} catch (err) {
Sentry.captureException(err);
return '';
}
};
const MAX_JSON_PARSE_BYTES = 128 * 1024;
// A JSON-encoded object/array string is parsed so DataViewer renders it as a tree
// instead of one escaped string; non-JSON / plain-text values are returned unchanged.
// Guarded against very large payloads.
export const parseJsonStringValue = (value: unknown): unknown => {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
const looksLikeJson = trimmed.startsWith('{') || trimmed.startsWith('[');
if (!looksLikeJson || trimmed.length > MAX_JSON_PARSE_BYTES) {
return value;
}
try {
const parsed = JSON.parse(trimmed);
return parsed !== null && typeof parsed === 'object' ? parsed : value;
} catch {
return value;
}
};
// Parse each attribute value that's a stringified JSON string into an object
// Non-JSON values are left unchanged.
const parseAttributeJsonValues = (
attributes: Record<string, unknown>,
): Record<string, unknown> => {
const parsed: Record<string, unknown> = {};
Object.keys(attributes).forEach((key) => {
parsed[key] = parseJsonStringValue(attributes[key]);
});
return parsed;
};
export const buildPrettyViewData = (
raw: ILogAggregateAttributesResources,
): Record<string, unknown> => {
const prettyData: Record<string, unknown> = { ...raw };
prettyData.body = parseJsonStringValue(raw.body);
prettyData.attributes = parseAttributeJsonValues(raw.attributes);
// drop undefined fields so they don't render as empty rows
Object.keys(prettyData).forEach((key) => {
if (prettyData[key] === undefined) {
delete prettyData[key];
}
});
return prettyData;
return JSON.stringify(outputJson, null, 2);
};
const isFloat = (num: number): boolean => num % 1 !== 0;

View File

@@ -2,9 +2,6 @@ import { blue, red } from '@ant-design/colors';
export const RESTRICTED_SELECTED_FIELDS = ['timestamp', 'id'];
// Fields that can be filtered on but not grouped by in the log details view.
export const RESTRICTED_GROUP_BY_FIELDS = ['body', 'trace_id'];
export const ICON_STYLE = {
PLUS: { color: blue[5] },
CLOSE: { color: red[5] },

View File

@@ -124,9 +124,6 @@ function Application(): JSX.Element {
start: minTime,
end: maxTime,
}),
// the time range is part of the key, so without this every window change blanks the
// operations list and the widgets below are rebuilt with an empty `operation in []`
keepPreviousData: true,
});
const selectedTraceTags: string = JSON.stringify(

View File

@@ -16,7 +16,7 @@ interface AuthNProvider {
function getAuthNProviders(samlEnabled: boolean): AuthNProvider[] {
return [
{
key: AuthtypesAuthNProviderDTO.google,
key: AuthtypesAuthNProviderDTO.google_auth,
title: 'Google Apps Authentication',
description: 'Let members sign-in with a Google workspace account',
icon: <SolidGoogle size={37} />,
@@ -78,7 +78,6 @@ function AuthnProviderSelector({
<Button
onClick={(): void => setAuthnProvider(provider.key)}
type="primary"
data-testid={`authn-provider-configure-${provider.key}`}
>
Configure
</Button>

View File

@@ -10,6 +10,8 @@ import {
import {
AuthtypesAuthNProviderDTO,
AuthtypesGettableAuthDomainDTO,
AuthtypesGoogleConfigDTO,
AuthtypesRoleMappingDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
@@ -22,11 +24,10 @@ import APIError from 'types/api/error';
import AuthnProviderSelector from './AuthnProviderSelector';
import {
convertDomainMappingsToRecord,
convertGroupMappingsToRecord,
FormValues,
kindToProvider,
prepareConfig,
prepareInitialValues,
prepareRoleMapping,
} from './CreateEdit.utils';
import ConfigureGoogleAuthAuthnProvider from './Providers/AuthnGoogleAuth';
import ConfigureOIDCAuthnProvider from './Providers/AuthnOIDC';
@@ -40,7 +41,7 @@ function configureAuthnProvider(
switch (authnProvider) {
case 'saml':
return <ConfigureSAMLAuthnProvider isCreate={isCreate} />;
case 'google':
case 'google_auth':
return <ConfigureGoogleAuthAuthnProvider isCreate={isCreate} />;
case 'oidc':
return <ConfigureOIDCAuthnProvider isCreate={isCreate} />;
@@ -60,7 +61,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const [form] = Form.useForm<FormValues>();
const [authnProvider, setAuthnProvider] = useState<
AuthtypesAuthNProviderDTO | ''
>(kindToProvider(record?.config?.kind));
>(record?.config?.ssoType || '');
const { showErrorModal } = useErrorModal();
const { featureFlags } = useAppContext();
@@ -84,6 +85,68 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const { mutate: updateAuthDomain, isLoading: isUpdating } =
useUpdateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
/**
* Prepares Google Auth config for API payload
*/
const getGoogleAuthConfig = useCallback(():
| AuthtypesGoogleConfigDTO
| undefined => {
const config = form.getFieldValue('googleAuthConfig');
if (!config) {
return undefined;
}
const {
domainToAdminEmailList,
allowedGroups,
serviceAccountJson,
domainToAdminEmail: _domainToAdminEmail,
fetchTransitiveGroupMembership,
...rest
} = config;
const domainToAdminEmail = convertDomainMappingsToRecord(
domainToAdminEmailList,
);
return {
...rest,
...(rest.fetchGroups
? {
allowedGroups,
serviceAccountJson,
domainToAdminEmail: domainToAdminEmail ?? {},
fetchTransitiveGroupMembership,
}
: { domainToAdminEmail: {} }),
};
}, [form]);
// Prepares role mapping for API payload
const getRoleMapping = useCallback((): AuthtypesRoleMappingDTO | undefined => {
const roleMapping = form.getFieldValue('roleMapping');
if (!roleMapping) {
return undefined;
}
const { groupMappingsList, ...rest } = roleMapping;
const groupMappings = convertGroupMappingsToRecord(groupMappingsList);
// Only return roleMapping if there's meaningful content
const hasDefaultRole = !!rest.defaultRole;
const hasUseRoleAttribute = rest.useRoleAttribute === true;
const hasGroupMappings =
groupMappings && Object.keys(groupMappings).length > 0;
if (!hasDefaultRole && !hasUseRoleAttribute && !hasGroupMappings) {
return undefined;
}
return {
...rest,
groupMappings: rest.useRoleAttribute ? undefined : (groupMappings ?? {}),
};
}, [form]);
const onSubmitHandler = useCallback(async (): Promise<void> => {
try {
await form.validateFields();
@@ -95,23 +158,25 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
return;
}
const values = form.getFieldsValue(true) as FormValues;
const name = values.name ?? '';
const config = prepareConfig(values, authnProvider);
const roleMapping = prepareRoleMapping(values);
if (!config) {
return;
}
const name = form.getFieldValue('name');
const googleAuthConfig = getGoogleAuthConfig();
const samlConfig = form.getFieldValue('samlConfig');
const oidcConfig = form.getFieldValue('oidcConfig');
const roleMapping = getRoleMapping();
if (isCreate) {
createAuthDomain(
{
data: {
name,
enabled: true,
config,
roleMapping,
config: {
ssoEnabled: true,
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
roleMapping,
},
},
},
{
@@ -131,9 +196,14 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
{
pathParams: { id: record.id },
data: {
enabled: values.enabled ?? false,
config,
roleMapping,
config: {
ssoEnabled: form.getFieldValue('ssoEnabled'),
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
roleMapping,
},
},
},
{
@@ -149,6 +219,8 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
authnProvider,
createAuthDomain,
form,
getGoogleAuthConfig,
getRoleMapping,
handleError,
isCreate,
@@ -171,10 +243,10 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
>
<Form
name="auth-domain"
data-testid="auth-domain-form"
initialValues={defaultTo(prepareInitialValues(record), {
name: '',
enabled: false,
ssoEnabled: false,
ssoType: '',
})}
form={form}
layout="vertical"
@@ -190,22 +262,12 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
{configureAuthnProvider(authnProvider, isCreate)}
<section className="action-buttons">
{isCreate && (
<Button
onClick={onBackHandler}
variant="solid"
color="secondary"
testId="auth-domain-back"
>
<Button onClick={onBackHandler} variant="solid" color="secondary">
Back
</Button>
)}
{!isCreate && (
<Button
onClick={onClose}
variant="solid"
color="secondary"
testId="auth-domain-cancel"
>
<Button onClick={onClose} variant="solid" color="secondary">
Cancel
</Button>
)}
@@ -214,7 +276,6 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
variant="solid"
color="primary"
loading={isCreating || isUpdating}
testId="auth-domain-save"
>
Save Changes
</Button>

View File

@@ -1,8 +1,4 @@
import {
AuthtypesAuthDomainConfigGoogleDTOKind,
AuthtypesAuthDomainConfigOIDCDTOKind,
AuthtypesAuthDomainConfigSAMLDTOKind,
} from 'api/generated/services/sigNoz.schemas';
import { AuthtypesAuthNProviderDTO } from 'api/generated/services/sigNoz.schemas';
import {
convertDomainMappingsToList,
@@ -86,7 +82,8 @@ describe('prepareInitialValues', () => {
it('returns empty defaults when no record is provided', () => {
expect(prepareInitialValues(undefined)).toStrictEqual({
name: '',
enabled: false,
ssoEnabled: false,
ssoType: '',
});
});
@@ -94,20 +91,15 @@ describe('prepareInitialValues', () => {
const result = prepareInitialValues({
id: 'domain-1',
name: 'example.com',
enabled: true,
config: {
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
spec: {
location: 'https://idp.example.com/sso',
entityId: 'urn:example:idp',
certificate: 'CERT',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
roleMapping: {
defaultRole: 'VIEWER',
useRoleAttribute: false,
groupMappings: { admins: 'ADMIN', viewers: 'VIEWER' },
},
},
roleMapping: {
defaultRole: 'VIEWER',
useRoleAttribute: false,
groupMappings: { admins: 'ADMIN', viewers: 'VIEWER' },
},
});
expect(result.roleMapping?.groupMappingsList).toStrictEqual([
@@ -120,10 +112,10 @@ describe('prepareInitialValues', () => {
const result = prepareInitialValues({
id: 'domain-1',
name: 'example.com',
enabled: true,
config: {
kind: AuthtypesAuthDomainConfigGoogleDTOKind.google,
spec: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'id',
clientSecret: 'secret',
domainToAdminEmail: { 'example.com': 'admin@example.com' },
@@ -140,16 +132,11 @@ describe('prepareInitialValues', () => {
const result = prepareInitialValues({
id: 'domain-1',
name: 'example.com',
enabled: true,
config: {
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
spec: {
issuer: 'https://oidc.example.com',
clientId: 'id',
clientSecret: 'secret',
},
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
roleMapping: { defaultRole: 'VIEWER', useRoleAttribute: true },
},
roleMapping: { defaultRole: 'VIEWER', useRoleAttribute: true },
});
expect(result.roleMapping?.groupMappingsList).toStrictEqual([]);

View File

@@ -1,9 +1,4 @@
import {
AuthtypesAuthDomainConfigDTO,
AuthtypesAuthDomainConfigGoogleDTOKind,
AuthtypesAuthDomainConfigOIDCDTOKind,
AuthtypesAuthDomainConfigSAMLDTOKind,
AuthtypesAuthNProviderDTO,
AuthtypesGettableAuthDomainDTO,
AuthtypesGoogleConfigDTO,
AuthtypesOIDCConfigDTO,
@@ -11,29 +6,11 @@ import {
AuthtypesSamlConfigDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Maps the config envelope's per-variant kind to the provider enum driving the
* create/edit UI.
*/
export function kindToProvider(
kind?: AuthtypesAuthDomainConfigDTO['kind'],
): AuthtypesAuthNProviderDTO | '' {
switch (kind) {
case AuthtypesAuthDomainConfigSAMLDTOKind.saml:
return AuthtypesAuthNProviderDTO.saml;
case AuthtypesAuthDomainConfigGoogleDTOKind.google:
return AuthtypesAuthNProviderDTO.google;
case AuthtypesAuthDomainConfigOIDCDTOKind.oidc:
return AuthtypesAuthNProviderDTO.oidc;
default:
return '';
}
}
// Form values interface for internal use (includes array-based fields for UI)
export interface FormValues {
name?: string;
enabled?: boolean;
ssoEnabled?: boolean;
ssoType?: string;
googleAuthConfig?: AuthtypesGoogleConfigDTO & {
domainToAdminEmailList?: Array<{ domain?: string; adminEmail?: string }>;
};
@@ -130,141 +107,33 @@ export function prepareInitialValues(
if (!record) {
return {
name: '',
enabled: false,
ssoEnabled: false,
ssoType: '',
};
}
const { config } = record;
const config = record.config ?? {};
return {
name: record.name,
enabled: record.enabled,
samlConfig:
config?.kind === AuthtypesAuthDomainConfigSAMLDTOKind.saml
? config.spec
: undefined,
oidcConfig:
config?.kind === AuthtypesAuthDomainConfigOIDCDTOKind.oidc
? config.spec
: undefined,
googleAuthConfig:
config?.kind === AuthtypesAuthDomainConfigGoogleDTOKind.google
? {
...config.spec,
domainToAdminEmailList: convertDomainMappingsToList(
config.spec.domainToAdminEmail,
),
}
: undefined,
roleMapping: record.roleMapping
ssoEnabled: config.ssoEnabled,
ssoType: config.ssoType,
samlConfig: config.samlConfig ?? undefined,
oidcConfig: config.oidcConfig ?? undefined,
googleAuthConfig: config.googleAuthConfig
? {
...record.roleMapping,
...config.googleAuthConfig,
domainToAdminEmailList: convertDomainMappingsToList(
config.googleAuthConfig.domainToAdminEmail,
),
}
: undefined,
roleMapping: config.roleMapping
? {
...config.roleMapping,
groupMappingsList: convertGroupMappingsToList(
record.roleMapping.groupMappings,
config.roleMapping.groupMappings,
),
}
: undefined,
};
}
/**
* Prepares Google Auth config for API payload
*/
export function prepareGoogleAuthConfig(
values: FormValues,
): AuthtypesGoogleConfigDTO | undefined {
const config = values.googleAuthConfig;
if (!config) {
return undefined;
}
const {
domainToAdminEmailList,
allowedGroups,
serviceAccountJson,
domainToAdminEmail: _domainToAdminEmail,
fetchTransitiveGroupMembership,
...rest
} = config;
const domainToAdminEmail = convertDomainMappingsToRecord(
domainToAdminEmailList,
);
return {
...rest,
...(rest.fetchGroups
? {
allowedGroups,
serviceAccountJson,
domainToAdminEmail: domainToAdminEmail ?? {},
fetchTransitiveGroupMembership,
}
: { domainToAdminEmail: {} }),
};
}
/**
* Prepares role mapping for API payload; only returned when there is
* meaningful content.
*/
export function prepareRoleMapping(
values: FormValues,
): AuthtypesRoleMappingDTO | undefined {
const roleMapping = values.roleMapping;
if (!roleMapping) {
return undefined;
}
const { groupMappingsList, ...rest } = roleMapping;
const groupMappings = convertGroupMappingsToRecord(groupMappingsList);
const hasDefaultRole = !!rest.defaultRole;
const hasUseRoleAttribute = rest.useRoleAttribute === true;
const hasGroupMappings =
groupMappings && Object.keys(groupMappings).length > 0;
if (!hasDefaultRole && !hasUseRoleAttribute && !hasGroupMappings) {
return undefined;
}
return {
...rest,
groupMappings: rest.useRoleAttribute ? undefined : (groupMappings ?? {}),
};
}
/**
* Prepares the kind/spec config envelope for API payload; the inverse of
* prepareInitialValues.
*/
export function prepareConfig(
values: FormValues,
provider: AuthtypesAuthNProviderDTO | '',
): AuthtypesAuthDomainConfigDTO | undefined {
switch (provider) {
case AuthtypesAuthNProviderDTO.saml:
return values.samlConfig
? {
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
spec: values.samlConfig,
}
: undefined;
case AuthtypesAuthNProviderDTO.google: {
const spec = prepareGoogleAuthConfig(values);
return spec
? {
kind: AuthtypesAuthDomainConfigGoogleDTOKind.google,
spec,
}
: undefined;
}
case AuthtypesAuthNProviderDTO.oidc:
return values.oidcConfig
? {
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
spec: values.oidcConfig,
}
: undefined;
default:
return undefined;
}
}

View File

@@ -91,11 +91,7 @@ function ConfigureGoogleAuthAuthnProvider({
{ required: true, message: 'Domain is required', whitespace: true },
]}
>
<Input
id="google-domain"
disabled={!isCreate}
testId="google-auth-domain"
/>
<Input id="google-domain" disabled={!isCreate} />
</Form.Item>
</div>
@@ -113,7 +109,7 @@ function ConfigureGoogleAuthAuthnProvider({
{ required: true, message: 'Client ID is required', whitespace: true },
]}
>
<Input id="google-client-id" testId="google-auth-client-id" />
<Input id="google-client-id" />
</Form.Item>
</div>
@@ -135,7 +131,7 @@ function ConfigureGoogleAuthAuthnProvider({
},
]}
>
<Input id="google-client-secret" testId="google-auth-client-secret" />
<Input id="google-client-secret" />
</Form.Item>
</div>
@@ -147,7 +143,6 @@ function ConfigureGoogleAuthAuthnProvider({
>
<Checkbox
id="google-skip-email-verification"
testId="google-auth-skip-email-verified"
onChange={(checked: boolean): void => {
form.setFieldValue(
['googleAuthConfig', 'insecureSkipEmailVerified'],
@@ -185,10 +180,7 @@ function ConfigureGoogleAuthAuthnProvider({
<Collapse.Panel
key="workspace-groups"
header={
<div
className="authn-provider__collapse-header"
data-testid="google-auth-workspace-groups-header"
>
<div className="authn-provider__collapse-header">
{expandedSection !== 'workspace-groups' ? (
<ChevronRight size={16} />
) : (
@@ -229,7 +221,6 @@ function ConfigureGoogleAuthAuthnProvider({
>
<Checkbox
id="google-fetch-groups"
testId="google-auth-fetch-groups"
onChange={(checked: boolean): void => {
form.setFieldValue(['googleAuthConfig', 'fetchGroups'], checked);
}}
@@ -260,7 +251,6 @@ function ConfigureGoogleAuthAuthnProvider({
>
<AntdInput.TextArea
id="google-service-account-json"
data-testid="google-auth-service-account-json"
rows={3}
placeholder="Paste service account JSON"
className="authn-provider__textarea"
@@ -280,7 +270,6 @@ function ConfigureGoogleAuthAuthnProvider({
>
<Checkbox
id="google-transitive-membership"
testId="google-auth-transitive-membership"
onChange={(checked: boolean): void => {
form.setFieldValue(
['googleAuthConfig', 'fetchTransitiveGroupMembership'],
@@ -310,10 +299,7 @@ function ConfigureGoogleAuthAuthnProvider({
name={['googleAuthConfig', 'allowedGroups']}
className="authn-provider__form-item"
>
<EmailTagInput
placeholder="Type a group email and press Enter"
testId="google-auth-allowed-groups"
/>
<EmailTagInput placeholder="Type a group email and press Enter" />
</Form.Item>
</div>
</div>

View File

@@ -76,7 +76,7 @@ function ConfigureSAMLAuthnProvider({
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'location']}
name={['samlConfig', 'samlIdp']}
className="authn-provider__form-item"
rules={[
{
@@ -98,7 +98,7 @@ function ConfigureSAMLAuthnProvider({
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'entityId']}
name={['samlConfig', 'samlEntity']}
className="authn-provider__form-item"
rules={[
{
@@ -120,7 +120,7 @@ function ConfigureSAMLAuthnProvider({
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'certificate']}
name={['samlConfig', 'samlCert']}
className="authn-provider__form-item"
rules={[
{

View File

@@ -9,14 +9,12 @@ interface EmailTagInputProps {
value?: string[];
onChange?: (value: string[]) => void;
placeholder?: string;
testId?: string;
}
function EmailTagInput({
value = [],
onChange,
placeholder = 'Type an email and press Enter',
testId,
}: EmailTagInputProps): JSX.Element {
const [validationError, setValidationError] = useState('');
@@ -36,7 +34,7 @@ function EmailTagInput({
);
return (
<div className="email-tag-input" data-testid={testId}>
<div className="email-tag-input">
<Tooltip
title={validationError}
open={!!validationError}

View File

@@ -74,7 +74,6 @@ function RoleMappingSection({
role="button"
aria-expanded={expanded}
aria-controls="role-mapping-content"
data-testid="role-mapping-header"
>
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<div className="role-mapping-section__collapse-header-text">
@@ -139,7 +138,6 @@ function RoleMappingSection({
>
<Checkbox
id="use-role-attribute"
testId="role-mapping-use-role-attribute"
onChange={(checked: boolean): void => {
form.setFieldValue([...fieldNamePrefix, 'useRoleAttribute'], checked);
}}
@@ -168,20 +166,13 @@ function RoleMappingSection({
{(fields, { add, remove }): JSX.Element => (
<div className="role-mapping-section__items">
{fields.map((field) => (
<div
key={field.key}
className="role-mapping-section__row"
data-testid="role-mapping-row"
>
<div key={field.key} className="role-mapping-section__row">
<Form.Item
name={[field.name, 'groupName']}
className="role-mapping-section__field role-mapping-section__field--group"
rules={[{ required: true, message: 'Group name is required' }]}
>
<Input
placeholder="IDP Group Name"
testId="role-mapping-group-name"
/>
<Input placeholder="IDP Group Name" />
</Form.Item>
<Form.Item
@@ -208,7 +199,6 @@ function RoleMappingSection({
className="role-mapping-section__remove-btn"
onClick={(): void => remove(field.name)}
aria-label="Remove mapping"
testId="role-mapping-remove"
>
<Trash2 size={12} />
</Button>
@@ -222,7 +212,6 @@ function RoleMappingSection({
add({ groupName: '', role: SIGNOZ_VIEWER_ROLE })
}
prefix={<Plus size={14} />}
testId="role-mapping-add"
>
Add Group Mapping
</Button>

View File

@@ -31,7 +31,7 @@ function SSOEnforcementToggle({
useUpdateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
const onChangeHandler = (checked: boolean): void => {
if (!record.id || !record.config) {
if (!record.id) {
return;
}
@@ -41,9 +41,14 @@ function SSOEnforcementToggle({
{
pathParams: { id: record.id },
data: {
enabled: checked,
config: record.config,
roleMapping: record.roleMapping,
config: {
ssoEnabled: checked,
ssoType: record.config?.ssoType,
googleAuthConfig: record.config?.googleAuthConfig,
oidcConfig: record.config?.oidcConfig,
samlConfig: record.config?.samlConfig,
roleMapping: record.config?.roleMapping,
},
},
},
{
@@ -60,12 +65,7 @@ function SSOEnforcementToggle({
};
return (
<Switch
disabled={isLoading}
value={isChecked}
onChange={onChangeHandler}
testId="auth-domain-enforce-sso"
/>
<Switch disabled={isLoading} value={isChecked} onChange={onChangeHandler} />
);
}

View File

@@ -58,7 +58,7 @@ describe('AuthDomain', () => {
});
});
it('reflects the enabled state in each row toggle', async () => {
it('reflects ssoEnabled state from nested config in each row toggle', async () => {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockDomainsListResponse)),
@@ -68,9 +68,9 @@ describe('AuthDomain', () => {
render(<AuthDomain />);
// mockDomainsListResponse rows:
// [0] signoz.io → enabled: true
// [1] example.com → enabled: false
// [2] corp.io → enabled: true
// [0] signoz.io → config.ssoEnabled: true
// [1] example.com → config.ssoEnabled: false
// [2] corp.io → config.ssoEnabled: true
const switches = await screen.findAllByRole('switch');
expect(switches).toHaveLength(3);
expect(switches[0]).toBeChecked();

View File

@@ -112,7 +112,9 @@ describe('CreateEdit — save payload correctness', () => {
await waitFor(() => expect(capturedPayload).not.toBeNull());
expect(capturedPayload).toMatchObject({
roleMapping: expect.objectContaining({ groupMappings: {} }),
config: expect.objectContaining({
roleMapping: expect.objectContaining({ groupMappings: {} }),
}),
});
});
@@ -159,7 +161,7 @@ describe('CreateEdit — save payload correctness', () => {
expect(capturedPayload).toMatchObject({
config: expect.objectContaining({
spec: expect.objectContaining({
googleAuthConfig: expect.objectContaining({
domainToAdminEmail: {},
}),
}),

View File

@@ -188,8 +188,8 @@ describe('CreateEdit — role mapping uses API roles', () => {
// SSO role mapping matches roles by name, so the payload carries the
// role *name*, not the opaque id.
expect(payload.get().roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().roleMapping.defaultRole).not.toBe(editorRole.id);
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().config.roleMapping.defaultRole).not.toBe(editorRole.id);
});
it('defaults a fresh role mapping to the signoz-viewer role name', async () => {
@@ -221,8 +221,8 @@ describe('CreateEdit — role mapping uses API roles', () => {
await waitFor(() => expect(payload.get()).not.toBeNull());
expect(payload.get().roleMapping.defaultRole).toBe(viewerRole.name);
expect(payload.get().roleMapping.defaultRole).not.toBe(viewerRole.id);
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
expect(payload.get().config.roleMapping.defaultRole).not.toBe(viewerRole.id);
});
it('still defaults to signoz-viewer when the roles fetch returns empty', async () => {
@@ -249,7 +249,7 @@ describe('CreateEdit — role mapping uses API roles', () => {
await waitFor(() => expect(payload.get()).not.toBeNull());
// The Form.Item initialValue (signoz-viewer) survives an empty roles list.
expect(payload.get().roleMapping.defaultRole).toBe(viewerRole.name);
expect(payload.get().config.roleMapping.defaultRole).toBe(viewerRole.name);
});
it('loads a stored role mapping by role name and round-trips it on save', async () => {
@@ -280,8 +280,8 @@ describe('CreateEdit — role mapping uses API roles', () => {
await waitFor(() => expect(payload.get()).not.toBeNull());
expect(payload.get().roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().roleMapping.groupMappings).toStrictEqual({
expect(payload.get().config.roleMapping.defaultRole).toBe(editorRole.name);
expect(payload.get().config.roleMapping.groupMappings).toStrictEqual({
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',

View File

@@ -1,9 +1,6 @@
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import {
AuthtypesAuthDomainConfigGoogleDTO,
AuthtypesGettableAuthDomainDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AuthtypesGettableAuthDomainDTO } from 'api/generated/services/sigNoz.schemas';
import CreateEdit from '../CreateEdit/CreateEdit';
import {
@@ -51,10 +48,11 @@ jest.mock('@signozhq/ui/button', () => ({
type SavedPayload = {
config: {
kind?: string;
spec?: Record<string, unknown>;
googleAuthConfig?: Record<string, unknown>;
samlConfig?: Record<string, unknown>;
oidcConfig?: Record<string, unknown>;
roleMapping?: Record<string, unknown>;
};
roleMapping?: Record<string, unknown>;
};
async function submitForm(
@@ -83,7 +81,7 @@ describe('CreateEdit — payload sanitization', () => {
it('sends core fields and omits workspace fields when fetchGroups is not set', async () => {
const payload = await submitForm(mockGoogleAuthDomain);
const g = payload.config.spec;
const g = payload.config.googleAuthConfig;
expect(g?.clientId).toBe('test-client-id');
expect(g?.clientSecret).toBe('test-client-secret');
expect(g?.allowedGroups).toBeUndefined();
@@ -93,20 +91,18 @@ describe('CreateEdit — payload sanitization', () => {
});
it('strips workspace fields when fetchGroups is false', async () => {
const googleConfig =
mockGoogleAuthWithWorkspaceGroups.config as AuthtypesAuthDomainConfigGoogleDTO;
const payload = await submitForm({
...mockGoogleAuthWithWorkspaceGroups,
config: {
...googleConfig,
spec: {
...googleConfig.spec,
...mockGoogleAuthWithWorkspaceGroups.config,
googleAuthConfig: {
...mockGoogleAuthWithWorkspaceGroups.config?.googleAuthConfig,
fetchGroups: false,
},
},
});
const g = payload.config.spec;
const g = payload.config.googleAuthConfig;
expect(g?.fetchGroups).toBe(false);
expect(g?.allowedGroups).toBeUndefined();
expect(g?.serviceAccountJson).toBeUndefined();
@@ -117,7 +113,7 @@ describe('CreateEdit — payload sanitization', () => {
it('includes all workspace fields when fetchGroups is true', async () => {
const payload = await submitForm(mockGoogleAuthWithWorkspaceGroups);
const g = payload.config.spec;
const g = payload.config.googleAuthConfig;
expect(g?.fetchGroups).toBe(true);
expect(g?.serviceAccountJson).toBe('{"type": "service_account"}');
expect(g?.fetchTransitiveGroupMembership).toBe(true);
@@ -135,10 +131,10 @@ describe('CreateEdit — payload sanitization', () => {
it('sends core and attributeMapping fields', async () => {
const payload = await submitForm(mockSamlWithAttributeMapping);
const s = payload.config.spec;
expect(s?.location).toBe('https://idp.saml-attrs.com/sso');
expect(s?.entityId).toBe('urn:saml-attrs:idp');
expect(s?.certificate).toBe('MOCK_CERTIFICATE_ATTRS');
const s = payload.config.samlConfig;
expect(s?.samlIdp).toBe('https://idp.saml-attrs.com/sso');
expect(s?.samlEntity).toBe('urn:saml-attrs:idp');
expect(s?.samlCert).toBe('MOCK_CERTIFICATE_ATTRS');
expect(s?.insecureSkipAuthNRequestsSigned).toBe(true);
const attr = s?.attributeMapping as Record<string, unknown>;
@@ -152,7 +148,7 @@ describe('CreateEdit — payload sanitization', () => {
it('sends all fields including claimMapping', async () => {
const payload = await submitForm(mockOidcWithClaimMapping);
const o = payload.config.spec;
const o = payload.config.oidcConfig;
expect(o?.issuer).toBe('https://oidc.claims.com');
expect(o?.issuerAlias).toBe('https://alias.claims.com');
expect(o?.clientId).toBe('claims-client-id');
@@ -172,21 +168,24 @@ describe('CreateEdit — payload sanitization', () => {
it('strips groupMappings when useRoleAttribute is true', async () => {
const payload = await submitForm({
...mockDomainWithRoleMapping,
roleMapping: {
...mockDomainWithRoleMapping.roleMapping,
useRoleAttribute: true,
config: {
...mockDomainWithRoleMapping.config,
roleMapping: {
...mockDomainWithRoleMapping.config?.roleMapping,
useRoleAttribute: true,
},
},
});
expect(payload.roleMapping?.useRoleAttribute).toBe(true);
expect(payload.roleMapping?.groupMappings).toBeUndefined();
expect(payload.config.roleMapping?.useRoleAttribute).toBe(true);
expect(payload.config.roleMapping?.groupMappings).toBeUndefined();
});
it('sends groupMappings when useRoleAttribute is false', async () => {
const payload = await submitForm(mockDomainWithRoleMapping);
expect(payload.roleMapping?.useRoleAttribute).toBe(false);
expect(payload.roleMapping?.groupMappings).toStrictEqual({
expect(payload.config.roleMapping?.useRoleAttribute).toBe(false);
expect(payload.config.roleMapping?.groupMappings).toStrictEqual({
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',

View File

@@ -25,7 +25,6 @@ jest.mock('@signozhq/ui/switch', () => ({
import SSOEnforcementToggle from '../SSOEnforcementToggle';
import {
AUTH_DOMAINS_UPDATE_ENDPOINT,
mockDomainWithRoleMapping,
mockErrorResponse,
mockGoogleAuthDomain,
mockUpdateSuccessResponse,
@@ -58,7 +57,7 @@ describe('SSOEnforcementToggle', () => {
isDefaultChecked={false}
record={{
...mockGoogleAuthDomain,
enabled: false,
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
}}
/>,
);
@@ -96,42 +95,13 @@ describe('SSOEnforcementToggle', () => {
expect(mockUpdateAPI).toHaveBeenCalledTimes(1);
expect(mockUpdateAPI).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
config: mockGoogleAuthDomain.config,
config: expect.objectContaining({
ssoEnabled: false,
}),
}),
);
});
// The toggle sends a full replacement, so anything it fails to echo back is
// dropped from the domain — role mappings included.
it('echoes the existing role mapping when toggling enforcement', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockUpdateAPI = jest.fn();
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
mockUpdateAPI(await req.json());
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
render(
<SSOEnforcementToggle
isDefaultChecked={true}
record={mockDomainWithRoleMapping}
/>,
);
await user.click(screen.getByRole('switch'));
await waitFor(() => expect(mockUpdateAPI).toHaveBeenCalledTimes(1));
expect(mockUpdateAPI).toHaveBeenCalledWith({
enabled: false,
config: mockDomainWithRoleMapping.config,
roleMapping: mockDomainWithRoleMapping.roleMapping,
});
});
it('shows error modal when update fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });

View File

@@ -1,24 +1,22 @@
import {
AuthtypesAuthDomainConfigGoogleDTOKind,
AuthtypesAuthDomainConfigOIDCDTOKind,
AuthtypesAuthDomainConfigSAMLDTOKind,
AuthtypesAuthNProviderDTO,
AuthtypesGettableAuthDomainDTO,
} from 'api/generated/services/sigNoz.schemas';
// API Endpoints
export const AUTH_DOMAINS_LIST_ENDPOINT = '*/api/v2/auth_domains';
export const AUTH_DOMAINS_CREATE_ENDPOINT = '*/api/v2/auth_domains';
export const AUTH_DOMAINS_UPDATE_ENDPOINT = '*/api/v2/auth_domains/:id';
export const AUTH_DOMAINS_DELETE_ENDPOINT = '*/api/v2/auth_domains/:id';
export const AUTH_DOMAINS_LIST_ENDPOINT = '*/api/v1/domains';
export const AUTH_DOMAINS_CREATE_ENDPOINT = '*/api/v1/domains';
export const AUTH_DOMAINS_UPDATE_ENDPOINT = '*/api/v1/domains/:id';
export const AUTH_DOMAINS_DELETE_ENDPOINT = '*/api/v1/domains/:id';
// Mock Auth Domain with Google Auth
export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-1',
name: 'signoz.io',
enabled: true,
config: {
kind: AuthtypesAuthDomainConfigGoogleDTOKind.google,
spec: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
@@ -32,13 +30,13 @@ export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-2',
name: 'example.com',
enabled: false,
config: {
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
spec: {
location: 'https://idp.example.com/sso',
entityId: 'urn:example:idp',
certificate: 'MOCK_CERTIFICATE',
ssoEnabled: false,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.example.com/sso',
samlEntity: 'urn:example:idp',
samlCert: 'MOCK_CERTIFICATE',
},
},
authNProviderInfo: {
@@ -50,10 +48,10 @@ export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-3',
name: 'corp.io',
enabled: true,
config: {
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
spec: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.corp.io',
clientId: 'oidc-client-id',
clientSecret: 'oidc-client-secret',
@@ -68,22 +66,22 @@ export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-4',
name: 'enterprise.com',
enabled: true,
config: {
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
spec: {
location: 'https://idp.enterprise.com/sso',
entityId: 'urn:enterprise:idp',
certificate: 'MOCK_CERTIFICATE',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.enterprise.com/sso',
samlEntity: 'urn:enterprise:idp',
samlCert: 'MOCK_CERTIFICATE',
},
},
roleMapping: {
defaultRole: 'signoz-editor',
useRoleAttribute: false,
groupMappings: {
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',
roleMapping: {
defaultRole: 'signoz-editor',
useRoleAttribute: false,
groupMappings: {
'admin-group': 'signoz-admin',
'dev-team': 'signoz-editor',
viewers: 'signoz-viewer',
},
},
},
authNProviderInfo: {
@@ -96,18 +94,18 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
{
id: 'domain-5',
name: 'direct-role.com',
enabled: true,
config: {
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
spec: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.direct-role.com',
clientId: 'direct-role-client-id',
clientSecret: 'direct-role-client-secret',
},
},
roleMapping: {
defaultRole: 'signoz-viewer',
useRoleAttribute: true,
roleMapping: {
defaultRole: 'signoz-viewer',
useRoleAttribute: true,
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-5',
@@ -118,10 +116,10 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-6',
name: 'oidc-claims.com',
enabled: true,
config: {
kind: AuthtypesAuthDomainConfigOIDCDTOKind.oidc,
spec: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
oidcConfig: {
issuer: 'https://oidc.claims.com',
issuerAlias: 'https://alias.claims.com',
clientId: 'claims-client-id',
@@ -145,13 +143,13 @@ export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
export const mockSamlWithAttributeMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-7',
name: 'saml-attrs.com',
enabled: true,
config: {
kind: AuthtypesAuthDomainConfigSAMLDTOKind.saml,
spec: {
location: 'https://idp.saml-attrs.com/sso',
entityId: 'urn:saml-attrs:idp',
certificate: 'MOCK_CERTIFICATE_ATTRS',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
samlConfig: {
samlIdp: 'https://idp.saml-attrs.com/sso',
samlEntity: 'urn:saml-attrs:idp',
samlCert: 'MOCK_CERTIFICATE_ATTRS',
insecureSkipAuthNRequestsSigned: true,
attributeMapping: {
name: 'user_display_name',
@@ -170,10 +168,10 @@ export const mockGoogleAuthWithWorkspaceGroups: AuthtypesGettableAuthDomainDTO =
{
id: 'domain-8',
name: 'google-groups.com',
enabled: true,
config: {
kind: AuthtypesAuthDomainConfigGoogleDTOKind.google,
spec: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'google-groups-client-id',
clientSecret: 'google-groups-client-secret',
insecureSkipEmailVerified: false,
@@ -220,7 +218,7 @@ export const mockUpdateSuccessResponse = {
status: 'success',
data: {
...mockGoogleAuthDomain,
enabled: false,
config: { ...mockGoogleAuthDomain.config, ssoEnabled: false },
},
};

View File

@@ -1,4 +1,4 @@
import { HTMLAttributes, useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Plus, Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
@@ -26,7 +26,7 @@ import './AuthDomain.styles.scss';
import '../../IngestionSettings/IngestionSettings.styles.scss';
export const SSOType = new Map<string, string>([
['google', 'Google Auth'],
['google_auth', 'Google Auth'],
['saml', 'SAML'],
['email_password', 'Email Password'],
['oidc', 'OIDC'],
@@ -121,8 +121,8 @@ function AuthDomain(): JSX.Element {
},
{
title: 'Enforce SSO',
dataIndex: 'enabled',
key: 'enabled',
dataIndex: ['config', 'ssoEnabled'],
key: 'ssoEnabled',
width: 80,
render: (
value: boolean,
@@ -157,15 +157,13 @@ function AuthDomain(): JSX.Element {
className="auth-domain-list-action-link"
onClick={(): void => setRecord(record)}
variant="link"
testId="auth-domain-configure"
>
Configure {SSOType.get(record.config?.kind || '')}
Configure {SSOType.get(record.config?.ssoType || '')}
</Button>
<Button
className="auth-domain-list-action-link delete"
onClick={(): void => showDeleteModal(record)}
variant="link"
testId="auth-domain-delete"
>
Delete
</Button>
@@ -179,9 +177,7 @@ function AuthDomain(): JSX.Element {
return (
<div className="auth-domain">
<section className="auth-domain-header">
<h3 className="auth-domain-title" data-testid="auth-domain-title">
Authenticated Domains
</h3>
<h3 className="auth-domain-title">Authenticated Domains</h3>
<Button
prefix={<Plus size="md" />}
onClick={(): void => {
@@ -190,7 +186,6 @@ function AuthDomain(): JSX.Element {
variant="solid"
size="sm"
color="primary"
testId="auth-domain-add"
>
Add Domain
</Button>
@@ -200,14 +195,7 @@ function AuthDomain(): JSX.Element {
<Table
columns={columns}
dataSource={authDomainListResponse?.data}
onRow={(
record: AuthtypesGettableAuthDomainDTO,
): HTMLAttributes<HTMLElement> =>
// data-* attributes are valid row props but absent from the antd typing
({
'data-testid': `auth-domain-row-${record.name}`,
}) as unknown as HTMLAttributes<HTMLElement>
}
onRow={undefined}
loading={
isLoadingAuthDomainListResponse || isFetchingAuthDomainListResponse
}
@@ -240,7 +228,6 @@ function AuthDomain(): JSX.Element {
onClick={hideDeleteModal}
className="cancel-btn"
prefix={<X size={16} />}
testId="auth-domain-delete-cancel"
>
Cancel
</Button>,
@@ -250,7 +237,6 @@ function AuthDomain(): JSX.Element {
onClick={handleDeleteDomain}
className="delete-btn"
loading={isLoading}
testId="auth-domain-delete-confirm"
>
Delete Domain
</Button>,

View File

@@ -2,17 +2,14 @@ import { useTranslation } from 'react-i18next';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Form, Select, Space } from 'antd';
import { FeatureKeys } from 'constants/features';
import { ModalFooterTitle } from 'container/PipelinePage/styles';
import { useAppContext } from 'providers/App/App';
import { ProcessorData } from 'types/api/pipeline/def';
import { formValidationRules } from '../config';
import { ProcessorFormField } from './config';
import { processorFields, ProcessorFormField } from './config';
import CSVInput from './FormFields/CSVInput';
import JsonFlattening from './FormFields/JsonFlattening';
import { FormWrapper, PipelineIndexIcon, StyledSelect } from './styles';
import { resolveProcessorFields } from './utils';
import './styles.scss';
@@ -136,23 +133,16 @@ function ProcessorForm({
selectedProcessorData,
isAdd,
}: ProcessorFormProps): JSX.Element {
const { featureFlags } = useAppContext();
const isBodyJsonEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
return (
<div className="processor-form-container">
{resolveProcessorFields(processorType, isBodyJsonEnabled).map(
(fieldData: ProcessorFormField) => (
<ProcessorFieldInput
key={fieldData.name + String(fieldData.initialValue)}
fieldData={fieldData}
selectedProcessorData={selectedProcessorData}
isAdd={isAdd}
/>
),
)}
{processorFields[processorType]?.map((fieldData: ProcessorFormField) => (
<ProcessorFieldInput
key={fieldData.name + String(fieldData.initialValue)}
fieldData={fieldData}
selectedProcessorData={selectedProcessorData}
isAdd={isAdd}
/>
))}
</div>
);
}

View File

@@ -1,24 +0,0 @@
import { processorFields, ProcessorFormField } from './config';
const BODY_PARSE_FROM = 'body';
const JSON_BODY_PARSE_FROM = 'body.message';
// With use_json_body the collector normalizes every body into a map before user
// operators run, so a parser pointed at `body` gets a map it cannot read and
// silently extracts nothing. The log text lives at body.message.
export function resolveProcessorFields(
processorType: string,
isBodyJsonEnabled: boolean,
): Array<ProcessorFormField> {
const fields = processorFields[processorType] ?? [];
if (!isBodyJsonEnabled) {
return fields;
}
return fields.map((field) =>
field.name === 'parse_from' && field.initialValue === BODY_PARSE_FROM
? { ...field, initialValue: JSON_BODY_PARSE_FROM }
: field,
);
}

View File

@@ -1,45 +0,0 @@
import { processorFields } from '../PipelineListsView/AddNewProcessor/config';
import { resolveProcessorFields } from '../PipelineListsView/AddNewProcessor/utils';
const parseFromDefault = (
fields: ReturnType<typeof resolveProcessorFields>,
): unknown => fields.find((field) => field.name === 'parse_from')?.initialValue;
describe('resolveProcessorFields', () => {
it.each(['grok_parser', 'regex_parser', 'json_parser'])(
'defaults %s parse_from to body.message when use_json_body is on',
(processorType) => {
expect(parseFromDefault(resolveProcessorFields(processorType, true))).toBe(
'body.message',
);
},
);
it.each(['grok_parser', 'regex_parser', 'json_parser'])(
'keeps %s parse_from as body when use_json_body is off',
(processorType) => {
expect(parseFromDefault(resolveProcessorFields(processorType, false))).toBe(
'body',
);
},
);
it('leaves parse_from defaults that do not point at the body alone', () => {
expect(parseFromDefault(resolveProcessorFields('time_parser', true))).toBe(
'attributes.timestamp',
);
expect(
parseFromDefault(resolveProcessorFields('severity_parser', true)),
).toBe('attributes.logLevel');
});
it('does not mutate the shared config', () => {
resolveProcessorFields('grok_parser', true);
expect(parseFromDefault(processorFields.grok_parser)).toBe('body');
});
it('returns an empty list for an unknown processor type', () => {
expect(resolveProcessorFields('does_not_exist', true)).toStrictEqual([]);
});
});

View File

@@ -1,88 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import useBaseAggregateOptions from '../useBaseAggregateOptions';
const mockGetUpdatedQuery = jest.fn();
const mockNotificationsError = jest.fn();
jest.mock('container/GridCardLayout/useResolveQuery', () => ({
__esModule: true,
default: (): unknown => ({
getUpdatedQuery: mockGetUpdatedQuery,
isLoading: false,
}),
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): unknown => ({
notifications: { error: mockNotificationsError },
}),
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): unknown => ({ dashboardData: undefined }),
}));
jest.mock('hooks/dashboard/useContextVariables', () => ({
__esModule: true,
default: (): unknown => ({ processedVariables: {} }),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): unknown => ({ safeNavigate: jest.fn() }),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({ pathname: '/services/socky-api' }),
}));
const QUERY = {
builder: {
queryData: [{ queryName: 'A', dataSource: 'traces', aggregations: [] }],
},
} as unknown as Query;
const AGGREGATE_DATA = { queryName: 'A', filters: [] };
const renderOptions = (): ReturnType<typeof renderHook> =>
renderHook(() =>
useBaseAggregateOptions({
query: QUERY,
onClose: jest.fn(),
subMenu: '',
setSubMenu: jest.fn(),
aggregateData: AGGREGATE_DATA,
fieldVariables: {},
}),
);
describe('useBaseAggregateOptions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('notifies and keeps the unresolved query when variable resolution fails', async () => {
mockGetUpdatedQuery.mockRejectedValue(
new Error('syntax errors in expression'),
);
renderOptions();
await waitFor(() =>
expect(mockNotificationsError).toHaveBeenCalledWith({
message: 'Unable to resolve variables',
}),
);
});
it('does not notify when variable resolution succeeds', async () => {
mockGetUpdatedQuery.mockResolvedValue(QUERY);
renderOptions();
await waitFor(() => expect(mockGetUpdatedQuery).toHaveBeenCalled());
expect(mockNotificationsError).not.toHaveBeenCalled();
});
});

View File

@@ -6,7 +6,6 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
import useContextVariables from 'hooks/dashboard/useContextVariables';
import { useNotifications } from 'hooks/useNotifications';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ContextLinksData } from 'types/api/dashboard/getAll';
@@ -51,25 +50,23 @@ const useBaseAggregateOptions = ({
const { getUpdatedQuery, isLoading: isResolveQueryLoading } =
useUpdatedQuery();
const { dashboardData } = useDashboardStore();
const { notifications } = useNotifications();
useEffect(() => {
if (!aggregateData) {
return;
}
getUpdatedQuery({
widgetConfig: {
query,
panelTypes: panelType || PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME',
},
dashboardData,
})
.then(setResolvedQuery)
.catch(() => {
setResolvedQuery(query);
notifications.error({ message: 'Unable to resolve variables' });
const resolveQuery = async (): Promise<void> => {
const updatedQuery = await getUpdatedQuery({
widgetConfig: {
query,
panelTypes: panelType || PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME',
},
dashboardData,
});
setResolvedQuery(updatedQuery);
};
resolveQuery();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, aggregateData, panelType]);

View File

@@ -10,6 +10,7 @@ import ServiceAccountsSettings from '../ServiceAccountsSettings';
const SA_LIST_ENDPOINT = '*/api/v1/service_accounts';
const SA_ENDPOINT = '*/api/v1/service_accounts/:id';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
const ROLES_ENDPOINT = '*/api/v1/roles';
const mockServiceAccountsAPI = [
@@ -17,7 +18,7 @@ const mockServiceAccountsAPI = [
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
serviceAccountRoles: [],
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: 1700000000,
updatedAt: 1700000001,
@@ -26,7 +27,7 @@ const mockServiceAccountsAPI = [
id: 'sa-2',
name: 'Monitoring Agent',
email: 'monitor@signoz.io',
serviceAccountRoles: [],
roles: ['signoz-viewer'],
status: 'ACTIVE',
createdAt: 1700000002,
updatedAt: 1700000003,
@@ -35,7 +36,7 @@ const mockServiceAccountsAPI = [
id: 'sa-3',
name: 'Legacy Bot',
email: 'legacy@signoz.io',
serviceAccountRoles: [],
roles: ['signoz-editor'],
status: 'DISABLED',
createdAt: 1700000004,
updatedAt: 1700000005,
@@ -60,6 +61,9 @@ describe('ServiceAccountsSettings (integration)', () => {
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),

View File

@@ -10,7 +10,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
} from '../utils/getUnstableCurrentSearchParams';
import { queryClient, TestWrapper } from './testUtils';
const mockSafeNavigate = jest.fn();

View File

@@ -10,7 +10,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
} from '../utils/getUnstableCurrentSearchParams';
import { queryClient, TestWrapper } from './testUtils';
const mockSafeNavigate = jest.fn();

View File

@@ -24,7 +24,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
} from '../utils/getUnstableCurrentSearchParams';
const queryClient = new QueryClient({
defaultOptions: {

View File

@@ -5,7 +5,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
} from '../utils/getUnstableCurrentSearchParams';
import { queryClient, TestWrapper } from './testUtils';
const mockSafeNavigate = jest.fn();

View File

@@ -10,7 +10,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
} from '../utils/getUnstableCurrentSearchParams';
import { queryClient, TestWrapper, createMockMoment } from './testUtils';
const mockSafeNavigate = jest.fn();

View File

@@ -54,7 +54,7 @@ import {
Time,
TimeRange,
} from './types';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { getUnstableCurrentSearchParams } from './utils/getUnstableCurrentSearchParams';
import './DateTimeSelectionV2.styles.scss';

View File

@@ -95,6 +95,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(updatedQuery)),
);
params.set(QueryParams.panelTypes, widget.panelTypes);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);

View File

@@ -1,54 +0,0 @@
import { Router } from 'react-router-dom';
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { createMemoryHistory } from 'history';
import { useGetPanelTypesQueryParam } from './useGetPanelTypesQueryParam';
const renderWithSearch = (
search: string,
defaultPanelType?: PANEL_TYPES,
): PANEL_TYPES | null => {
const history = createMemoryHistory({
initialEntries: [`/logs/logs-explorer${search}`],
});
const { result } = renderHook(
() => useGetPanelTypesQueryParam(defaultPanelType),
{
wrapper: ({ children }) => <Router history={history}>{children}</Router>,
},
);
return result.current;
};
describe('useGetPanelTypesQueryParam', () => {
it('reads a JSON encoded panel type, as written by the explorers', () => {
expect(renderWithSearch('?panelTypes=%22table%22', PANEL_TYPES.LIST)).toBe(
PANEL_TYPES.TABLE,
);
});
it('reads a plain string panel type, as written by the alerts flow', () => {
expect(renderWithSearch('?panelTypes=graph', PANEL_TYPES.LIST)).toBe(
PANEL_TYPES.TIME_SERIES,
);
});
it('falls back to the default for an unparseable panel type', () => {
expect(renderWithSearch('?panelTypes=%7Bfoo', PANEL_TYPES.LIST)).toBe(
PANEL_TYPES.LIST,
);
});
it('falls back to the default for a value that is not a panel type', () => {
expect(renderWithSearch('?panelTypes=%22nope%22', PANEL_TYPES.LIST)).toBe(
PANEL_TYPES.LIST,
);
});
it('falls back to the default when the param is absent', () => {
expect(renderWithSearch('', PANEL_TYPES.LIST)).toBe(PANEL_TYPES.LIST);
});
});

View File

@@ -3,24 +3,6 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
const PANEL_TYPE_VALUES = new Set<string>(Object.values(PANEL_TYPES));
// The param is JSON encoded by the explorers and written as a plain string by the
// alerts flow, so accept both and treat anything unrecognised as absent.
const parsePanelType = (value: string): PANEL_TYPES | null => {
let parsed: unknown = value;
try {
parsed = JSON.parse(value);
} catch {
parsed = value;
}
return typeof parsed === 'string' && PANEL_TYPE_VALUES.has(parsed)
? (parsed as PANEL_TYPES)
: null;
};
export const useGetPanelTypesQueryParam = <T extends PANEL_TYPES | undefined>(
defaultPanelType?: T,
): T extends undefined ? PANEL_TYPES | null : PANEL_TYPES => {
@@ -29,10 +11,6 @@ export const useGetPanelTypesQueryParam = <T extends PANEL_TYPES | undefined>(
return useMemo(() => {
const panelTypeQuery = urlQuery.get(QueryParams.panelTypes);
return (
(panelTypeQuery ? parsePanelType(panelTypeQuery) : null) ?? defaultPanelType
);
}, [urlQuery, defaultPanelType]) as T extends undefined
? PANEL_TYPES | null
: PANEL_TYPES;
return panelTypeQuery ? JSON.parse(panelTypeQuery) : defaultPanelType;
}, [urlQuery, defaultPanelType]);
};

View File

@@ -1,23 +1,19 @@
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import {
useCreateServiceAccountRole,
useDeleteServiceAccountRole,
useGetServiceAccount,
getGetServiceAccountRolesQueryKey,
useCreateServiceAccountRoleDeprecated,
useDeleteServiceAccountRoleDeprecated,
useGetServiceAccountRoles,
} from 'api/generated/services/serviceaccount';
import type {
AuthtypesGettableRoleDTO,
ServiceaccounttypesServiceAccountRoleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import { retryOn429 } from 'utils/errorUtils';
const enum PromiseStatus {
Fulfilled = 'fulfilled',
Rejected = 'rejected',
}
// Stable identity so the memos below do not recompute on every render.
const EMPTY_SERVICE_ACCOUNT_ROLES: ServiceaccounttypesServiceAccountRoleDTO[] =
[];
export interface RoleUpdateFailure {
roleName: string;
error: unknown;
@@ -37,40 +33,34 @@ export function useServiceAccountRoleManager(
accountId: string,
options?: { enabled?: boolean },
): UseServiceAccountRoleManagerResult {
const { data, isLoading } = useGetServiceAccount(
const queryClient = useQueryClient();
const { data, isLoading } = useGetServiceAccountRoles(
{ id: accountId },
{ query: { enabled: options?.enabled ?? true } },
);
const serviceAccountRoles =
data?.data?.serviceAccountRoles ?? EMPTY_SERVICE_ACCOUNT_ROLES;
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
() =>
serviceAccountRoles.map((serviceAccountRole) => serviceAccountRole.role),
[serviceAccountRoles],
);
// DELETE /api/v1/service_account_roles/{id} is keyed by the join row, not the role.
const assignmentIdByRoleId = useMemo(
() =>
new Map(
serviceAccountRoles.map((serviceAccountRole) => [
serviceAccountRole.roleId,
serviceAccountRole.id,
]),
),
[serviceAccountRoles],
() => data?.data ?? [],
[data?.data],
);
// the retry for these mutations is safe due to being idempotent on backend
const { mutateAsync: createRole } = useCreateServiceAccountRole({
const { mutateAsync: createRole } = useCreateServiceAccountRoleDeprecated({
mutation: { retry: retryOn429 },
});
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
const { mutateAsync: deleteRole } = useDeleteServiceAccountRoleDeprecated({
mutation: { retry: retryOn429 },
});
const invalidateRoles = useCallback(
() =>
queryClient.invalidateQueries(
getGetServiceAccountRolesQueryKey({ id: accountId }),
),
[accountId, queryClient],
);
const applyDiff = useCallback(
async (
localRoleIds: string[],
@@ -94,34 +84,26 @@ export function useServiceAccountRoleManager(
...addedRoles.map((role) => ({
role,
run: (): ReturnType<typeof createRole> =>
createRole({
data: { serviceAccountId: accountId, roleId: role.id ?? '' },
}),
createRole({ pathParams: { id: accountId }, data: { id: role.id } }),
})),
...removedRoles.map((role) => ({
role,
run: (): ReturnType<typeof deleteRole> =>
deleteRole({ pathParams: { id: accountId, rid: role.id ?? '' } }),
})),
...removedRoles
.map((role) => ({
role,
assignmentId: assignmentIdByRoleId.get(role.id ?? ''),
}))
.filter(
(
entry,
): entry is {
role: AuthtypesGettableRoleDTO;
assignmentId: string;
} => !!entry.assignmentId,
)
.map(({ role, assignmentId }) => ({
role,
run: (): ReturnType<typeof deleteRole> =>
deleteRole({ pathParams: { id: assignmentId } }),
})),
];
const results = await Promise.allSettled(
allOperations.map((op) => op.run()),
);
const successCount = results.filter(
(r) => r.status === PromiseStatus.Fulfilled,
).length;
if (successCount > 0) {
await invalidateRoles();
}
const failures: RoleUpdateFailure[] = [];
results.forEach((result, index) => {
if (result.status === PromiseStatus.Rejected) {
@@ -131,6 +113,7 @@ export function useServiceAccountRoleManager(
error: result.reason,
onRetry: async (): Promise<void> => {
await run();
await invalidateRoles();
},
});
}
@@ -138,7 +121,7 @@ export function useServiceAccountRoleManager(
return failures;
},
[accountId, currentRoles, assignmentIdByRoleId, createRole, deleteRole],
[accountId, currentRoles, createRole, deleteRole, invalidateRoles],
);
return {

View File

@@ -15,8 +15,6 @@ import store from 'store';
import APIError from 'types/api/error';
import { installTranslationResilience } from 'translation-resilience';
import 'lib/monaco/setup';
import './ReactI18';
import 'styles.scss';

View File

@@ -1,21 +0,0 @@
import { loader } from '@monaco-editor/react';
import * as monaco from 'monaco-editor';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
// Serve Monaco's workers from our own origin instead of letting @monaco-editor/loader
// fetch them from cdn.jsdelivr.net at runtime. The CDN default breaks the editor in
// air-gapped/on-prem installs, CDN-blocking corporate networks, and regions where
// jsdelivr is unreachable. Ref: engineering-pod#5871, SIGNOZ-UI-5G0.
self.MonacoEnvironment = {
getWorker(_workerId: string, label: string): Worker {
if (label === 'json') {
return new JsonWorker(); // JSON language service
}
// SigNoz editors use JSON + a hand-registered ClickHouse tokenizer (no worker),
// so the base editor worker covers everything else.
return new EditorWorker(); // base worker — sql, yaml, plaintext etc.
},
};
loader.config({ monaco });

View File

@@ -189,8 +189,7 @@ function DashboardActions({
onClick: (): void => void handleClone(),
});
}
if (canEditDashboard && (isAuthor || user.role === USER_ROLES.ADMIN)) {
if (isAuthor || user.role === USER_ROLES.ADMIN) {
dashboardGroup.push({
key: 'lock',
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',

View File

@@ -125,37 +125,6 @@ describe('NumberPanelRenderer', () => {
expect(queryByText('3.14159')).not.toBeInTheDocument();
});
// #7669: large scalars are unreadable as an undelimited digit run.
it('groups large values into thousands', () => {
const { getByText, queryByText } = renderPanel({
panel: panelWith({}),
data: dataWith('1234567'),
});
expect(getByText('1,234,567')).toBeInTheDocument();
expect(queryByText('1234567')).not.toBeInTheDocument();
});
it('groups the value while keeping its unit separate', () => {
const { getByText } = renderPanel({
panel: panelWith({ formatting: { unit: 'percent' } }),
data: dataWith('1234567'),
});
expect(getByText('1,234,567')).toBeInTheDocument();
expect(getByText('%')).toBeInTheDocument();
});
it('leaves a unit-scaled value ungrouped', () => {
const { getByText } = renderPanel({
panel: panelWith({ formatting: { unit: 'bytes' } }),
data: dataWith('1234567'),
});
expect(getByText('1.18')).toBeInTheDocument();
expect(getByText('MiB')).toBeInTheDocument();
});
it('renders No Data when the response has no scalar results', () => {
const { getByTestId } = renderPanel({ data: emptyData });

View File

@@ -93,15 +93,6 @@ describe('TablePanelRenderer', () => {
expect(getByText('cartservice')).toBeInTheDocument();
});
// Value cells share `formatPanelValue`, so they group like the Number panel.
it('groups large value cells into thousands', () => {
const { getByText } = renderPanel({
data: dataWith([['frontend', 1234567]]),
});
expect(getByText('1,234,567')).toBeInTheDocument();
});
it('renders No Data when the response has no scalar results', () => {
const { getByTestId } = renderPanel({ data: emptyData });

View File

@@ -30,14 +30,4 @@ describe('formatPanelValue', () => {
it('renders whole numbers without a trailing decimal', () => {
expect(formatPanelValue(5, undefined, 2)).toBe('5');
});
it('groups the integer part into thousands', () => {
expect(formatPanelValue(1234567, undefined, 2)).toBe('1,234,567');
expect(formatPanelValue(1234567, 'percent', 2)).toBe('1,234,567%');
expect(formatPanelValue(1234567.891, undefined, 2)).toBe('1,234,567.89');
});
it('leaves unit-scaled values ungrouped', () => {
expect(formatPanelValue(1234567, 'bytes', 2)).toBe('1.18 MiB');
});
});

View File

@@ -1,55 +0,0 @@
import { groupThousands } from '../groupThousands';
describe('groupThousands', () => {
it('groups the integer digits of a plain number', () => {
expect(groupThousands('1234567')).toBe('1,234,567');
expect(groupThousands('1000')).toBe('1,000');
expect(groupThousands('1000000000000000000000')).toBe(
'1,000,000,000,000,000,000,000',
);
});
it('leaves values below a thousand alone', () => {
expect(groupThousands('0')).toBe('0');
expect(groupThousands('999')).toBe('999');
expect(groupThousands('295.43')).toBe('295.43');
});
it('groups only the integer part', () => {
expect(groupThousands('1234567.891')).toBe('1,234,567.891');
expect(groupThousands('1234.0001234')).toBe('1,234.0001234');
});
it('keeps the sign outside the first group', () => {
expect(groupThousands('-1234567')).toBe('-1,234,567');
expect(groupThousands('-1234567.891')).toBe('-1,234,567.891');
});
it('preserves suffix and prefix unit decoration', () => {
expect(groupThousands('1234567 ms')).toBe('1,234,567 ms');
expect(groupThousands('1234567%')).toBe('1,234,567%');
expect(groupThousands('$ 1234567')).toBe('$ 1,234,567');
});
it('leaves formatter-scaled values untouched', () => {
expect(groupThousands('1.18 MiB')).toBe('1.18 MiB');
expect(groupThousands('1.23 Mil')).toBe('1.23 Mil');
expect(groupThousands('20.58 mins')).toBe('20.58 mins');
});
it('leaves exponent notation untouched', () => {
expect(groupThousands('1.234567e+21')).toBe('1.234567e+21');
expect(groupThousands('1234567e-8')).toBe('1234567e-8');
});
it('returns non-numeric output unchanged', () => {
expect(groupThousands('∞')).toBe('∞');
expect(groupThousands('-∞')).toBe('-∞');
expect(groupThousands('NaN')).toBe('NaN');
expect(groupThousands('')).toBe('');
});
it('is idempotent on already-grouped input', () => {
expect(groupThousands(groupThousands('1234567'))).toBe('1,234,567');
});
});

View File

@@ -1,60 +0,0 @@
import { parseFormattedValue } from '../parseFormattedValue';
describe('parseFormattedValue', () => {
it('splits a trailing unit label off the numeric core', () => {
expect(parseFormattedValue('295.43 ms')).toStrictEqual({
numericValue: '295.43',
prefixUnit: '',
suffixUnit: 'ms',
});
});
it('splits a leading currency symbol off the numeric core', () => {
expect(parseFormattedValue('$ 1.2K')).toStrictEqual({
numericValue: '1.2K',
prefixUnit: '$',
suffixUnit: '',
});
});
// Regression: the numeric core used to reject `,`, so a grouped value fell
// through to the whole-string fallback and lost its unit split.
it('keeps the unit split for grouped values', () => {
expect(parseFormattedValue('1,234,567 ms')).toStrictEqual({
numericValue: '1,234,567',
prefixUnit: '',
suffixUnit: 'ms',
});
expect(parseFormattedValue('1,234,567%')).toStrictEqual({
numericValue: '1,234,567',
prefixUnit: '',
suffixUnit: '%',
});
expect(parseFormattedValue('$ 1,234,567.89')).toStrictEqual({
numericValue: '1,234,567.89',
prefixUnit: '$',
suffixUnit: '',
});
});
it('treats a unitless value as the numeric core', () => {
expect(parseFormattedValue('1,234,567')).toStrictEqual({
numericValue: '1,234,567',
prefixUnit: '',
suffixUnit: '',
});
});
it('falls back to the whole string when nothing matches', () => {
expect(parseFormattedValue('∞')).toStrictEqual({
numericValue: '∞',
prefixUnit: '',
suffixUnit: '',
});
expect(parseFormattedValue('NaN')).toStrictEqual({
numericValue: 'NaN',
prefixUnit: '',
suffixUnit: '',
});
});
});

View File

@@ -1,21 +1,16 @@
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { groupThousands } from './groupThousands';
/**
* Formats a scalar for display in a V2 panel, honoring decimal precision and
* grouping the integer part into thousands. The single seam through which panels
* touch `getYAxisFormattedValue`. Unitless values format through the `'none'`
* unit, which still respects precision — so precision isn't silently dropped
* when no unit is set.
* Formats a scalar for display in a V2 panel, honoring decimal precision. The
* single seam through which panels touch `getYAxisFormattedValue`. Unitless
* values format through the `'none'` unit, which still respects precision — so
* precision isn't silently dropped when no unit is set.
*/
export function formatPanelValue(
value: number,
unit?: string,
precision?: PrecisionOption,
): string {
return groupThousands(
getYAxisFormattedValue(String(value), unit || 'none', precision),
);
return getYAxisFormattedValue(String(value), unit || 'none', precision);
}

View File

@@ -1,22 +0,0 @@
const THOUSANDS_BOUNDARY = /(\d)(?=(?:\d{3})+$)/g;
/** Leading number of a formatted value; unit decoration falls outside the match. */
const NUMERIC_TOKEN = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/;
/**
* Inserts thousand separators into the integer part of an already-formatted value,
* so large scalars read as `1,234,567`. Fractions, unit labels and formatter-scaled
* values (`1.18 MiB`) are left as-is, as is exponent notation.
*/
export function groupThousands(formatted: string): string {
return formatted.replace(NUMERIC_TOKEN, (token) => {
if (token.includes('e') || token.includes('E')) {
return token;
}
const [integerPart, fraction] = token.split('.');
const grouped = integerPart.replace(THOUSANDS_BOUNDARY, '$1,');
return fraction === undefined ? grouped : `${grouped}.${fraction}`;
});
}

View File

@@ -1,5 +1,5 @@
export interface ParsedFormattedValue {
/** The numeric portion (e.g. "295.43", "1,234,567", "1.2K"). */
/** The numeric portion (e.g. "295.43", "1.2K"). */
numericValue: string;
/** A leading unit symbol such as a currency prefix, if any. */
prefixUnit: string;
@@ -8,14 +8,13 @@ export interface ParsedFormattedValue {
}
/**
* Splits a formatted value (e.g. "$ 1.2K", "295.43 ms", "1,234,567") into its
* numeric core and prefix/suffix unit for independent styling. The core accepts
* thousand separators, so a grouped value keeps its unit split. Non-matching
* input falls back to the whole string as the numeric value.
* Splits a formatted value (e.g. "$ 1.2K", "295.43 ms") into its numeric core
* and prefix/suffix unit for independent styling. Non-matching input falls back
* to the whole string as the numeric value.
*/
export function parseFormattedValue(value: string): ParsedFormattedValue {
const matches = value.match(
/^([^\d.]*)?([\d.,]+(?:[eE][+-]?[\d]+)?[KMB]?)([^\d.]*)?$/,
/^([^\d.]*)?([\d.]+(?:[eE][+-]?[\d]+)?[KMB]?)([^\d.]*)?$/,
);
return {

View File

@@ -46,11 +46,23 @@ beforeAll(() => {
});
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () =>
jest
.requireActual('tests/browser-history-safe-navigate')
.createBrowserHistorySafeNavigateMock(),
);
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory } = jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useHistory();
return {
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
if (opts?.replace) {
history.replace(to);
} else {
history.push(to);
}
},
};
},
};
});
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest
@@ -192,12 +204,9 @@ function Harness(): JSX.Element {
);
}
const INITIAL_ROUTE = '/dashboard/dash-1';
const renderHarness = (): void => {
window.history.replaceState(null, '', INITIAL_ROUTE);
render(
<MemoryRouter initialEntries={[INITIAL_ROUTE]}>
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<CompatRouter>
<QueryClientProvider client={new QueryClient()}>
<ReduxProvider store={configureStore([])(appStore.getState())}>

View File

@@ -16,11 +16,23 @@ import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
import { useViewPanel } from '../hooks/useViewPanel';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () =>
jest
.requireActual('tests/browser-history-safe-navigate')
.createBrowserHistorySafeNavigateMock(),
);
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory } = jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useHistory();
return {
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
if (opts?.replace) {
history.replace(to);
} else {
history.push(to);
}
},
};
},
};
});
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery',
@@ -138,12 +150,9 @@ function Harness(): JSX.Element {
);
}
const INITIAL_ROUTE = '/dashboard/dash-1';
const renderHarness = (): void => {
window.history.replaceState(null, '', INITIAL_ROUTE);
render(
<MemoryRouter initialEntries={[INITIAL_ROUTE]}>
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<CompatRouter>
<QueryClientProvider client={new QueryClient()}>
<ReduxProvider store={configureStore([])(appStore.getState())}>

View File

@@ -14,11 +14,23 @@ import { useViewPanelMode } from '../ViewPanelModal/useViewPanelMode';
import { useViewPanel } from '../hooks/useViewPanel';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () =>
jest
.requireActual('tests/browser-history-safe-navigate')
.createBrowserHistorySafeNavigateMock(),
);
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory } = jest.requireActual('react-router-dom');
return {
useSafeNavigate: (): unknown => {
const history = useHistory();
return {
safeNavigate: (to: string, opts?: { replace?: boolean }): void => {
if (opts?.replace) {
history.replace(to);
} else {
history.push(to);
}
},
};
},
};
});
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery',
@@ -172,12 +184,9 @@ function Harness(): JSX.Element {
);
}
const INITIAL_ROUTE = '/dashboard/dash-1';
const renderHarness = (): void => {
window.history.replaceState(null, '', INITIAL_ROUTE);
render(
<MemoryRouter initialEntries={[INITIAL_ROUTE]}>
<MemoryRouter initialEntries={['/dashboard/dash-1']}>
<CompatRouter>
<QueryBuilderProvider>
<Harness />

View File

@@ -168,6 +168,7 @@ describe('useCreateAlertFromPanel', () => {
// The resolved query is seeded with the panel-derived alert prefill.
expect(mockBuildAlertUrl).toHaveBeenCalledWith(
{ resolved: 'query' },
PANEL_TYPES.TIME_SERIES,
undefined,
mockPrefill,
);

View File

@@ -79,6 +79,7 @@ export function useCreateAlertFromPanel(): (
const unit = readPanelUnit(panel.spec.plugin);
const url = buildAlertUrl(
query,
panelType,
unit,
deriveAlertPrefill(panel, query, unit),
);

View File

@@ -65,19 +65,14 @@ describe('buildCreateAlertUrl', () => {
);
});
it('tags the URL with the v5 version and the dashboards source', () => {
it('tags the URL with panel type, v5 version, and the dashboards source', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
expect(params.get(QueryParams.panelTypes)).toBe(PANEL_TYPES.TIME_SERIES);
expect(params.get(QueryParams.version)).toBe(ENTITY_VERSION_V5);
expect(params.get(QueryParams.source)).toBe('dashboards');
});
it('does not tag the URL with a panel type, which the alert page ignores', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
expect(params.get(QueryParams.panelTypes)).toBeNull();
});
it('encodes the translated query as the compositeQuery param', () => {
const params = parse(buildCreateAlertUrl(makePanel()));

View File

@@ -5,6 +5,7 @@ import type {
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
@@ -33,6 +34,7 @@ export function readPanelUnit(
*/
export function buildAlertUrl(
query: Query,
panelType: PANEL_TYPES,
unit?: string,
prefill?: PanelAlertPrefill,
): string {
@@ -46,6 +48,7 @@ export function buildAlertUrl(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
params.set(QueryParams.panelTypes, panelType);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);
@@ -73,5 +76,10 @@ export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const query = fromPerses(panel.spec.queries, panelType);
const unit = readPanelUnit(panel.spec.plugin);
return buildAlertUrl(query, unit, deriveAlertPrefill(panel, query, unit));
return buildAlertUrl(
query,
panelType,
unit,
deriveAlertPrefill(panel, query, unit),
);
}

View File

@@ -19,20 +19,11 @@ import { resolveDashboardImage } from 'pages/DashboardPageV2/DashboardContainer/
interface DashboardContainerProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
refetch: () => void;
/**
* @deprecated
* `canEditDashboardOverride` is a temporary solution to allow the dashboard to be view only.
* This is only used for LLM Observability.
* It will be removed in the future.
* TODO: @Ashwin / @Abhi — remove when the final solution is implemented.
*/
canEditDashboardOverride?: boolean;
}
function DashboardContainer({
dashboard,
refetch,
canEditDashboardOverride,
}: DashboardContainerProps): JSX.Element {
const spec = dashboard.spec;
const image = resolveDashboardImage(dashboard.image);
@@ -54,11 +45,10 @@ function DashboardContainer({
// Seed during render (not an effect) so the first Panel render already sees the id —
// useDashboardFetchRequired throws on a missing id. setEditContext self-guards.
const setEditContext = useDashboardStore((s) => s.setEditContext);
setEditContext({
dashboardId: dashboard.id,
isLocked,
canEditDashboard: canEditDashboardOverride ?? canEditDashboard,
canEditDashboard,
refetch,
});

View File

@@ -690,7 +690,7 @@ function Success(props: ISuccessProps): JSX.Element {
urlQuery.set('spanId', span?.span_id);
}
safeNavigate({ search: urlQuery.toString() }, { replace: true });
safeNavigate({ search: urlQuery.toString() });
},
[setSelectedSpan, urlQuery, safeNavigate],
);

View File

@@ -260,13 +260,11 @@ describe('Span Click User Flows', () => {
) as HTMLElement;
await user.click(spanElement);
// Verify URL was updated with spanId
expect(mockUrlQuery.get('spanId')).toBe('span-1');
expect(mockSafeNavigate).toHaveBeenCalledWith(
{
search: expect.stringContaining('spanId=span-1'),
},
{ replace: true },
);
expect(mockSafeNavigate).toHaveBeenCalledWith({
search: expect.stringContaining('spanId=span-1'),
});
});
it('clicking span duration visually selects the span', async () => {
@@ -432,13 +430,10 @@ describe('Span Click User Flows', () => {
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
expect(mockUrlQuery.get('spanId')).toBe('span-1');
expect(mockSafeNavigate).toHaveBeenCalledWith(
{
search: expect.stringMatching(
/existingParam=existingValue.*anotherParam=anotherValue.*spanId=span-1/,
),
},
{ replace: true },
);
expect(mockSafeNavigate).toHaveBeenCalledWith({
search: expect.stringMatching(
/existingParam=existingValue.*anotherParam=anotherValue.*spanId=span-1/,
),
});
});
});

View File

@@ -80,7 +80,7 @@ function TraceDetailsV3(): JSX.Element {
const handleSpanDetailsClose = useCallback((): void => {
urlQuery.delete('spanId');
safeNavigate({ search: urlQuery.toString() }, { replace: true });
safeNavigate({ search: urlQuery.toString() });
}, [urlQuery, safeNavigate]);
const handleFilteredSpansChange = useCallback(

View File

@@ -1,4 +1,4 @@
import { CSSProperties, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import logEvent from 'api/common/logEvent';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
@@ -24,23 +24,16 @@ export interface DataViewerProps {
data: Record<string, any>;
drawerKey?: string;
prettyViewProps?: Omit<PrettyViewProps, 'data' | 'drawerKey'>;
// Optional override for the JSON view otherwise `data` is
// stringified and used.
jsonString?: string;
fontSize?: number;
}
function DataViewer({
data,
drawerKey = 'default',
prettyViewProps,
jsonString,
fontSize,
}: DataViewerProps): JSX.Element {
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Pretty);
const derivedJson = useMemo(() => JSON.stringify(data, null, 2), [data]);
const json = jsonString ?? derivedJson;
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
const handleViewModeChange = (value: string): void => {
const next = value as ViewMode;
@@ -62,14 +55,7 @@ function DataViewer({
};
return (
<div
className="data-viewer"
style={
fontSize
? ({ '--data-viewer-font-size': `${fontSize}px` } as CSSProperties)
: undefined
}
>
<div className="data-viewer">
<div className="data-viewer__toolbar">
<ToggleGroupSimple
type="single"
@@ -79,14 +65,14 @@ function DataViewer({
items={VIEW_MODE_OPTIONS}
testId="data-viewer-view-mode"
/>
<CopyButton value={json} ariaLabel="Copy JSON" />
<CopyButton value={jsonString} ariaLabel="Copy JSON" />
</div>
<div className="data-viewer__content">
{viewMode === ViewMode.Pretty && (
<PrettyView data={data} drawerKey={drawerKey} {...prettyViewProps} />
)}
{viewMode === ViewMode.Json && <JsonView data={json} fontSize={fontSize} />}
{viewMode === ViewMode.Json && <JsonView data={jsonString} />}
</div>
</div>
);

Some files were not shown because too many files have changed in this diff Show More