Compare commits

..

7 Commits

Author SHA1 Message Date
Abhishek Kumar Singh
543fe613a5 chore: updated comment 2026-01-19 19:27:53 +05:30
Abhishek Kumar Singh
512e0519ee test: invalid target unit fix in test rule 2026-01-19 18:21:08 +05:30
Abhishek Kumar Singh
d56ab18691 refactor: removed tests for old code breaking CI 2026-01-19 18:14:15 +05:30
Abhishek Kumar Singh
9d95703539 refactor: updated validation for composite query changed from v3-4 to v5 format validation 2026-01-19 18:09:09 +05:30
Abhishek Kumar Singh
7dc54530ce feat: added validation for unit 2026-01-19 18:06:01 +05:30
Abhishek Kumar Singh
e712434e01 refactor: updated assign vars function to not import v3 package 2026-01-19 17:58:30 +05:30
Abhishek Kumar Singh
91ec60b923 chore: added Validate function for QueryBuilderFormula struct 2026-01-19 16:06:40 +05:30
115 changed files with 1442 additions and 8312 deletions

9
.github/CODEOWNERS vendored
View File

@@ -55,6 +55,7 @@
/pkg/telemetrymetrics/ @srikanthccv
/pkg/telemetrytraces/ @srikanthccv
# Metrics
/pkg/types/metrictypes/ @srikanthccv
@@ -90,14 +91,6 @@
# AuthN / AuthZ Owners
/pkg/authz/ @vikrantgupta25
/ee/authz/ @vikrantgupta25
/pkg/authn/ @vikrantgupta25
/ee/authn/ @vikrantgupta25
/pkg/modules/user/ @vikrantgupta25
/pkg/modules/session/ @vikrantgupta25
/pkg/modules/organization/ @vikrantgupta25
/pkg/modules/authdomain/ @vikrantgupta25
/pkg/modules/role/ @vikrantgupta25
# Integration tests

View File

@@ -25,10 +25,3 @@ jobs:
else
echo "No references to 'ee' packages found in 'pkg' directory"
fi
if grep -R --include="*.go" '.*/ee/.*' cmd/community/; then
echo "Error: Found references to 'ee' packages in 'cmd/community' directory"
exit 1
else
echo "No references to 'ee' packages found in 'cmd/community' directory"
fi

4
.gitignore vendored
View File

@@ -1,9 +1,6 @@
node_modules
.vscode
!.vscode/settings.json
deploy/docker/environment_tiny/common_test
frontend/node_modules
frontend/.pnp
@@ -107,6 +104,7 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/

View File

@@ -86,7 +86,7 @@ go-run-enterprise: ## Runs the enterprise go backend server
@SIGNOZ_INSTRUMENTATION_LOGS_LEVEL=debug \
SIGNOZ_SQLSTORE_SQLITE_PATH=signoz.db \
SIGNOZ_WEB_ENABLED=false \
SIGNOZ_TOKENIZER_JWT_SECRET=secret \
SIGNOZ_JWT_SECRET=secret \
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \
@@ -103,7 +103,7 @@ go-run-community: ## Runs the community go backend server
@SIGNOZ_INSTRUMENTATION_LOGS_LEVEL=debug \
SIGNOZ_SQLSTORE_SQLITE_PATH=signoz.db \
SIGNOZ_WEB_ENABLED=false \
SIGNOZ_TOKENIZER_JWT_SECRET=secret \
SIGNOZ_JWT_SECRET=secret \
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \

View File

@@ -5,14 +5,13 @@ import (
"log/slog"
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
"github.com/SigNoz/signoz/ee/authz/openfgaschema"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/authz/openfgaauthz"
"github.com/SigNoz/signoz/pkg/authz/openfgaschema"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/gateway/noopgateway"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
@@ -25,6 +24,7 @@ import (
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
@@ -57,6 +57,13 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
// print the version
version.Info.PrettyPrint(config.Version)
// add enterprise sqlstore factories to the community sqlstore factories
sqlstoreFactories := signoz.NewSQLStoreProviderFactories()
if err := sqlstoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory())); err != nil {
logger.ErrorContext(ctx, "failed to add postgressqlstore factory", "error", err)
return err
}
signoz, err := signoz.New(
ctx,
config,
@@ -83,9 +90,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, _ role.Module, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser)
},
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err)

View File

@@ -10,7 +10,6 @@ import (
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
"github.com/SigNoz/signoz/ee/authz/openfgaschema"
"github.com/SigNoz/signoz/ee/gateway/httpgateway"
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
@@ -23,7 +22,6 @@ import (
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
@@ -122,9 +120,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, role role.Module, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, role, queryParser, querier, licensing)
},
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err)

View File

@@ -195,7 +195,7 @@ services:
- GODEBUG=netdns=go
- TELEMETRY_ENABLED=true
- DEPLOYMENT_TYPE=docker-swarm
- SIGNOZ_TOKENIZER_JWT_SECRET=secret
- SIGNOZ_JWT_SECRET=secret
- DOT_METRICS_ENABLED=true
healthcheck:
test:

View File

@@ -2067,361 +2067,6 @@ paths:
summary: Get features
tags:
- features
/api/v2/gateway/ingestion_keys:
get:
deprecated: false
description: This endpoint returns the ingestion keys for a workspace
operationId: GetIngestionKeys
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/GatewaytypesGettableIngestionKeys'
status:
type: string
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: Get ingestion keys for workspace
tags:
- gateway
post:
deprecated: false
description: This endpoint creates an ingestion key for the workspace
operationId: CreateIngestionKey
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GatewaytypesPostableIngestionKey'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/GatewaytypesGettableCreatedIngestionKey'
status:
type: string
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: Create ingestion key for workspace
tags:
- gateway
/api/v2/gateway/ingestion_keys/{keyId}:
delete:
deprecated: false
description: This endpoint deletes an ingestion key for the workspace
operationId: DeleteIngestionKey
parameters:
- in: path
name: keyId
required: true
schema:
type: string
responses:
"204":
description: No Content
"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 ingestion key for workspace
tags:
- gateway
patch:
deprecated: false
description: This endpoint updates an ingestion key for the workspace
operationId: UpdateIngestionKey
parameters:
- in: path
name: keyId
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GatewaytypesPostableIngestionKey'
responses:
"204":
description: No Content
"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: Update ingestion key for workspace
tags:
- gateway
/api/v2/gateway/ingestion_keys/{keyId}/limits:
post:
deprecated: false
description: This endpoint creates an ingestion key limit
operationId: CreateIngestionKeyLimit
parameters:
- in: path
name: keyId
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GatewaytypesPostableIngestionKeyLimit'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/GatewaytypesGettableCreatedIngestionKeyLimit'
status:
type: string
type: object
description: Created
"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: Create limit for the ingestion key
tags:
- gateway
/api/v2/gateway/ingestion_keys/limits/{limitId}:
delete:
deprecated: false
description: This endpoint deletes an ingestion key limit
operationId: DeleteIngestionKeyLimit
parameters:
- in: path
name: limitId
required: true
schema:
type: string
responses:
"204":
description: No Content
"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 limit for the ingestion key
tags:
- gateway
patch:
deprecated: false
description: This endpoint updates an ingestion key limit
operationId: UpdateIngestionKeyLimit
parameters:
- in: path
name: limitId
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GatewaytypesUpdatableIngestionKeyLimit'
responses:
"204":
description: No Content
"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: Update limit for the ingestion key
tags:
- gateway
/api/v2/gateway/ingestion_keys/search:
get:
deprecated: false
description: This endpoint returns the ingestion keys for a workspace
operationId: SearchIngestionKeys
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/GatewaytypesGettableIngestionKeys'
status:
type: string
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: Search ingestion keys for workspace
tags:
- gateway
/api/v2/metric/alerts:
get:
deprecated: false
@@ -3091,25 +2736,12 @@ paths:
- sessions
components:
schemas:
AuthtypesAttributeMapping:
properties:
email:
type: string
groups:
type: string
name:
type: string
role:
type: string
type: object
AuthtypesAuthDomainConfig:
properties:
googleAuthConfig:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
oidcConfig:
$ref: '#/components/schemas/AuthtypesOIDCConfig'
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
samlConfig:
$ref: '#/components/schemas/AuthtypesSamlConfig'
ssoEnabled:
@@ -3143,6 +2775,11 @@ components:
url:
type: string
type: object
AuthtypesClaimMapping:
properties:
email:
type: string
type: object
AuthtypesDeprecatedGettableLogin:
properties:
accessJwt:
@@ -3174,8 +2811,6 @@ components:
$ref: '#/components/schemas/AuthtypesOIDCConfig'
orgId:
type: string
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
samlConfig:
$ref: '#/components/schemas/AuthtypesSamlConfig'
ssoEnabled:
@@ -3199,33 +2834,17 @@ components:
type: object
AuthtypesGoogleConfig:
properties:
allowedGroups:
items:
type: string
type: array
clientId:
type: string
clientSecret:
type: string
domainToAdminEmail:
additionalProperties:
type: string
type: object
fetchGroups:
type: boolean
fetchTransitiveGroupMembership:
type: boolean
insecureSkipEmailVerified:
type: boolean
redirectURI:
type: string
serviceAccountJson:
type: string
type: object
AuthtypesOIDCConfig:
properties:
claimMapping:
$ref: '#/components/schemas/AuthtypesAttributeMapping'
$ref: '#/components/schemas/AuthtypesClaimMapping'
clientId:
type: string
clientSecret:
@@ -3276,22 +2895,8 @@ components:
refreshToken:
type: string
type: object
AuthtypesRoleMapping:
properties:
defaultRole:
type: string
groupMappings:
additionalProperties:
type: string
nullable: true
type: object
useRoleAttribute:
type: boolean
type: object
AuthtypesSamlConfig:
properties:
attributeMapping:
$ref: '#/components/schemas/AuthtypesAttributeMapping'
insecureSkipAuthNRequestsSigned:
type: boolean
samlCert:
@@ -3406,160 +3011,6 @@ components:
nullable: true
type: object
type: object
GatewaytypesGettableCreatedIngestionKey:
properties:
id:
type: string
value:
type: string
type: object
GatewaytypesGettableCreatedIngestionKeyLimit:
properties:
id:
type: string
type: object
GatewaytypesGettableIngestionKeys:
properties:
_pagination:
$ref: '#/components/schemas/GatewaytypesPagination'
keys:
items:
$ref: '#/components/schemas/GatewaytypesIngestionKey'
nullable: true
type: array
type: object
GatewaytypesIngestionKey:
properties:
created_at:
format: date-time
type: string
expires_at:
format: date-time
type: string
id:
type: string
limits:
items:
$ref: '#/components/schemas/GatewaytypesLimit'
nullable: true
type: array
name:
type: string
tags:
items:
type: string
nullable: true
type: array
updated_at:
format: date-time
type: string
value:
type: string
workspace_id:
type: string
type: object
GatewaytypesLimit:
properties:
config:
$ref: '#/components/schemas/GatewaytypesLimitConfig'
created_at:
format: date-time
type: string
id:
type: string
key_id:
type: string
metric:
$ref: '#/components/schemas/GatewaytypesLimitMetric'
signal:
type: string
tags:
items:
type: string
nullable: true
type: array
updated_at:
format: date-time
type: string
type: object
GatewaytypesLimitConfig:
properties:
day:
$ref: '#/components/schemas/GatewaytypesLimitValue'
second:
$ref: '#/components/schemas/GatewaytypesLimitValue'
type: object
GatewaytypesLimitMetric:
properties:
day:
$ref: '#/components/schemas/GatewaytypesLimitMetricValue'
second:
$ref: '#/components/schemas/GatewaytypesLimitMetricValue'
type: object
GatewaytypesLimitMetricValue:
properties:
count:
format: int64
type: integer
size:
format: int64
type: integer
type: object
GatewaytypesLimitValue:
properties:
count:
format: int64
type: integer
size:
format: int64
type: integer
type: object
GatewaytypesPagination:
properties:
page:
type: integer
pages:
type: integer
per_page:
type: integer
total:
type: integer
type: object
GatewaytypesPostableIngestionKey:
properties:
expires_at:
format: date-time
type: string
name:
type: string
tags:
items:
type: string
nullable: true
type: array
type: object
GatewaytypesPostableIngestionKeyLimit:
properties:
config:
$ref: '#/components/schemas/GatewaytypesLimitConfig'
signal:
type: string
tags:
items:
type: string
nullable: true
type: array
type: object
GatewaytypesUpdatableIngestionKeyLimit:
properties:
config:
$ref: '#/components/schemas/GatewaytypesLimitConfig'
tags:
items:
type: string
nullable: true
type: array
type: object
MetricsexplorertypesMetricAlert:
properties:
alertId:

View File

@@ -2,7 +2,6 @@ package oidccallbackauthn
import (
"context"
"fmt"
"net/url"
"github.com/SigNoz/signoz/pkg/authn"
@@ -20,27 +19,25 @@ const (
redirectPath string = "/api/v1/complete/oidc"
)
var defaultScopes []string = []string{"email", "profile", oidc.ScopeOpenID}
var (
scopes []string = []string{"email", oidc.ScopeOpenID}
)
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
settings factory.ScopedProviderSettings
store authtypes.AuthNStore
licensing licensing.Licensing
httpClient *client.Client
}
func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings) (*AuthN, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn")
httpClient, err := client.New(providerSettings.Logger, providerSettings.TracerProvider, providerSettings.MeterProvider)
if err != nil {
return nil, err
}
return &AuthN{
settings: settings,
store: store,
licensing: licensing,
httpClient: httpClient,
@@ -129,40 +126,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
}
name := ""
if nameClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Name; nameClaim != "" {
if n, ok := claims[nameClaim].(string); ok {
name = n
}
}
var groups []string
if groupsClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Groups; groupsClaim != "" {
if claimValue, exists := claims[groupsClaim]; exists {
switch g := claimValue.(type) {
case []any:
for _, group := range g {
if gs, ok := group.(string); ok {
groups = append(groups, gs)
}
}
case string:
// Some IDPs return a single group as a string instead of an array
groups = append(groups, g)
default:
a.settings.Logger().WarnContext(ctx, "oidc: unsupported groups type", "type", fmt.Sprintf("%T", claimValue))
}
}
}
role := ""
if roleClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Role; roleClaim != "" {
if r, ok := claims[roleClaim].(string); ok {
role = r
}
}
return authtypes.NewCallbackIdentity(name, email, authDomain.StorableAuthDomain().OrgID, state, groups, role), nil
return authtypes.NewCallbackIdentity("", email, authDomain.StorableAuthDomain().OrgID, state), nil
}
func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
@@ -181,13 +145,6 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
return nil, nil, err
}
scopes := make([]string, len(defaultScopes))
copy(scopes, defaultScopes)
if authDomain.AuthDomainConfig().RoleMapping != nil && len(authDomain.AuthDomainConfig().RoleMapping.GroupMappings) > 0 {
scopes = append(scopes, "groups")
}
return oidcProvider, &oauth2.Config{
ClientID: authDomain.AuthDomainConfig().OIDC.ClientID,
ClientSecret: authDomain.AuthDomainConfig().OIDC.ClientSecret,

View File

@@ -96,26 +96,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "saml: invalid email").WithAdditional("The nameID assertion is used to retrieve the email address, please check your IDP configuration and try again.")
}
name := ""
if nameAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Name; nameAttribute != "" {
if val := assertionInfo.Values.Get(nameAttribute); val != "" {
name = val
}
}
var groups []string
if groupAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Groups; groupAttribute != "" {
groups = assertionInfo.Values.GetAll(groupAttribute)
}
role := ""
if roleAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Role; roleAttribute != "" {
if val := assertionInfo.Values.Get(roleAttribute); val != "" {
role = val
}
}
return authtypes.NewCallbackIdentity(name, email, authDomain.StorableAuthDomain().OrgID, state, groups, role), nil
return authtypes.NewCallbackIdentity("", email, authDomain.StorableAuthDomain().OrgID, state), nil
}
func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {

View File

@@ -1,282 +0,0 @@
package httpgateway
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/http/client"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/gatewaytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/tidwall/gjson"
)
type Provider struct {
settings factory.ScopedProviderSettings
config gateway.Config
httpClient *client.Client
licensing licensing.Licensing
}
func NewProviderFactory(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return factory.NewProviderFactory(factory.MustNewName("http"), func(ctx context.Context, ps factory.ProviderSettings, c gateway.Config) (gateway.Gateway, error) {
return New(ctx, ps, c, licensing)
})
}
func New(ctx context.Context, providerSettings factory.ProviderSettings, config gateway.Config, licensing licensing.Licensing) (gateway.Gateway, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/gateway/httpgateway")
httpClient, err := client.New(
settings.Logger(),
providerSettings.TracerProvider,
providerSettings.MeterProvider,
client.WithRequestResponseLog(true),
client.WithRetryCount(3),
)
if err != nil {
return nil, err
}
return &Provider{
settings: settings,
config: config,
httpClient: httpClient,
licensing: licensing,
}, nil
}
func (provider *Provider) GetIngestionKeys(ctx context.Context, orgID valuer.UUID, page, perPage int) (*gatewaytypes.GettableIngestionKeys, error) {
qParams := url.Values{}
qParams.Add("page", strconv.Itoa(page))
qParams.Add("per_page", strconv.Itoa(perPage))
responseBody, err := provider.do(ctx, orgID, http.MethodGet, "/v1/workspaces/me/keys", qParams, nil)
if err != nil {
return nil, err
}
var ingestionKeys []gatewaytypes.IngestionKey
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "data").String()), &ingestionKeys); err != nil {
return nil, err
}
var pagination gatewaytypes.Pagination
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "_pagination").String()), &pagination); err != nil {
return nil, err
}
return &gatewaytypes.GettableIngestionKeys{
Keys: ingestionKeys,
Pagination: pagination,
}, nil
}
func (provider *Provider) SearchIngestionKeysByName(ctx context.Context, orgID valuer.UUID, name string, page, perPage int) (*gatewaytypes.GettableIngestionKeys, error) {
qParams := url.Values{}
qParams.Add("name", name)
qParams.Add("page", strconv.Itoa(page))
qParams.Add("per_page", strconv.Itoa(perPage))
responseBody, err := provider.do(ctx, orgID, http.MethodGet, "/v1/workspaces/me/keys/search", qParams, nil)
if err != nil {
return nil, err
}
var ingestionKeys []gatewaytypes.IngestionKey
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "data").String()), &ingestionKeys); err != nil {
return nil, err
}
var pagination gatewaytypes.Pagination
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "_pagination").String()), &pagination); err != nil {
return nil, err
}
return &gatewaytypes.GettableIngestionKeys{
Keys: ingestionKeys,
Pagination: pagination,
}, nil
}
func (provider *Provider) CreateIngestionKey(ctx context.Context, orgID valuer.UUID, name string, tags []string, expiresAt time.Time) (*gatewaytypes.GettableCreatedIngestionKey, error) {
requestBody := gatewaytypes.PostableIngestionKey{
Name: name,
Tags: tags,
ExpiresAt: expiresAt,
}
requestBodyBytes, err := json.Marshal(requestBody)
if err != nil {
return nil, err
}
responseBody, err := provider.do(ctx, orgID, http.MethodPost, "/v1/workspaces/me/keys", nil, requestBodyBytes)
if err != nil {
return nil, err
}
var createdKeyResponse gatewaytypes.GettableCreatedIngestionKey
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "data").String()), &createdKeyResponse); err != nil {
return nil, err
}
return &createdKeyResponse, nil
}
func (provider *Provider) UpdateIngestionKey(ctx context.Context, orgID valuer.UUID, keyID string, name string, tags []string, expiresAt time.Time) error {
requestBody := gatewaytypes.PostableIngestionKey{
Name: name,
Tags: tags,
ExpiresAt: expiresAt,
}
requestBodyBytes, err := json.Marshal(requestBody)
if err != nil {
return err
}
_, err = provider.do(ctx, orgID, http.MethodPatch, "/v1/workspaces/me/keys/"+keyID, nil, requestBodyBytes)
if err != nil {
return err
}
return nil
}
func (provider *Provider) DeleteIngestionKey(ctx context.Context, orgID valuer.UUID, keyID string) error {
_, err := provider.do(ctx, orgID, http.MethodDelete, "/v1/workspaces/me/keys/"+keyID, nil, nil)
if err != nil {
return err
}
return nil
}
func (provider *Provider) CreateIngestionKeyLimit(ctx context.Context, orgID valuer.UUID, keyID string, signal string, limitConfig gatewaytypes.LimitConfig, tags []string) (*gatewaytypes.GettableCreatedIngestionKeyLimit, error) {
requestBody := gatewaytypes.PostableIngestionKeyLimit{
Signal: signal,
Config: limitConfig,
Tags: tags,
}
requestBodyBytes, err := json.Marshal(requestBody)
if err != nil {
return nil, err
}
responseBody, err := provider.do(ctx, orgID, http.MethodPost, "/v1/workspaces/me/keys/"+keyID+"/limits", nil, requestBodyBytes)
if err != nil {
return nil, err
}
var createdIngestionKeyLimitResponse gatewaytypes.GettableCreatedIngestionKeyLimit
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "data").String()), &createdIngestionKeyLimitResponse); err != nil {
return nil, err
}
return &createdIngestionKeyLimitResponse, nil
}
func (provider *Provider) UpdateIngestionKeyLimit(ctx context.Context, orgID valuer.UUID, limitID string, limitConfig gatewaytypes.LimitConfig, tags []string) error {
requestBody := gatewaytypes.UpdatableIngestionKeyLimit{
Config: limitConfig,
Tags: tags,
}
requestBodyBytes, err := json.Marshal(requestBody)
if err != nil {
return err
}
_, err = provider.do(ctx, orgID, http.MethodPatch, "/v1/workspaces/me/limits/"+limitID, nil, requestBodyBytes)
if err != nil {
return err
}
return nil
}
func (provider *Provider) DeleteIngestionKeyLimit(ctx context.Context, orgID valuer.UUID, limitID string) error {
_, err := provider.do(ctx, orgID, http.MethodDelete, "/v1/workspaces/me/limits/"+limitID, nil, nil)
if err != nil {
return err
}
return nil
}
func (provider *Provider) do(ctx context.Context, orgID valuer.UUID, method string, path string, queryParams url.Values, body []byte) ([]byte, error) {
license, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "no valid license found").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
// build url
requestURL := provider.config.URL.JoinPath(path)
// add query params to the url
if queryParams != nil {
requestURL.RawQuery = queryParams.Encode()
}
// build request
request, err := http.NewRequestWithContext(ctx, method, requestURL.String(), bytes.NewBuffer(body))
if err != nil {
return nil, err
}
// add headers needed to call gateway
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Signoz-Cloud-Api-Key", license.Key)
request.Header.Set("X-Consumer-Username", "lid:00000000-0000-0000-0000-000000000000")
request.Header.Set("X-Consumer-Groups", "ns:default")
// execute request
response, err := provider.httpClient.Do(request)
if err != nil {
return nil, err
}
// read response
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
// only 2XX
if response.StatusCode/100 == 2 {
return responseBody, nil
}
errorMessage := gjson.GetBytes(responseBody, "error").String()
if errorMessage == "" {
errorMessage = "an unknown error occurred"
}
// return error for non 2XX
return nil, provider.errFromStatusCode(response.StatusCode, errorMessage)
}
func (provider *Provider) errFromStatusCode(code int, errorMessage string) error {
switch code {
case http.StatusBadRequest:
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, errorMessage)
case http.StatusUnauthorized:
return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, errorMessage)
case http.StatusForbidden:
return errors.New(errors.TypeForbidden, errors.CodeForbidden, errorMessage)
case http.StatusNotFound:
return errors.New(errors.TypeNotFound, errors.CodeNotFound, errorMessage)
case http.StatusConflict:
return errors.New(errors.TypeAlreadyExists, errors.CodeAlreadyExists, errorMessage)
}
return errors.New(errors.TypeInternal, errors.CodeInternal, errorMessage)
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/apis/fields"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/middleware"
querierAPI "github.com/SigNoz/signoz/pkg/querier"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
@@ -37,7 +36,6 @@ type APIHandlerOptions struct {
GatewayUrl string
// Querier Influx Interval
FluxInterval time.Duration
GlobalConfig global.Config
}
type APIHandler struct {

View File

@@ -76,7 +76,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
return
}
signozApiUrl, apiErr := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
ingestionUrl, signozApiUrl, apiErr := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
if apiErr != nil {
RespondError(w, basemodel.WrapApiError(
apiErr, "couldn't deduce ingestion url and signoz api url",
@@ -84,7 +84,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
return
}
result.IngestionUrl = ah.opts.GlobalConfig.IngestionURL.String()
result.IngestionUrl = ingestionUrl
result.SigNozAPIUrl = signozApiUrl
gatewayUrl := ah.opts.GatewayUrl
@@ -186,7 +186,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
}
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (
string, *basemodel.ApiError,
string, string, *basemodel.ApiError,
) {
// TODO: remove this struct from here
type deploymentResponse struct {
@@ -200,7 +200,7 @@ func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licens
respBytes, err := ah.Signoz.Zeus.GetDeployment(ctx, licenseKey)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
return "", "", basemodel.InternalError(fmt.Errorf(
"couldn't query for deployment info: error: %w", err,
))
}
@@ -209,7 +209,7 @@ func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licens
err = json.Unmarshal(respBytes, resp)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
return "", "", basemodel.InternalError(fmt.Errorf(
"couldn't unmarshal deployment info response: error: %w", err,
))
}
@@ -219,14 +219,16 @@ func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licens
if len(regionDns) < 1 || len(deploymentName) < 1 {
// Fail early if actual response structure and expectation here ever diverge
return "", basemodel.InternalError(fmt.Errorf(
return "", "", basemodel.InternalError(fmt.Errorf(
"deployment info response not in expected shape. couldn't determine region dns and deployment name",
))
}
ingestionUrl := fmt.Sprintf("https://ingest.%s", regionDns)
signozApiUrl := fmt.Sprintf("https://%s.%s", deploymentName, regionDns)
return signozApiUrl, nil
return ingestionUrl, signozApiUrl, nil
}
type ingestionKey struct {

View File

@@ -172,7 +172,6 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
FluxInterval: config.Querier.FluxInterval,
Gateway: gatewayProxy,
GatewayUrl: config.Gateway.URL.String(),
GlobalConfig: config.Global,
}
apiHandler, err := api.NewAPIHandler(apiOpts, signoz)

View File

@@ -211,10 +211,7 @@ describe('VariableItem Integration Tests', () => {
await user.clear(textInput);
await user.type(textInput, 'new-text-value');
// Blur the input to trigger the value update
await user.tab();
// Should call onValueUpdate after blur
// Should call onValueUpdate after debounce
await waitFor(
() => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(

View File

@@ -4,7 +4,7 @@ import { OPERATORS, PANEL_TYPES } from 'constants/queryBuilder';
import { Formula } from 'container/QueryBuilder/components/Formula';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { memo, useEffect, useMemo, useRef } from 'react';
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -33,7 +33,6 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
addTraceOperator,
panelType,
initialDataSource,
handleRunQuery,
} = useQueryBuilder();
const containerRef = useRef(null);
@@ -158,29 +157,10 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>): void => {
const target = e.target as HTMLElement | null;
const tagName = target?.tagName || '';
const isInputElement =
['INPUT', 'TEXTAREA', 'SELECT'].includes(tagName) ||
(target?.getAttribute('contenteditable') || '').toLowerCase() === 'true';
// Allow input elements in qb to run the query when Cmd/Ctrl + Enter is pressed
if (isInputElement && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleRunQuery();
}
},
[handleRunQuery],
);
return (
<QueryBuilderV2Provider>
<div className="query-builder-v2">
<div className="qb-content-container" onKeyDownCapture={handleKeyDown}>
<div className="qb-content-container">
{!isMultiQueryAllowed ? (
<QueryV2
ref={containerRef}

View File

@@ -11,7 +11,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { get, isEmpty } from 'lodash-es';
import { BarChart2, ChevronUp, ExternalLink, ScrollText } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -171,9 +171,6 @@ function QueryAddOns({
const [selectedViews, setSelectedViews] = useState<AddOn[]>([]);
const initializedRef = useRef(false);
const prevAvailableKeysRef = useRef<Set<string> | null>(null);
const { handleChangeQueryData } = useQueryOperations({
index,
query,
@@ -216,41 +213,23 @@ function QueryAddOns({
}
setAddOns(filteredAddOns);
const availableAddOnKeys = new Set(filteredAddOns.map((a) => a.key));
const previousKeys = prevAvailableKeysRef.current;
const hasAvailabilityItemsChanged =
previousKeys !== null &&
(previousKeys.size !== availableAddOnKeys.size ||
[...availableAddOnKeys].some((key) => !previousKeys.has(key)));
prevAvailableKeysRef.current = availableAddOnKeys;
const activeAddOnKeys = new Set(
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
.filter(([, path]) => hasValue(get(query, path)))
.map(([key]) => key),
);
if (!initializedRef.current || hasAvailabilityItemsChanged) {
initializedRef.current = true;
const activeAddOnKeys = new Set(
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
.filter(([, path]) => hasValue(get(query, path)))
.map(([key]) => key),
);
// Initial seeding from query values on mount
setSelectedViews(
filteredAddOns.filter(
(addOn) =>
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
),
);
return;
}
setSelectedViews((prev) =>
prev.filter((view) =>
filteredAddOns.some((addOn) => addOn.key === view.key),
const availableAddOnKeys = new Set(filteredAddOns.map((addOn) => addOn.key));
// Filter and set selected views: add-ons that are both active and available
setSelectedViews(
filteredAddOns.filter(
(addOn) =>
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [panelType, isListViewPanel, query, showReduceTo]);
}, [panelType, isListViewPanel, query]);
const handleOptionClick = (e: RadioChangeEvent): void => {
if (selectedViews.find((view) => view.key === e.target.value.key)) {

View File

@@ -1379,6 +1379,8 @@ function QuerySearch({
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
onRun(getCurrentExpression());
} else {
handleRunQuery();
}
return true;
},

View File

@@ -410,6 +410,8 @@ function TraceOperatorEditor({
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
onRun(value);
} else {
handleRunQuery();
}
return true;
},

View File

@@ -270,6 +270,44 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
await waitFor(() => expect(onRun).toHaveBeenCalled(), { timeout: 2000 });
});
it('calls handleRunQuery when Mod-Enter without onRun', async () => {
const mockedHandleRunQuery = handleRunQueryMock as jest.MockedFunction<
() => void
>;
mockedHandleRunQuery.mockClear();
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
// Wait for CodeMirror to initialize
await waitFor(() => {
const editor = document.querySelector(CM_EDITOR_SELECTOR);
expect(editor).toBeInTheDocument();
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await userEvent.click(editor);
await userEvent.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
fireEvent.keyDown(editor, {
key: 'Enter',
code: 'Enter',
[modKey]: true,
keyCode: 13,
});
await waitFor(() => expect(mockedHandleRunQuery).toHaveBeenCalled(), {
timeout: 2000,
});
});
it('initializes CodeMirror with expression from queryData.filter.expression on mount', async () => {
const testExpression =
"http.status_code >= 500 AND service.name = 'frontend'";

View File

@@ -3,21 +3,14 @@
import '@testing-library/jest-dom';
import { jest } from '@jest/globals';
import { fireEvent, waitFor } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { render, screen, userEvent } from 'tests/test-utils';
import {
Having,
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { render, screen } from 'tests/test-utils';
import { Having, IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { UseQueryOperations } from 'types/common/operations.types';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
import { QueryBuilderV2 } from '../../QueryBuilderV2';
import { QueryV2 } from '../QueryV2';
// Local mocks for domain-specific heavy child components
jest.mock(
@@ -43,87 +36,16 @@ const mockedUseQueryOperations = jest.mocked(
useQueryOperations,
) as jest.MockedFunction<UseQueryOperations>;
describe('QueryBuilderV2 + QueryV2 - base render', () => {
let handleRunQueryMock: jest.MockedFunction<() => void>;
describe('QueryV2 - base render', () => {
beforeEach(() => {
const mockCloneQuery = jest.fn() as jest.MockedFunction<
(type: string, q: IBuilderQuery) => void
>;
handleRunQueryMock = jest.fn() as jest.MockedFunction<() => void>;
const baseQuery: IBuilderQuery = {
queryName: 'A',
dataSource: DataSource.LOGS,
aggregateOperator: '',
aggregations: [],
timeAggregation: '',
spaceAggregation: '',
temporality: '',
functions: [],
filter: undefined,
filters: { items: [], op: 'AND' },
groupBy: [],
expression: '',
disabled: false,
having: [] as Having[],
limit: 10,
stepInterval: null,
orderBy: [],
legend: 'A',
};
const currentQueryObj: Query = {
id: 'test',
unit: undefined,
queryType: EQueryType.CLICKHOUSE,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [baseQuery],
queryFormulas: [],
queryTraceOperator: [],
},
};
const updateAllQueriesOperators: QueryBuilderContextType['updateAllQueriesOperators'] = (
q,
) => q;
const updateQueriesData: QueryBuilderContextType['updateQueriesData'] = (q) =>
q;
mockedUseQueryBuilder.mockReturnValue(({
currentQuery: currentQueryObj,
stagedQuery: null,
lastUsedQuery: null,
setLastUsedQuery: jest.fn(),
supersetQuery: currentQueryObj,
setSupersetQuery: jest.fn(),
initialDataSource: null,
panelType: PANEL_TYPES.TABLE,
isEnabledQuery: true,
handleSetQueryData: jest.fn(),
handleSetTraceOperatorData: jest.fn(),
handleSetFormulaData: jest.fn(),
handleSetQueryItemData: jest.fn(),
handleSetConfig: jest.fn(),
removeQueryBuilderEntityByIndex: jest.fn(),
removeAllQueryBuilderEntities: jest.fn(),
removeQueryTypeItemByIndex: jest.fn(),
addNewBuilderQuery: jest.fn(),
addNewFormula: jest.fn(),
removeTraceOperator: jest.fn(),
addTraceOperator: jest.fn(),
// Only fields used by QueryV2
cloneQuery: mockCloneQuery,
addNewQueryItem: jest.fn(),
redirectWithQueryBuilderData: jest.fn(),
handleRunQuery: handleRunQueryMock,
resetQuery: jest.fn(),
handleOnUnitsChange: jest.fn(),
updateAllQueriesOperators,
updateQueriesData,
initQueryBuilderData: jest.fn(),
isStagedQueryUpdated: jest.fn(() => false),
isDefaultQuery: jest.fn(() => false),
panelType: null,
} as unknown) as QueryBuilderContextType);
mockedUseQueryOperations.mockReturnValue({
@@ -149,7 +71,40 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
});
it('renders limit input when dataSource is logs', () => {
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
const baseQuery: IBuilderQuery = {
queryName: 'A',
dataSource: DataSource.LOGS,
aggregateOperator: '',
aggregations: [],
timeAggregation: '',
spaceAggregation: '',
temporality: '',
functions: [],
filter: undefined,
filters: { items: [], op: 'AND' },
groupBy: [],
expression: '',
disabled: false,
having: [] as Having[],
limit: 10,
stepInterval: null,
orderBy: [],
legend: 'A',
};
render(
<QueryV2
index={0}
isAvailableToDisable
query={baseQuery}
version="v4"
onSignalSourceChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
signalSourceChangeEnabled={false}
queriesCount={1}
showTraceOperator={false}
hasTraceOperator={false}
/>,
);
// Ensure the Limit add-on input is present and is of type number
const limitInput = screen.getByPlaceholderText(
@@ -160,43 +115,4 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
expect(limitInput).toHaveAttribute('name', 'limit');
expect(limitInput).toHaveAttribute('data-testid', 'input-Limit');
});
it('Cmd+Enter on an input triggers handleRunQuery via container handler', async () => {
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
const limitInput = screen.getByPlaceholderText('Enter limit');
fireEvent.keyDown(limitInput, {
key: 'Enter',
code: 'Enter',
metaKey: true,
});
expect(handleRunQueryMock).toHaveBeenCalled();
const legendInput = screen.getByPlaceholderText('Write legend format');
fireEvent.keyDown(legendInput, {
key: 'Enter',
code: 'Enter',
metaKey: true,
});
expect(handleRunQueryMock).toHaveBeenCalled();
const CM_EDITOR_SELECTOR = '.cm-editor .cm-content';
// Wait for CodeMirror to initialize
await waitFor(() => {
const editor = document.querySelector(CM_EDITOR_SELECTOR);
expect(editor).toBeInTheDocument();
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await userEvent.click(editor);
fireEvent.keyDown(editor, {
key: 'Enter',
code: 'Enter',
metaKey: true,
});
expect(handleRunQueryMock).toHaveBeenCalled();
});
});

View File

@@ -37,7 +37,10 @@ function ThresholdItem({
);
if (units.length === 0) {
component = (
<Tooltip trigger="hover" title="No compatible units available">
<Tooltip
trigger="hover"
title="Please select a Y-axis unit for the query first"
>
<Select
placeholder="Unit"
value={threshold.unit ? threshold.unit : null}

View File

@@ -47,17 +47,9 @@ export function getCategoryByOptionId(id: string): string | undefined {
}
export function getCategorySelectOptionByName(
name: string | undefined,
name: string,
): DefaultOptionType[] {
if (!name) {
return [];
}
const categories = getYAxisCategories(YAxisSource.ALERTS);
if (!categories.length) {
return [];
}
return (
categories
.find((category) => category.name === name)

View File

@@ -1,15 +1,12 @@
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import {
UniversalYAxisUnit,
YAxisSource,
} from 'components/YAxisUnitSelector/types';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useCreateAlertState } from 'container/CreateAlertV2/context';
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
import PlotTag from 'container/NewWidget/LeftContainer/WidgetGraph/PlotTag';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { AlertTypes } from 'types/api/alerts/alertTypes';
@@ -29,7 +26,6 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
alertState,
setAlertState,
isEditMode,
setThresholdState,
} = useCreateAlertState();
const { selectedTime: globalSelectedInterval } = useSelector<
AppState,
@@ -55,18 +51,6 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
}
}, [initialYAxisUnit, setAlertState, shouldUpdateYAxisUnit]);
const handleYAxisUnitChange = useCallback(
(value: UniversalYAxisUnit): void => {
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
// Reset all threshold units when the y-axis unit changes
setThresholdState({
type: 'SET_THRESHOLDS',
payload: thresholdState.thresholds.map((t) => ({ ...t, unit: '' })),
});
},
[setAlertState, setThresholdState, thresholdState.thresholds],
);
const headline = (
<div className="chart-preview-headline">
<PlotTag
@@ -76,7 +60,9 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
<YAxisUnitSelector
value={yAxisUnit}
initialValue={initialYAxisUnit}
onChange={handleYAxisUnitChange}
onChange={(value): void => {
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
}}
source={YAxisSource.ALERTS}
loading={isLoading}
/>

View File

@@ -1,12 +1,6 @@
/* eslint-disable sonarjs/no-identical-functions */
/* eslint-disable sonarjs/no-duplicate-string */
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import {
IDashboardVariable,
TSortVariableValuesType,
@@ -645,186 +639,4 @@ describe('VariableItem Component', () => {
await expectCircularDependencyError();
});
});
describe('Textbox Variable Default Value Handling', () => {
test('saves textbox variable with defaultValue and selectedValue set to textboxValue', async () => {
const user = userEvent.setup();
const textboxVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Textbox Variable',
type: 'TEXTBOX',
textboxValue: 'my-default-value',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(textboxVariable);
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with defaultValue and selectedValue equal to textboxValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: 'my-default-value',
defaultValue: 'my-default-value',
selectedValue: 'my-default-value',
}),
expect.anything(),
);
});
test('saves textbox variable with empty values when textboxValue is empty', async () => {
const user = userEvent.setup();
const textboxVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Textbox Variable',
type: 'TEXTBOX',
textboxValue: '',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(textboxVariable);
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with empty defaultValue and selectedValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: '',
defaultValue: '',
selectedValue: '',
}),
expect.anything(),
);
});
test('updates textbox defaultValue and selectedValue when user changes textboxValue input', async () => {
const user = userEvent.setup();
const textboxVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Textbox Variable',
type: 'TEXTBOX',
textboxValue: 'initial-value',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(textboxVariable);
// Change the textbox value
const textboxInput = screen.getByPlaceholderText(
'Enter a default value (if any)...',
);
await user.clear(textboxInput);
await user.type(textboxInput, 'updated-value');
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with the updated defaultValue and selectedValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: 'updated-value',
defaultValue: 'updated-value',
selectedValue: 'updated-value',
}),
expect.anything(),
);
});
test('non-textbox variables use variableDefaultValue instead of textboxValue', async () => {
const user = userEvent.setup();
const queryVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Query Variable',
type: 'QUERY',
queryValue: 'SELECT * FROM test',
textboxValue: 'should-not-be-used',
defaultValue: 'query-default-value',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(queryVariable);
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with defaultValue not being textboxValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'QUERY',
defaultValue: 'query-default-value',
}),
expect.anything(),
);
// Verify that defaultValue is NOT the textboxValue
const savedVariable = onSave.mock.calls[0][1];
expect(savedVariable.defaultValue).not.toBe('should-not-be-used');
});
test('switching to textbox type sets defaultValue and selectedValue correctly on save', async () => {
const user = userEvent.setup();
// Start with a QUERY variable
const queryVariable: IDashboardVariable = {
id: TEST_VAR_IDS.VAR1,
name: TEST_VAR_NAMES.VAR1,
description: 'Test Variable',
type: 'QUERY',
queryValue: 'SELECT * FROM test',
...VARIABLE_DEFAULTS,
order: 0,
};
renderVariableItem(queryVariable);
// Switch to TEXTBOX type
const textboxButton = findButtonByText(TEXT.TEXTBOX);
expect(textboxButton).toBeInTheDocument();
if (textboxButton) {
await user.click(textboxButton);
}
// Enter a default value in the textbox input
const textboxInput = screen.getByPlaceholderText(
'Enter a default value (if any)...',
);
await user.type(textboxInput, 'new-textbox-default');
// Click save button
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
await user.click(saveButton);
// Verify that onSave was called with type TEXTBOX and correct defaultValue and selectedValue
expect(onSave).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
type: 'TEXTBOX',
textboxValue: 'new-textbox-default',
defaultValue: 'new-textbox-default',
selectedValue: 'new-textbox-default',
}),
expect.anything(),
);
});
});
});

View File

@@ -320,10 +320,6 @@ function VariableItem({
]);
const variableValue = useMemo(() => {
if (queryType === 'TEXTBOX') {
return variableTextboxValue;
}
if (variableMultiSelect) {
let value = variableData.selectedValue;
if (isEmpty(value)) {
@@ -356,8 +352,6 @@ function VariableItem({
variableData.selectedValue,
variableData.showALLOption,
variableDefaultValue,
variableTextboxValue,
queryType,
previewValues,
]);
@@ -373,10 +367,13 @@ function VariableItem({
multiSelect: variableMultiSelect,
showALLOption: queryType === 'DYNAMIC' ? true : variableShowALLOption,
sort: variableSortType,
// the reason we need to do this is because defaultValues are treated differently in case of textbox type
// They are the exact same and not like the other types where defaultValue is a separate field
defaultValue:
queryType === 'TEXTBOX' ? variableTextboxValue : variableDefaultValue,
...(queryType === 'TEXTBOX' && {
selectedValue: (variableData.selectedValue ||
variableTextboxValue) as never,
}),
...(queryType !== 'TEXTBOX' && {
defaultValue: variableDefaultValue as never,
}),
modificationUUID: generateUUID(),
id: variableData.id || generateUUID(),
order: variableData.order,

View File

@@ -25,12 +25,6 @@
}
}
&.focused {
.variable-value {
outline: 1px solid var(--bg-robin-400);
}
}
.variable-value {
display: flex;
min-width: 120px;
@@ -99,12 +93,6 @@
.lightMode {
.variable-item {
&.focused {
.variable-value {
border: 1px solid var(--bg-robin-400);
}
}
.variable-name {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);

View File

@@ -94,7 +94,7 @@ function DashboardVariableSelection(): JSX.Element | null {
cycleNodes,
});
}
}, [variables, variablesTableData]);
}, [setVariablesToGetUpdated, variables, variablesTableData]);
// this handles the case where the dependency order changes i.e. variable list updated via creation or deletion etc. and we need to refetch the variables
// also trigger when the global time changes

View File

@@ -80,12 +80,10 @@ describe('VariableItem', () => {
/>
</MockQueryClientProvider>,
);
expect(
screen.getByTestId('variable-textbox-test_variable'),
).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter value')).toBeInTheDocument();
});
test('calls onValueUpdate when Input value changes and blurs', async () => {
test('calls onChange event handler when Input value changes', async () => {
render(
<MockQueryClientProvider>
<VariableItem
@@ -104,19 +102,13 @@ describe('VariableItem', () => {
</MockQueryClientProvider>,
);
const inputElement = screen.getByTestId('variable-textbox-test_variable');
// Change the value
act(() => {
const inputElement = screen.getByPlaceholderText('Enter value');
fireEvent.change(inputElement, { target: { value: 'newValue' } });
});
// Blur the input to trigger the update
act(() => {
fireEvent.blur(inputElement);
});
await waitFor(() => {
// expect(mockOnValueUpdate).toHaveBeenCalledTimes(1);
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'testVariable',
'test_variable',

View File

@@ -8,14 +8,14 @@ import './DashboardVariableSelection.styles.scss';
import { orange } from '@ant-design/colors';
import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons';
import { Input, InputRef, Popover, Tooltip, Typography } from 'antd';
import { Input, Popover, Tooltip, Typography } from 'antd';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { debounce, isArray, isEmpty, isString } from 'lodash-es';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
@@ -71,15 +71,6 @@ function VariableItem({
string | string[] | undefined
>(undefined);
// Local state for textbox input to ensure smooth editing experience
const [textboxInputValue, setTextboxInputValue] = useState<string>(
(variableData.selectedValue?.toString() ||
variableData.defaultValue?.toString()) ??
'',
);
const [isTextboxFocused, setIsTextboxFocused] = useState<boolean>(false);
const textboxInputRef = useRef<InputRef>(null);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
@@ -380,7 +371,7 @@ function VariableItem({
}, [variableData.type, variableData.customValue]);
return (
<div className={`variable-item${isTextboxFocused ? ' focused' : ''}`}>
<div className="variable-item">
<Typography.Text className="variable-name" ellipsis>
${variableData.name}
{variableData.description && (
@@ -393,40 +384,16 @@ function VariableItem({
<div className="variable-value">
{variableData.type === 'TEXTBOX' ? (
<Input
ref={textboxInputRef}
placeholder="Enter value"
data-testid={`variable-textbox-${variableData.id}`}
bordered={false}
value={textboxInputValue}
title={textboxInputValue}
key={variableData.selectedValue?.toString()}
defaultValue={variableData.selectedValue?.toString()}
onChange={(e): void => {
setTextboxInputValue(e.target.value);
debouncedHandleChange(e.target.value || '');
}}
onFocus={(): void => {
setIsTextboxFocused(true);
}}
onBlur={(e): void => {
setIsTextboxFocused(false);
const value = e.target.value.trim();
// If empty, reset to default value
if (!value && variableData.defaultValue) {
setTextboxInputValue(variableData.defaultValue.toString());
debouncedHandleChange(variableData.defaultValue.toString());
} else {
debouncedHandleChange(value);
}
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
const value = textboxInputValue.trim();
if (!value && variableData.defaultValue) {
setTextboxInputValue(variableData.defaultValue.toString());
debouncedHandleChange(variableData.defaultValue.toString());
} else {
debouncedHandleChange(value);
}
textboxInputRef.current?.blur();
}
style={{
width:
50 + ((variableData.selectedValue?.toString()?.length || 0) * 7 || 50),
}}
/>
) : (

View File

@@ -257,15 +257,6 @@ export const onUpdateVariableNode = (
): void => {
const visited = new Set<string>();
// If nodeToUpdate is not in topologicalOrder (e.g., CUSTOM variable),
// we still need to mark its children as needing updates
if (!topologicalOrder.includes(nodeToUpdate)) {
// Mark direct children of the node as visited so they get processed
(graph[nodeToUpdate] || []).forEach((child) => {
visited.add(child);
});
}
// Start processing from the node to update
topologicalOrder.forEach((node) => {
if (node === nodeToUpdate || visited.has(node)) {

View File

@@ -1,4 +1,4 @@
import { areArraysEqual, onUpdateVariableNode, VariableGraph } from './util';
import { areArraysEqual } from './util';
describe('areArraysEqual', () => {
it('should return true for equal arrays with same order', () => {
@@ -31,121 +31,3 @@ describe('areArraysEqual', () => {
expect(areArraysEqual(array1, array2)).toBe(true);
});
});
describe('onUpdateVariableNode', () => {
// Graph structure:
// deployment -> namespace -> service -> pod
// deployment has no parents, namespace depends on deployment, etc.
const graph: VariableGraph = {
deployment: ['namespace'],
namespace: ['service'],
service: ['pod'],
pod: [],
customVar: ['namespace'], // CUSTOM variable that affects namespace
};
const topologicalOrder = ['deployment', 'namespace', 'service', 'pod'];
it('should call callback for the node and all its descendants', () => {
const visited: string[] = [];
const callback = (node: string): void => {
visited.push(node);
};
onUpdateVariableNode('deployment', graph, topologicalOrder, callback);
expect(visited).toEqual(['deployment', 'namespace', 'service', 'pod']);
});
it('should call callback starting from a middle node', () => {
const visited: string[] = [];
const callback = (node: string): void => {
visited.push(node);
};
onUpdateVariableNode('namespace', graph, topologicalOrder, callback);
expect(visited).toEqual(['namespace', 'service', 'pod']);
});
it('should only call callback for the leaf node when updating leaf', () => {
const visited: string[] = [];
const callback = (node: string): void => {
visited.push(node);
};
onUpdateVariableNode('pod', graph, topologicalOrder, callback);
expect(visited).toEqual(['pod']);
});
it('should handle CUSTOM variable not in topologicalOrder by updating its children', () => {
const visited: string[] = [];
const callback = (node: string): void => {
visited.push(node);
};
// customVar is not in topologicalOrder but has namespace as a child
onUpdateVariableNode('customVar', graph, topologicalOrder, callback);
// Should process namespace and its descendants (service, pod)
expect(visited).toEqual(['namespace', 'service', 'pod']);
});
it('should handle node not in graph gracefully', () => {
const visited: string[] = [];
const callback = (node: string): void => {
visited.push(node);
};
onUpdateVariableNode('unknownNode', graph, topologicalOrder, callback);
// Should not call callback for any node since unknownNode has no children
expect(visited).toEqual([]);
});
it('should handle empty graph', () => {
const visited: string[] = [];
const callback = (node: string): void => {
visited.push(node);
};
onUpdateVariableNode('deployment', {}, topologicalOrder, callback);
// deployment is in topologicalOrder, so callback is called for it
expect(visited).toEqual(['deployment']);
});
it('should handle empty topologicalOrder', () => {
const visited: string[] = [];
const callback = (node: string): void => {
visited.push(node);
};
onUpdateVariableNode('deployment', graph, [], callback);
expect(visited).toEqual([]);
});
it('should handle CUSTOM variable with multiple children', () => {
const graphWithMultipleChildren: VariableGraph = {
...graph,
customMulti: ['namespace', 'service'], // CUSTOM variable affecting multiple nodes
};
const visited: string[] = [];
const callback = (node: string): void => {
visited.push(node);
};
onUpdateVariableNode(
'customMulti',
graphWithMultipleChildren,
topologicalOrder,
callback,
);
// Should process namespace, service, and pod (descendants)
expect(visited).toEqual(['namespace', 'service', 'pod']);
});
});

View File

@@ -4,7 +4,3 @@
gap: 8px;
align-items: center;
}
.rule-unit-selector {
width: 150px;
}

View File

@@ -15,7 +15,8 @@ import { DefaultOptionType } from 'antd/es/select';
import {
getCategoryByOptionId,
getCategorySelectOptionByName,
} from 'container/CreateAlertV2/AlertCondition/utils';
} from 'container/NewWidget/RightContainer/alertFomatCategories';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useTranslation } from 'react-i18next';
import {
AlertDef,
@@ -42,10 +43,10 @@ function RuleOptions({
setAlertDef,
queryCategory,
queryOptions,
yAxisUnit,
}: RuleOptionsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('alerts');
const { currentQuery } = useQueryBuilder();
const { ruleType } = alertDef;
@@ -364,9 +365,11 @@ function RuleOptions({
</InlineSelect>
);
const selectedCategory = getCategoryByOptionId(yAxisUnit);
const selectedCategory = getCategoryByOptionId(currentQuery?.unit || '');
const categorySelectOptions = getCategorySelectOptionByName(selectedCategory);
const categorySelectOptions = getCategorySelectOptionByName(
selectedCategory?.name,
);
const step3Label = alertDef.alertType === 'METRIC_BASED_ALERT' ? '3' : '2';
@@ -399,7 +402,6 @@ function RuleOptions({
<Form.Item noStyle>
<Select
className="rule-unit-selector"
getPopupContainer={popupContainer}
allowClear
showSearch
@@ -513,6 +515,5 @@ interface RuleOptionsProps {
setAlertDef: (a: AlertDef) => void;
queryCategory: EQueryType;
queryOptions: DefaultOptionType[];
yAxisUnit: string;
}
export default RuleOptions;

View File

@@ -914,7 +914,6 @@ function FormAlertRules({
alertDef={alertDef}
setAlertDef={setAlertDef}
queryOptions={queryOptions}
yAxisUnit={yAxisUnit || ''}
/>
{renderBasicInfo()}

View File

@@ -318,9 +318,7 @@ function GridCardGraph({
version={version}
threshold={threshold}
headerMenuList={menuList}
isFetchingResponse={
queryResponse.isFetching || variablesToGetUpdated.length > 0
}
isFetchingResponse={queryResponse.isFetching}
setRequestData={setRequestData}
onClickHandler={onClickHandler}
onDragSelect={onDragSelect}

View File

@@ -1,84 +0,0 @@
.license-section {
display: flex;
flex-direction: column;
gap: 16px;
.license-section-header {
display: flex;
flex-direction: column;
gap: 4px;
.license-section-title {
color: #fff;
font-family: Inter;
font-size: 16px;
font-style: normal;
line-height: 24px;
letter-spacing: -0.08px;
}
}
.license-section-content {
display: flex;
flex-direction: column;
gap: 16px;
.license-section-content-item {
padding: 16px;
border: 1px solid var(--Slate-500, #161922);
background: var(--Ink-400, #121317);
border-radius: 3px;
.license-section-content-item-title-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--Vanilla-300, #eee);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: normal;
letter-spacing: -0.07px;
margin-bottom: 8px;
}
.license-section-content-item-description {
color: var(--Vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 12px;
font-style: normal;
line-height: 20px;
letter-spacing: -0.07px;
}
}
}
}
.lightMode {
.license-section {
.license-section-header {
.license-section-title {
color: var(--bg-ink-400);
}
}
.license-section-content {
.license-section-content-item {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.license-section-content-item-title-action {
color: var(--bg-ink-400);
}
.license-section-content-item-description {
color: var(--bg-ink-300);
}
}
}
}
}

View File

@@ -1,65 +0,0 @@
import './LicenseSection.styles.scss';
import { Button } from '@signozhq/button';
import { Typography } from 'antd';
import { useNotifications } from 'hooks/useNotifications';
import { Copy } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useCopyToClipboard } from 'react-use';
function LicenseSection(): JSX.Element | null {
const { activeLicense } = useAppContext();
const { notifications } = useNotifications();
const [, handleCopyToClipboard] = useCopyToClipboard();
const getMaskedKey = (key: string): string => {
if (!key || key.length < 4) return key || 'N/A';
return `${key.substring(0, 2)}********${key
.substring(key.length - 2)
.trim()}`;
};
const handleCopyKey = (text: string): void => {
handleCopyToClipboard(text);
notifications.success({
message: 'Copied to clipboard',
});
};
if (!activeLicense?.key) {
return <></>;
}
return (
<div className="license-section">
<div className="license-section-header">
<div className="license-section-title">License</div>
</div>
<div className="license-section-content">
<div className="license-section-content-item">
<div className="license-section-content-item-title-action">
<span>License key</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Typography.Text code>{getMaskedKey(activeLicense.key)}</Typography.Text>
<Button
variant="ghost"
aria-label="Copy license key"
data-testid="license-key-copy-btn"
onClick={(): void => handleCopyKey(activeLicense.key)}
>
<Copy size={14} />
</Button>
</span>
</div>
<div className="license-section-content-item-description">
Your SigNoz license key.
</div>
</div>
</div>
</div>
);
}
export default LicenseSection;

View File

@@ -1 +0,0 @@
export { default } from './LicenseSection';

View File

@@ -1,31 +1,8 @@
import userEvent from '@testing-library/user-event';
import MySettingsContainer from 'container/MySettings';
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from 'tests/test-utils';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
const toggleThemeFunction = jest.fn();
const logEventFunction = jest.fn();
const copyToClipboardFn = jest.fn();
const editUserFn = jest.fn();
jest.mock('react-use', () => ({
__esModule: true,
useCopyToClipboard: (): [unknown, (text: string) => void] => [
null,
copyToClipboardFn,
],
}));
jest.mock('api/v1/user/id/update', () => ({
__esModule: true,
default: (...args: unknown[]): Promise<unknown> => editUserFn(...args),
}));
jest.mock('hooks/useDarkMode', () => ({
__esModule: true,
@@ -67,7 +44,6 @@ const PASSWORD_VALIDATION_MESSAGE_TEST_ID = 'password-validation-message';
describe('MySettings Flows', () => {
beforeEach(() => {
jest.clearAllMocks();
editUserFn.mockResolvedValue({});
render(<MySettingsContainer />);
});
@@ -239,71 +215,4 @@ describe('MySettings Flows', () => {
expect(submitButton).not.toBeDisabled();
});
});
describe('License section', () => {
it('Should render license section content when license key exists', () => {
expect(screen.getByText('License')).toBeInTheDocument();
expect(screen.getByText('License key')).toBeInTheDocument();
expect(screen.getByText('Your SigNoz license key.')).toBeInTheDocument();
});
it('Should not render license section when license key is missing', () => {
const { container } = render(<MySettingsContainer />, undefined, {
appContextOverrides: {
activeLicense: null,
},
});
const scoped = within(container);
expect(scoped.queryByText('License')).not.toBeInTheDocument();
expect(scoped.queryByText('License key')).not.toBeInTheDocument();
expect(
scoped.queryByText('Your SigNoz license key.'),
).not.toBeInTheDocument();
});
it('Should mask license key in the UI', () => {
const { container } = render(<MySettingsContainer />, undefined, {
appContextOverrides: {
activeLicense: {
key: 'abcd',
} as any,
},
});
expect(within(container).getByText('ab********cd')).toBeInTheDocument();
});
it('Should not mask license key if it is too short', () => {
const { container } = render(<MySettingsContainer />, undefined, {
appContextOverrides: {
activeLicense: {
key: 'abc',
} as any,
},
});
expect(within(container).getByText('abc')).toBeInTheDocument();
});
it('Should copy license key and show success toast', async () => {
const user = userEvent.setup();
const { container } = render(<MySettingsContainer />, undefined, {
appContextOverrides: {
activeLicense: {
key: 'test-license-key-12345',
} as any,
},
});
await user.click(within(container).getByTestId('license-key-copy-btn'));
await waitFor(() => {
expect(copyToClipboardFn).toHaveBeenCalledWith('test-license-key-12345');
expect(successNotification).toHaveBeenCalledWith({
message: 'Copied to clipboard',
});
});
});
});
});

View File

@@ -15,7 +15,6 @@ import { useMutation } from 'react-query';
import { UserPreference } from 'types/api/preferences/preference';
import { showErrorNotification } from 'utils/error';
import LicenseSection from './LicenseSection';
import TimezoneAdaptation from './TimezoneAdaptation/TimezoneAdaptation';
import UserInfo from './UserInfo';
@@ -231,8 +230,6 @@ function MySettings(): JSX.Element {
</div>
</div>
</div>
<LicenseSection />
</div>
);
}

View File

@@ -20,17 +20,13 @@ interface AttributeRecord {
interface AttributeActionsProps {
record: AttributeRecord;
isPinned?: boolean;
onTogglePin?: (fieldKey: string) => void;
showPinned?: boolean;
showCopyOptions?: boolean;
onTogglePin: (fieldKey: string) => void;
}
export default function AttributeActions({
record,
isPinned,
onTogglePin,
showPinned = true,
showCopyOptions = true,
}: AttributeActionsProps): JSX.Element {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [isFilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
@@ -95,7 +91,7 @@ export default function AttributeActions({
}, [onCopyFieldValue, textToCopy]);
const handleTogglePin = useCallback((): void => {
onTogglePin?.(record.field);
onTogglePin(record.field);
}, [onTogglePin, record.field]);
const moreActionsContent = (
@@ -109,41 +105,35 @@ export default function AttributeActions({
>
Group By Attribute
</Button>
{showCopyOptions && (
<>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldName}
block
>
Copy Field Name
</Button>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldValue}
block
>
Copy Field Value
</Button>
</>
)}
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldName}
block
>
Copy Field Name
</Button>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldValue}
block
>
Copy Field Value
</Button>
</div>
);
return (
<div className={cx('action-btn', { 'action-btn--is-open': isOpen })}>
{showPinned && (
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
<Button
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
aria-label={isPinned ? 'Unpin attribute' : 'Pin attribute'}
icon={<Pin size={14} fill={isPinned ? 'currentColor' : 'none'} />}
onClick={handleTogglePin}
/>
</Tooltip>
)}
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
<Button
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
aria-label={isPinned ? 'Unpin attribute' : 'Pin attribute'}
icon={<Pin size={14} fill={isPinned ? 'currentColor' : 'none'} />}
onClick={handleTogglePin}
/>
</Tooltip>
<Tooltip title="Filter for value">
<Button
className="filter-btn periscope-btn"
@@ -194,7 +184,4 @@ export default function AttributeActions({
AttributeActions.defaultProps = {
isPinned: false,
showPinned: true,
showCopyOptions: true,
onTogglePin: undefined,
};

View File

@@ -47,56 +47,15 @@
.description {
display: flex;
flex-direction: column;
padding: 10px 0px;
gap: 16px;
padding: 10px 12px;
.item {
padding: 8px 12px;
&,
.attribute-container {
display: flex;
flex-direction: column;
gap: 8px;
position: relative; // ensure absolutely-positioned children anchor to the row
}
// Show attribute actions on hover for hardcoded rows
.attribute-actions-wrapper {
display: none;
gap: 8px;
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
border-radius: 4px;
padding: 2px;
// style the action button group
.action-btn {
display: flex;
gap: 4px;
}
.filter-btn {
display: flex;
align-items: center;
border: none;
box-shadow: none;
border-radius: 2px;
background: var(--bg-slate-400);
padding: 4px;
gap: 3px;
height: 24px;
width: 24px;
&:hover {
background: var(--bg-slate-300);
}
}
}
&:hover {
background-color: var(--bg-slate-500);
.attribute-actions-wrapper {
display: flex;
}
}
.span-name-wrapper {
@@ -687,29 +646,6 @@
.description {
.item {
.attribute-actions-wrapper {
display: none;
gap: 8px;
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
border-radius: 4px;
padding: 2px;
.filter-btn {
background: var(--bg-vanilla-200);
&:hover {
background: var(--bg-vanilla-100);
}
}
}
&:hover {
background-color: var(--bg-vanilla-300);
.attribute-actions-wrapper {
display: flex;
}
}
.span-name-wrapper {
.span-percentile-value-container {
&.span-percentile-value-container-open {

View File

@@ -21,7 +21,6 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { themeColors } from 'constants/theme';
import { USER_PREFERENCES } from 'constants/userPreferences';
import AttributeActions from 'container/SpanDetailsDrawer/Attributes/AttributeActions';
import dayjs from 'dayjs';
import useClickOutside from 'hooks/useClickOutside';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
@@ -104,10 +103,6 @@ interface IResourceAttribute {
const DEFAULT_RESOURCE_ATTRIBUTES = {
serviceName: 'service.name',
name: 'name',
spanId: 'span_id',
spanKind: 'kind_string',
statusCodeString: 'status_code_string',
statusMessage: 'status_message',
};
// eslint-disable-next-line sonarjs/cognitive-complexity
@@ -840,16 +835,6 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
{selectedSpan.spanId}
</Typography.Text>
</div>
<div className="attribute-actions-wrapper">
<AttributeActions
record={{
field: DEFAULT_RESOURCE_ATTRIBUTES.spanId,
value: selectedSpan.spanId,
}}
showPinned={false}
showCopyOptions={false}
/>
</div>
</div>
<div className="item">
<Typography.Text className="attribute-key">start time</Typography.Text>
@@ -878,16 +863,6 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
</Typography.Text>
</Tooltip>
</div>
<div className="attribute-actions-wrapper">
<AttributeActions
record={{
field: DEFAULT_RESOURCE_ATTRIBUTES.serviceName,
value: selectedSpan.serviceName,
}}
showPinned={false}
showCopyOptions={false}
/>
</div>
</div>
</div>
<div className="item">
@@ -897,16 +872,6 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
{selectedSpan.spanKind}
</Typography.Text>
</div>
<div className="attribute-actions-wrapper">
<AttributeActions
record={{
field: DEFAULT_RESOURCE_ATTRIBUTES.spanKind,
value: selectedSpan.spanKind,
}}
showPinned={false}
showCopyOptions={false}
/>
</div>
</div>
<div className="item">
<Typography.Text className="attribute-key">
@@ -917,16 +882,6 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
{selectedSpan.statusCodeString}
</Typography.Text>
</div>
<div className="attribute-actions-wrapper">
<AttributeActions
record={{
field: DEFAULT_RESOURCE_ATTRIBUTES.statusCodeString,
value: selectedSpan.statusCodeString,
}}
showPinned={false}
showCopyOptions={false}
/>
</div>
</div>
{selectedSpan.statusMessage && (
@@ -936,16 +891,6 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
attributeValue={selectedSpan.statusMessage}
onExpand={showStatusMessageModal}
/>
<div className="attribute-actions-wrapper">
<AttributeActions
record={{
field: DEFAULT_RESOURCE_ATTRIBUTES.statusMessage,
value: selectedSpan.statusMessage,
}}
showPinned={false}
showCopyOptions={false}
/>
</div>
</div>
)}
<div className="item">

View File

@@ -1,247 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import AttributeActions from '../Attributes/AttributeActions';
// Mock only Popover from antd to simplify hover/open behavior while keeping other components real
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
const MockPopover = ({
content,
children,
open,
onOpenChange,
...rest
}: any): JSX.Element => (
<div
data-testid="mock-popover-wrapper"
onMouseEnter={(): void => onOpenChange?.(true)}
{...rest}
>
{children}
{open ? <div data-testid="mock-popover-content">{content}</div> : null}
</div>
);
return { ...actual, Popover: MockPopover };
});
// Mock getAggregateKeys API used inside useTraceActions to resolve autocomplete keys
jest.mock('api/queryBuilder/getAttributeKeys', () => ({
getAggregateKeys: jest.fn().mockResolvedValue({
payload: {
attributeKeys: [
{
key: 'http.method',
dataType: 'string',
type: 'tag',
isColumn: true,
},
],
},
}),
}));
const record = { field: 'http.method', value: 'GET' };
describe('AttributeActions (unit)', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders core action buttons (pin, filter in/out, more)', async () => {
render(<AttributeActions record={record} isPinned={false} />);
expect(
screen.getByRole('button', { name: 'Pin attribute' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Filter for value' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Filter out value' }),
).toBeInTheDocument();
// more actions (ellipsis) button
expect(
document.querySelector('.lucide-ellipsis')?.closest('button'),
).toBeInTheDocument();
});
it('applies "Filter for" and calls redirectWithQueryBuilderData with correct query', async () => {
const redirectWithQueryBuilderData = jest.fn();
const currentQuery = {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
} as any;
render(<AttributeActions record={record} />, undefined, {
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
});
const filterForBtn = screen.getByRole('button', { name: 'Filter for value' });
await userEvent.click(filterForBtn);
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
op: '=',
value: 'GET',
}),
]),
}),
}),
]),
}),
}),
{},
expect.any(String),
);
});
});
it('applies "Filter out" and calls redirectWithQueryBuilderData with correct query', async () => {
const redirectWithQueryBuilderData = jest.fn();
const currentQuery = {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
} as any;
render(<AttributeActions record={record} />, undefined, {
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
});
const filterOutBtn = screen.getByRole('button', { name: 'Filter out value' });
await userEvent.click(filterOutBtn);
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
op: '!=',
value: 'GET',
}),
]),
}),
}),
]),
}),
}),
{},
expect.any(String),
);
});
});
it('opens more actions on hover and calls Group By handler; closes after click', async () => {
const redirectWithQueryBuilderData = jest.fn();
const currentQuery = {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
} as any;
render(<AttributeActions record={record} />, undefined, {
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
});
const ellipsisBtn = document
.querySelector('.lucide-ellipsis')
?.closest('button') as HTMLElement;
expect(ellipsisBtn).toBeInTheDocument();
// hover to trigger Popover open via mock
fireEvent.mouseEnter(ellipsisBtn.parentElement as Element);
// content appears
await waitFor(() =>
expect(screen.getByText('Group By Attribute')).toBeInTheDocument(),
);
await userEvent.click(screen.getByText('Group By Attribute'));
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
groupBy: expect.arrayContaining([
expect.objectContaining({ key: 'http.method' }),
]),
}),
]),
}),
}),
{},
expect.any(String),
);
});
// After clicking group by, popover should close
await waitFor(() =>
expect(screen.queryByTestId('mock-popover-content')).not.toBeInTheDocument(),
);
});
it('hides pin button when showPinned=false', async () => {
render(<AttributeActions record={record} showPinned={false} />);
expect(
screen.queryByRole('button', { name: /pin attribute/i }),
).not.toBeInTheDocument();
});
it('hides copy options when showCopyOptions=false', async () => {
render(<AttributeActions record={record} showCopyOptions={false} />);
const ellipsisBtn = document
.querySelector('.lucide-ellipsis')
?.closest('button') as HTMLElement;
fireEvent.mouseEnter(ellipsisBtn.parentElement as Element);
await waitFor(() =>
expect(screen.queryByText('Copy Field Name')).not.toBeInTheDocument(),
);
expect(screen.queryByText('Copy Field Value')).not.toBeInTheDocument();
});
});

View File

@@ -1,183 +0,0 @@
/* eslint-disable sonarjs/no-duplicate-string */
import {
initialClickHouseData,
initialQueryBuilderFormValuesMap,
initialQueryPromQLData,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { IDashboardVariable, Widgets } from 'types/api/dashboard/getAll';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { createDynamicVariableToWidgetsMap } from './utils';
const createMockDynamicVariable = (
overrides: Partial<IDashboardVariable> = {},
): IDashboardVariable => ({
id: 'var-1',
name: 'testVar',
description: '',
type: 'DYNAMIC',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
dynamicVariablesAttribute: 'service.name',
...overrides,
});
const createBaseWidget = (id: string, query: Query): Widgets => ({
id,
title: 'Test Widget',
description: '',
panelTypes: PANEL_TYPES.TIME_SERIES,
opacity: '1',
nullZeroValues: '',
timePreferance: 'GLOBAL_TIME',
softMin: null,
softMax: null,
selectedLogFields: null,
selectedTracesFields: null,
query,
});
const createMockPromQLWidget = (
id: string,
queries: {
query: string;
name?: string;
legend?: string;
disabled?: boolean;
}[],
): Widgets => {
const promqlQueries = queries.map((q) => ({
...initialQueryPromQLData,
query: q.query,
name: q.name || 'A',
legend: q.legend || '',
disabled: q.disabled ?? false,
}));
const query: Query = {
queryType: EQueryType.PROM,
promql: promqlQueries,
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [],
id: 'query-1',
};
return createBaseWidget(id, query);
};
const createMockClickHouseWidget = (
id: string,
queries: {
query: string;
name?: string;
legend?: string;
disabled?: boolean;
}[],
): Widgets => {
const clickhouseQueries = queries.map((q) => ({
...initialClickHouseData,
query: q.query,
name: q.name || 'A',
legend: q.legend || '',
disabled: q.disabled ?? false,
}));
const query: Query = {
queryType: EQueryType.CLICKHOUSE,
promql: [],
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: clickhouseQueries,
id: 'query-1',
};
return createBaseWidget(id, query);
};
const createMockQueryBuilderWidget = (
id: string,
filters: { key: string; value: string | string[]; op?: string }[],
): Widgets => {
const queryData = {
...initialQueryBuilderFormValuesMap[DataSource.LOGS],
queryName: 'A',
filters: {
items: filters.map((f, index) => ({
id: `filter-${index}`,
key: { key: f.key, dataType: DataTypes.String, type: '', id: f.key },
op: f.op || '=',
value: f.value,
})),
op: 'AND',
},
};
const query: Query = {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: {
queryData: [queryData],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [],
id: 'query-1',
};
return createBaseWidget(id, query);
};
describe('createDynamicVariableToWidgetsMap', () => {
it('should handle widgets with different query types', () => {
const dynamicVariables = [
createMockDynamicVariable({
id: 'var-1',
name: 'service.name123',
dynamicVariablesAttribute: 'service.name',
}),
];
const widgets = [
createMockPromQLWidget('widget-promql-pass', [
{ query: 'up{service="$service.name123"}' },
]),
createMockPromQLWidget('widget-promql-fail', [
{ query: 'up{service="$service.name"}' },
]),
createMockClickHouseWidget('widget-clickhouse-pass', [
{ query: "SELECT * FROM logs WHERE service_name = '$service.name123'" },
]),
createMockClickHouseWidget('widget-clickhouse-fail', [
{ query: "SELECT * FROM logs WHERE service_name = '$service.name'" },
]),
createMockQueryBuilderWidget('widget-builder-pass', [
{ key: 'service.name', value: '$service.name123' },
]),
createMockQueryBuilderWidget('widget-builder-fail', [
{ key: 'service.name', value: '$service.name' },
]),
];
const result = createDynamicVariableToWidgetsMap(dynamicVariables, widgets);
expect(result['var-1']).toContain('widget-promql-pass');
expect(result['var-1']).toContain('widget-clickhouse-pass');
expect(result['var-1']).toContain('widget-builder-pass');
expect(result['var-1']).not.toContain('widget-promql-fail');
expect(result['var-1']).not.toContain('widget-clickhouse-fail');
expect(result['var-1']).not.toContain('widget-builder-fail');
});
});

View File

@@ -104,9 +104,10 @@ export const createDynamicVariableToWidgetsMap = (
// Check each widget for usage of dynamic variables
if (Array.isArray(widgets)) {
widgets.forEach((widget) => {
if (widget.query?.queryType === EQueryType.QUERY_BUILDER) {
if (!Array.isArray(widget.query.builder.queryData)) return;
if (
widget.query?.builder?.queryData &&
widget.query?.queryType === EQueryType.QUERY_BUILDER
) {
widget.query.builder.queryData.forEach((queryData: IBuilderQuery) => {
// Check filter items for dynamic variables
queryData.filters?.items?.forEach((filter: TagFilterItem) => {
@@ -138,34 +139,6 @@ export const createDynamicVariableToWidgetsMap = (
});
}
});
} else if (widget.query?.queryType === EQueryType.PROM) {
if (!Array.isArray(widget.query.promql)) return;
widget.query.promql.forEach((promqlQuery) => {
dynamicVariables.forEach((variable) => {
if (
variable.dynamicVariablesAttribute &&
promqlQuery.query?.includes(`$${variable.name}`) &&
!dynamicVariableToWidgetsMap[variable.id].includes(widget.id)
) {
dynamicVariableToWidgetsMap[variable.id].push(widget.id);
}
});
});
} else if (widget.query?.queryType === EQueryType.CLICKHOUSE) {
if (!Array.isArray(widget.query.clickhouse_sql)) return;
widget.query.clickhouse_sql.forEach((clickhouseQuery) => {
dynamicVariables.forEach((variable) => {
if (
variable.dynamicVariablesAttribute &&
clickhouseQuery.query?.includes(`$${variable.name}`) &&
!dynamicVariableToWidgetsMap[variable.id].includes(widget.id)
) {
dynamicVariableToWidgetsMap[variable.id].push(widget.id);
}
});
});
}
});
}

View File

@@ -1,132 +0,0 @@
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { convertValue, getFormattedUnit } from 'lib/getConvertedValue';
describe('getFormattedUnit', () => {
it('should return the grafana unit for universal unit if it exists', () => {
const formattedUnit = getFormattedUnit(UniversalYAxisUnit.KILOBYTES);
expect(formattedUnit).toBe('deckbytes');
});
it('should return the unit directly if it is not a universal unit', () => {
const formattedUnit = getFormattedUnit('{reason}');
expect(formattedUnit).toBe('{reason}');
});
it('should return the universal unit directly if it does not have a grafana equivalent', () => {
const formattedUnit = getFormattedUnit(UniversalYAxisUnit.EXABYTES);
expect(formattedUnit).toBe(UniversalYAxisUnit.EXABYTES);
});
});
describe('convertValue', () => {
describe('data', () => {
it('should convert bytes (IEC) to kilobytes', () => {
expect(
convertValue(
1000,
UniversalYAxisUnit.BYTES_IEC,
UniversalYAxisUnit.KILOBYTES,
),
).toBe(1);
});
it('should convert bytes (SI) to kilobytes', () => {
expect(
convertValue(1000, UniversalYAxisUnit.BYTES, UniversalYAxisUnit.KILOBYTES),
).toBe(1);
});
it('should convert kilobytes to bytes', () => {
expect(
convertValue(1, UniversalYAxisUnit.KILOBYTES, UniversalYAxisUnit.BYTES),
).toBe(1000);
});
it('should convert megabytes to kilobytes', () => {
expect(convertValue(1, 'mbytes', 'kbytes')).toBe(1024);
});
it('should convert gigabytes to megabytes', () => {
expect(convertValue(1, 'gbytes', 'mbytes')).toBe(1024);
});
it('should convert kilobytes to megabytes', () => {
expect(convertValue(1024, 'kbytes', 'mbytes')).toBe(1);
});
it('should convert bits to gigabytes', () => {
// 12 GB = 103079215104 bits
expect(convertValue(103079215104, 'bits', 'gbytes')).toBe(12);
});
});
describe('time', () => {
it('should convert milliseconds to seconds', () => {
expect(convertValue(1000, 'ms', 's')).toBe(1);
});
it('should convert seconds to milliseconds', () => {
expect(convertValue(1, 's', 'ms')).toBe(1000);
});
it('should convert nanoseconds to milliseconds', () => {
expect(convertValue(1000000, 'ns', 'ms')).toBe(1);
});
it('should convert seconds to minutes', () => {
expect(convertValue(60, 's', 'm')).toBe(1);
});
it('should convert minutes to hours', () => {
expect(convertValue(60, 'm', 'h')).toBe(1);
});
});
describe('data rate', () => {
it('should convert bytes/sec to kibibytes/sec', () => {
expect(convertValue(1024, 'binBps', 'KiBs')).toBe(1);
});
it('should convert kibibytes/sec to bytes/sec', () => {
expect(convertValue(1, 'KiBs', 'binBps')).toBe(1024);
});
});
describe('throughput', () => {
it('should convert counts per second to counts per minute', () => {
expect(convertValue(1, 'cps', 'cpm')).toBe(1 / 60);
});
it('should convert operations per second to operations per minute', () => {
expect(convertValue(1, 'ops', 'opm')).toBe(1 / 60);
});
it('should convert counts per minute to counts per second', () => {
expect(convertValue(1, 'cpm', 'cps')).toBe(60);
});
it('should convert operations per minute to operations per second', () => {
expect(convertValue(1, 'opm', 'ops')).toBe(60);
});
});
describe('percent', () => {
it('should convert percentunit to percent', () => {
expect(convertValue(0.5, 'percentunit', 'percent')).toBe(50);
});
it('should convert percent to percentunit', () => {
expect(convertValue(50, 'percent', 'percentunit')).toBe(0.5);
});
});
describe('invalid values', () => {
it('should return null when currentUnit is invalid', () => {
expect(convertValue(100, 'invalidUnit', 'bytes')).toBeNull();
});
it('should return null when targetUnit is invalid', () => {
expect(convertValue(100, 'bytes', 'invalidUnit')).toBeNull();
});
});
});

View File

@@ -1,17 +1,3 @@
import {
UniversalUnitToGrafanaUnit,
Y_AXIS_UNIT_NAMES,
} from 'components/YAxisUnitSelector/constants';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { isUniversalUnit } from 'components/YAxisUnitSelector/utils';
// 1 byte = 8 bits
// Or 1 bit = 1/8 bytes
const BIT_FACTOR = 1 / 8;
const DECIMAL_FACTOR = 1000;
const BINARY_FACTOR = 1024;
const unitsMapping = [
{
label: 'Data',
@@ -29,132 +15,62 @@ const unitsMapping = [
{
label: 'bits(IEC)',
value: 'bits',
factor: BIT_FACTOR,
factor: 8, // 1 byte = 8 bits
},
{
label: 'bits(SI)',
value: 'decbits',
factor: BIT_FACTOR,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.KILOBITS],
value: UniversalYAxisUnit.KILOBITS,
factor: BIT_FACTOR * DECIMAL_FACTOR,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MEGABITS],
value: UniversalYAxisUnit.MEGABITS,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 2,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.GIGABITS],
value: UniversalYAxisUnit.GIGABITS,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 3,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.TERABITS],
value: UniversalYAxisUnit.TERABITS,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 4,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PETABITS],
value: UniversalYAxisUnit.PETABITS,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 5,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXABITS],
value: UniversalYAxisUnit.EXABITS,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 6,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZETTABITS],
value: UniversalYAxisUnit.ZETTABITS,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 7,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOTTABITS],
value: UniversalYAxisUnit.YOTTABITS,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 8,
factor: 8, // 1 byte = 8 bits
},
{
label: 'kibibytes',
value: 'kbytes',
factor: BINARY_FACTOR,
factor: 1024,
},
{
label: 'kilobytes',
value: 'deckbytes',
factor: DECIMAL_FACTOR,
factor: 1000,
},
{
label: 'mebibytes',
value: 'mbytes',
factor: BINARY_FACTOR ** 2,
factor: 1024 * 1024,
},
{
label: 'megabytes',
value: 'decmbytes',
factor: DECIMAL_FACTOR ** 2,
factor: 1000 * 1000,
},
{
label: 'gibibytes',
value: 'gbytes',
factor: BINARY_FACTOR ** 3,
factor: 1024 * 1024 * 1024,
},
{
label: 'gigabytes',
value: 'decgbytes',
factor: DECIMAL_FACTOR ** 3,
factor: 1000 * 1000 * 1000,
},
{
label: 'tebibytes',
value: 'tbytes',
factor: BINARY_FACTOR ** 4,
factor: 1024 * 1024 * 1024 * 1024,
},
{
label: 'terabytes',
value: 'dectbytes',
factor: DECIMAL_FACTOR ** 4,
factor: 1000 * 1000 * 1000 * 1000,
},
{
label: 'pebibytes',
value: 'pbytes',
factor: BINARY_FACTOR ** 5,
factor: 1024 * 1024 * 1024 * 1024 * 1024,
},
{
label: 'petabytes',
value: 'decpbytes',
factor: DECIMAL_FACTOR ** 5,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXABYTES],
value: UniversalYAxisUnit.EXABYTES,
factor: DECIMAL_FACTOR ** 6,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXBIBYTES],
value: UniversalYAxisUnit.EXBIBYTES,
factor: BINARY_FACTOR ** 6,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZETTABYTES],
value: UniversalYAxisUnit.ZETTABYTES,
factor: DECIMAL_FACTOR ** 7,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZEBIBYTES],
value: UniversalYAxisUnit.ZEBIBYTES,
factor: BINARY_FACTOR ** 7,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOTTABYTES],
value: UniversalYAxisUnit.YOTTABYTES,
factor: DECIMAL_FACTOR ** 8,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOBIBYTES],
value: UniversalYAxisUnit.YOBIBYTES,
factor: BINARY_FACTOR ** 8,
factor: 1000 * 1000 * 1000 * 1000 * 1000,
},
],
},
@@ -174,103 +90,44 @@ const unitsMapping = [
{
label: 'bits/sec(IEC)',
value: 'binbps',
factor: BIT_FACTOR, // 1 byte = 8 bits
factor: 8, // 1 byte = 8 bits
},
{
label: 'bits/sec(SI)',
value: 'bps',
factor: BIT_FACTOR, // 1 byte = 8 bits
factor: 8, // 1 byte = 8 bits
},
{
label: 'kibibytes/sec',
value: 'KiBs',
factor: BINARY_FACTOR,
factor: 1024,
},
{
label: 'kibibits/sec',
value: 'Kibits',
factor: BIT_FACTOR * BINARY_FACTOR, // 1 KiB = 8 Kibits
factor: 8 * 1024, // 1 KiB = 8 Kibits
},
{
label: 'kilobytes/sec',
value: 'KBs',
factor: DECIMAL_FACTOR,
factor: 1000,
},
{
label: 'kilobits/sec',
value: 'Kbits',
factor: BIT_FACTOR * DECIMAL_FACTOR, // 1 KB = 8 Kbits
factor: 8 * 1000, // 1 KB = 8 Kbits
},
{
label: 'mebibytes/sec',
value: 'MiBs',
factor: BINARY_FACTOR ** 2,
factor: 1024 * 1024,
},
{
label: 'mebibits/sec',
value: 'Mibits',
factor: BIT_FACTOR * BINARY_FACTOR ** 2, // 1 MiB = 8 Mibits
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXABYTES_SECOND],
value: UniversalYAxisUnit.EXABYTES_SECOND,
factor: DECIMAL_FACTOR ** 6,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZETTABYTES_SECOND],
value: UniversalYAxisUnit.ZETTABYTES_SECOND,
factor: DECIMAL_FACTOR ** 7,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOTTABYTES_SECOND],
value: UniversalYAxisUnit.YOTTABYTES_SECOND,
factor: DECIMAL_FACTOR ** 8,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXBIBYTES_SECOND],
value: UniversalYAxisUnit.EXBIBYTES_SECOND,
factor: BINARY_FACTOR ** 6,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZEBIBYTES_SECOND],
value: UniversalYAxisUnit.ZEBIBYTES_SECOND,
factor: BINARY_FACTOR ** 7,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOBIBYTES_SECOND],
value: UniversalYAxisUnit.YOBIBYTES_SECOND,
factor: BINARY_FACTOR ** 8,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXABITS_SECOND],
value: UniversalYAxisUnit.EXABITS_SECOND,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 6,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZETTABITS_SECOND],
value: UniversalYAxisUnit.ZETTABITS_SECOND,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 7,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOTTABITS_SECOND],
value: UniversalYAxisUnit.YOTTABITS_SECOND,
factor: BIT_FACTOR * DECIMAL_FACTOR ** 8,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXBIBITS_SECOND],
value: UniversalYAxisUnit.EXBIBITS_SECOND,
factor: BIT_FACTOR * BINARY_FACTOR ** 6,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZEBIBITS_SECOND],
value: UniversalYAxisUnit.ZEBIBITS_SECOND,
factor: BIT_FACTOR * BINARY_FACTOR ** 7,
},
{
label: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOBIBITS_SECOND],
value: UniversalYAxisUnit.YOBIBITS_SECOND,
factor: BIT_FACTOR * BINARY_FACTOR ** 8,
factor: 8 * 1024 * 1024, // 1 MiB = 8 Mibits
},
// ... (other options)
],
},
{
@@ -411,14 +268,6 @@ function findUnitObject(
return unitObj || null;
}
export function getFormattedUnit(unit: string): string {
const isUniversalYAxisUnit = isUniversalUnit(unit);
if (isUniversalYAxisUnit) {
return UniversalUnitToGrafanaUnit[unit as UniversalYAxisUnit] || unit;
}
return unit;
}
export function convertValue(
value: number,
currentUnit?: string,
@@ -432,12 +281,8 @@ export function convertValue(
) {
return value;
}
const formattedCurrentUnit = getFormattedUnit(currentUnit);
const formattedTargetUnit = getFormattedUnit(targetUnit);
const currentUnitObj = findUnitObject(formattedCurrentUnit);
const targetUnitObj = findUnitObject(formattedTargetUnit);
const currentUnitObj = findUnitObject(currentUnit);
const targetUnitObj = findUnitObject(targetUnit);
if (currentUnitObj && targetUnitObj) {
const baseValue = value * currentUnitObj.factor;

View File

@@ -291,10 +291,6 @@ export function DashboardProvider({
variable.order = order;
existingOrders.add(order);
// ! BWC - Specific case for backward compatibility where textboxValue was used instead of defaultValue
if (variable.type === 'TEXTBOX' && !variable.defaultValue) {
variable.defaultValue = variable.textboxValue || '';
}
}
if (variable.id === undefined) {

View File

@@ -1,5 +1,5 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { render, screen, waitFor } from '@testing-library/react';
import { render, waitFor } from '@testing-library/react';
import getDashboard from 'api/v1/dashboards/id/get';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
@@ -379,9 +379,12 @@ describe('Dashboard Provider - URL Variables Integration', () => {
// Empty URL variables - tests initialization flow
mockGetUrlVariables.mockReturnValue({});
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
const { getByTestId } = renderWithDashboardProvider(
`/dashboard/${DASHBOARD_ID}`,
{
dashboardId: DASHBOARD_ID,
},
);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
@@ -412,14 +415,16 @@ describe('Dashboard Provider - URL Variables Integration', () => {
});
// Verify dashboard state contains the variables with default values
const dashboardVariables = await screen.findByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
await waitFor(() => {
const dashboardVariables = getByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables).toHaveProperty('environment');
expect(parsedVariables).toHaveProperty('services');
// Default allSelected values should be preserved
expect(parsedVariables.environment.allSelected).toBe(false);
expect(parsedVariables.services.allSelected).toBe(false);
expect(parsedVariables).toHaveProperty('environment');
expect(parsedVariables).toHaveProperty('services');
// Default allSelected values should be preserved
expect(parsedVariables.environment.allSelected).toBe(false);
expect(parsedVariables.services.allSelected).toBe(false);
});
});
it('should merge URL variables with dashboard data and normalize values correctly', async () => {
@@ -433,9 +438,12 @@ describe('Dashboard Provider - URL Variables Integration', () => {
.mockReturnValueOnce('development')
.mockReturnValueOnce(['db', 'cache']);
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
const { getByTestId } = renderWithDashboardProvider(
`/dashboard/${DASHBOARD_ID}`,
{
dashboardId: DASHBOARD_ID,
},
);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
@@ -466,16 +474,18 @@ describe('Dashboard Provider - URL Variables Integration', () => {
});
// Verify the dashboard state reflects the normalized URL values
const dashboardVariables = await screen.findByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
await waitFor(() => {
const dashboardVariables = getByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
// The selectedValue should be updated with normalized URL values
expect(parsedVariables.environment.selectedValue).toBe('development');
expect(parsedVariables.services.selectedValue).toEqual(['db', 'cache']);
// The selectedValue should be updated with normalized URL values
expect(parsedVariables.environment.selectedValue).toBe('development');
expect(parsedVariables.services.selectedValue).toEqual(['db', 'cache']);
// allSelected should be set to false when URL values override
expect(parsedVariables.environment.allSelected).toBe(false);
expect(parsedVariables.services.allSelected).toBe(false);
// allSelected should be set to false when URL values override
expect(parsedVariables.environment.allSelected).toBe(false);
expect(parsedVariables.services.allSelected).toBe(false);
});
});
it('should handle ALL_SELECTED_VALUE from URL and set allSelected correctly', async () => {
@@ -485,9 +495,12 @@ describe('Dashboard Provider - URL Variables Integration', () => {
mockGetUrlVariables.mockReturnValue(urlVariables);
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
const { getByTestId } = renderWithDashboardProvider(
`/dashboard/${DASHBOARD_ID}`,
{
dashboardId: DASHBOARD_ID,
},
);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
@@ -500,8 +513,8 @@ describe('Dashboard Provider - URL Variables Integration', () => {
);
// Verify that allSelected is set to true for the services variable
await waitFor(async () => {
const dashboardVariables = await screen.findByTestId('dashboard-variables');
await waitFor(() => {
const dashboardVariables = getByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables.services.allSelected).toBe(true);
@@ -550,203 +563,3 @@ describe('Dashboard Provider - URL Variables Integration', () => {
});
});
});
describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
const DASHBOARD_ID = 'test-dashboard-id';
beforeEach(() => {
jest.clearAllMocks();
mockGetUrlVariables.mockReturnValue({});
// eslint-disable-next-line sonarjs/no-identical-functions
mockNormalizeUrlValueForVariable.mockImplementation((urlValue) => {
if (urlValue === undefined || urlValue === null) {
return urlValue;
}
return urlValue as IDashboardVariable['selectedValue'];
});
});
describe('Textbox Variable defaultValue Migration', () => {
it('should set defaultValue from textboxValue for TEXTBOX variables without defaultValue (BWC)', async () => {
// Mock dashboard with TEXTBOX variable that has textboxValue but no defaultValue
// This simulates old data format before the migration
/* eslint-disable @typescript-eslint/no-explicit-any */
mockGetDashboard.mockResolvedValue({
httpStatusCode: 200,
data: {
id: DASHBOARD_ID,
title: 'Test Dashboard',
data: {
variables: {
myTextbox: {
id: 'textbox-id',
name: 'myTextbox',
type: 'TEXTBOX',
textboxValue: 'legacy-default-value',
// defaultValue is intentionally missing to test BWC
multiSelect: false,
showALLOption: false,
sort: 'DISABLED',
} as any,
},
},
},
} as any);
/* eslint-enable @typescript-eslint/no-explicit-any */
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
});
// Verify that defaultValue is set from textboxValue
await waitFor(async () => {
const dashboardVariables = await screen.findByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables.myTextbox.type).toBe('TEXTBOX');
expect(parsedVariables.myTextbox.textboxValue).toBe('legacy-default-value');
expect(parsedVariables.myTextbox.defaultValue).toBe('legacy-default-value');
});
});
it('should not override existing defaultValue for TEXTBOX variables', async () => {
// Mock dashboard with TEXTBOX variable that already has defaultValue
/* eslint-disable @typescript-eslint/no-explicit-any */
mockGetDashboard.mockResolvedValue({
httpStatusCode: 200,
data: {
id: DASHBOARD_ID,
title: 'Test Dashboard',
data: {
variables: {
myTextbox: {
id: 'textbox-id',
name: 'myTextbox',
type: 'TEXTBOX',
textboxValue: 'old-textbox-value',
defaultValue: 'existing-default-value',
multiSelect: false,
showALLOption: false,
sort: 'DISABLED',
} as any,
},
},
},
} as any);
/* eslint-enable @typescript-eslint/no-explicit-any */
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
});
// Verify that existing defaultValue is preserved
await waitFor(async () => {
const dashboardVariables = await screen.findByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables.myTextbox.type).toBe('TEXTBOX');
expect(parsedVariables.myTextbox.defaultValue).toBe(
'existing-default-value',
);
});
});
it('should set empty defaultValue when textboxValue is also empty for TEXTBOX variables', async () => {
// Mock dashboard with TEXTBOX variable with empty textboxValue and no defaultValue
/* eslint-disable @typescript-eslint/no-explicit-any */
mockGetDashboard.mockResolvedValue({
httpStatusCode: 200,
data: {
id: DASHBOARD_ID,
title: 'Test Dashboard',
data: {
variables: {
myTextbox: {
id: 'textbox-id',
name: 'myTextbox',
type: 'TEXTBOX',
textboxValue: '',
// defaultValue is intentionally missing
multiSelect: false,
showALLOption: false,
sort: 'DISABLED',
} as any,
},
},
},
} as any);
/* eslint-enable @typescript-eslint/no-explicit-any */
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
});
// Verify that defaultValue is set to empty string
await waitFor(async () => {
const dashboardVariables = await screen.findByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables.myTextbox.type).toBe('TEXTBOX');
expect(parsedVariables.myTextbox.defaultValue).toBe('');
});
});
it('should not apply BWC logic to non-TEXTBOX variables', async () => {
// Mock dashboard with QUERY variable that has no defaultValue
/* eslint-disable @typescript-eslint/no-explicit-any */
mockGetDashboard.mockResolvedValue({
httpStatusCode: 200,
data: {
id: DASHBOARD_ID,
title: 'Test Dashboard',
data: {
variables: {
myQuery: {
id: 'query-id',
name: 'myQuery',
type: 'QUERY',
queryValue: 'SELECT * FROM test',
textboxValue: 'should-not-be-used',
// defaultValue is intentionally missing
multiSelect: false,
showALLOption: false,
sort: 'DISABLED',
} as any,
},
},
},
} as any);
/* eslint-enable @typescript-eslint/no-explicit-any */
renderWithDashboardProvider(`/dashboard/${DASHBOARD_ID}`, {
dashboardId: DASHBOARD_ID,
});
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
});
// Verify that defaultValue is NOT set from textboxValue for QUERY type
await waitFor(async () => {
const dashboardVariables = await screen.findByTestId('dashboard-variables');
const parsedVariables = JSON.parse(dashboardVariables.textContent || '{}');
expect(parsedVariables.myQuery.type).toBe('QUERY');
// defaultValue should not be set to textboxValue for non-TEXTBOX variables
expect(parsedVariables.myQuery.defaultValue).not.toBe('should-not-be-used');
});
});
});
});

View File

@@ -37,7 +37,6 @@ export interface IDashboardVariable {
// Custom
customValue?: string;
// Textbox
// special case of variable where defaultValue is same as this. Otherwise, defaultValue is a single field
textboxValue?: string;
sort: TSortVariableValuesType;

View File

@@ -7634,6 +7634,11 @@ compute-scroll-into-view@^3.0.2:
resolved "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.0.3.tgz"
integrity sha512-nadqwNxghAGTamwIqQSG433W6OADZx2vCo3UXHNrzTRHK/htu+7+L0zhjEoaeaQVNAi3YgqWDv8+tzf0hRfR+A==
confusing-browser-globals@^1.0.10:
version "1.0.11"
resolved "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz"
integrity sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==
connect-history-api-fallback@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz"
@@ -9013,11 +9018,42 @@ escodegen@^2.0.0:
optionalDependencies:
source-map "~0.6.1"
eslint-config-airbnb-base@^15.0.0:
version "15.0.0"
resolved "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz"
integrity sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==
dependencies:
confusing-browser-globals "^1.0.10"
object.assign "^4.1.2"
object.entries "^1.1.5"
semver "^6.3.0"
eslint-config-airbnb-typescript@^16.1.4:
version "16.2.0"
resolved "https://registry.npmjs.org/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-16.2.0.tgz"
integrity sha512-OUaMPZpTOZGKd5tXOjJ9PRU4iYNW/Z5DoHIynjsVK/FpkWdiY5+nxQW6TiJAlLwVI1l53xUOrnlZWtVBVQzuWA==
dependencies:
eslint-config-airbnb-base "^15.0.0"
eslint-config-airbnb@^19.0.4:
version "19.0.4"
resolved "https://registry.npmjs.org/eslint-config-airbnb/-/eslint-config-airbnb-19.0.4.tgz"
integrity sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==
dependencies:
eslint-config-airbnb-base "^15.0.0"
object.assign "^4.1.2"
object.entries "^1.1.5"
eslint-config-prettier@^8.3.0:
version "8.8.0"
resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.8.0.tgz"
integrity sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==
eslint-config-standard@^16.0.3:
version "16.0.3"
resolved "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-16.0.3.tgz"
integrity sha512-x4fmJL5hGqNJKGHSjnLdgA6U6h1YW/G2dW9fA+cyVur4SK6lyue8+UgNKWlZtUDTXvgKDD/Oa3GQjmB5kjtVvg==
eslint-import-resolver-node@^0.3.7:
version "0.3.7"
resolved "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.7.tgz"
@@ -9034,6 +9070,14 @@ eslint-module-utils@^2.8.0:
dependencies:
debug "^3.2.7"
eslint-plugin-es@^3.0.0:
version "3.0.1"
resolved "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz"
integrity sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==
dependencies:
eslint-utils "^2.0.0"
regexpp "^3.0.0"
eslint-plugin-import@^2.28.1:
version "2.28.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.28.1.tgz#63b8b5b3c409bfc75ebaf8fb206b07ab435482c4"
@@ -9086,6 +9130,18 @@ eslint-plugin-jsx-a11y@^6.5.1:
object.fromentries "^2.0.6"
semver "^6.3.0"
eslint-plugin-node@^11.1.0:
version "11.1.0"
resolved "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz"
integrity sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==
dependencies:
eslint-plugin-es "^3.0.0"
eslint-utils "^2.0.0"
ignore "^5.1.1"
minimatch "^3.0.4"
resolve "^1.10.1"
semver "^6.1.0"
eslint-plugin-prettier@^4.0.0:
version "4.2.1"
resolved "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz"
@@ -9093,6 +9149,11 @@ eslint-plugin-prettier@^4.0.0:
dependencies:
prettier-linter-helpers "^1.0.0"
eslint-plugin-promise@^5.1.0:
version "5.2.0"
resolved "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-5.2.0.tgz"
integrity sha512-SftLb1pUG01QYq2A/hGAWfDRXqYD82zE7j7TopDOyNdU+7SvvoXREls/+PRTY17vUXzXnZA/zfnyKgRH6x4JJw==
eslint-plugin-react-hooks@^4.3.0:
version "4.6.0"
resolved "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz"
@@ -9137,7 +9198,7 @@ eslint-scope@5.1.1, eslint-scope@^5.1.1:
esrecurse "^4.3.0"
estraverse "^4.1.1"
eslint-utils@^2.1.0:
eslint-utils@^2.0.0, eslint-utils@^2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz"
integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==
@@ -10672,7 +10733,7 @@ ignore@^4.0.6:
resolved "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz"
integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
ignore@^5.1.8, ignore@^5.2.0:
ignore@^5.1.1, ignore@^5.1.8, ignore@^5.2.0:
version "5.2.4"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
@@ -13632,7 +13693,7 @@ object-keys@^1.1.1:
resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz"
integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
object.assign@^4.1.3, object.assign@^4.1.4:
object.assign@^4.1.2, object.assign@^4.1.3, object.assign@^4.1.4:
version "4.1.4"
resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz"
integrity sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==
@@ -13642,7 +13703,7 @@ object.assign@^4.1.3, object.assign@^4.1.4:
has-symbols "^1.0.3"
object-keys "^1.1.1"
object.entries@^1.1.6:
object.entries@^1.1.5, object.entries@^1.1.6:
version "1.1.6"
resolved "https://registry.npmjs.org/object.entries/-/object.entries-1.1.6.tgz"
integrity sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==
@@ -15753,7 +15814,7 @@ regexp.prototype.flags@^1.4.3, regexp.prototype.flags@^1.5.0:
define-properties "^1.2.0"
functions-have-names "^1.2.3"
regexpp@^3.1.0:
regexpp@^3.0.0, regexpp@^3.1.0:
version "3.2.0"
resolved "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz"
integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==
@@ -16034,7 +16095,7 @@ resolve.exports@^1.1.0:
resolved "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.1.tgz"
integrity sha512-/NtpHNDN7jWhAaQ9BvBUYZ6YTXsRBgfqWFWP7BZBaoMJO/I3G5OFzvTuWNlZC3aPjins1F+TNrLKsGbH4rfsRQ==
resolve@^1.10.0, resolve@^1.12.0, resolve@^1.14.2, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.1:
resolve@^1.10.0, resolve@^1.10.1, resolve@^1.12.0, resolve@^1.14.2, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.1:
version "1.22.2"
resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz"
integrity sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==
@@ -16282,7 +16343,7 @@ selfsigned@^2.4.1:
"@types/node-forge" "^1.3.0"
node-forge "^1"
"semver@2 || 3 || 4 || 5", semver@7.3.7, semver@7.5.4, semver@7.x, semver@^5.6.0, semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0, semver@^6.3.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.6.3:
"semver@2 || 3 || 4 || 5", semver@7.3.7, semver@7.5.4, semver@7.x, semver@^5.6.0, semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0, semver@^6.3.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.6.3:
version "7.5.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==

14
go.mod
View File

@@ -16,7 +16,6 @@ require (
github.com/coreos/go-oidc/v3 v3.14.1
github.com/dgraph-io/ristretto/v2 v2.3.0
github.com/dustin/go-humanize v1.0.1
github.com/gin-gonic/gin v1.11.0
github.com/go-co-op/gocron v1.30.1
github.com/go-openapi/runtime v0.28.0
github.com/go-openapi/strfmt v0.23.0
@@ -93,12 +92,6 @@ require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.15.1 // indirect
@@ -106,11 +99,10 @@ require (
github.com/swaggest/refl v1.4.0 // indirect
github.com/swaggest/usecase v1.3.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 // indirect
go.opentelemetry.io/collector/config/configretry v1.34.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/tools/godoc v0.1.0-deprecated // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
@@ -207,7 +199,7 @@ require (
github.com/jpillora/backoff v1.0.0 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
@@ -351,7 +343,7 @@ require (
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.39.0 // indirect
gonum.org/v1/gonum v0.16.0 // indirect
google.golang.org/api v0.236.0
google.golang.org/api v0.236.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect
google.golang.org/grpc v1.75.1 // indirect

28
go.sum
View File

@@ -292,11 +292,7 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-co-op/gocron v1.30.1 h1:tjWUvJl5KrcwpkEkSXFSQFr4F9h5SfV/m4+RX0cV2fs=
github.com/go-co-op/gocron v1.30.1/go.mod h1:39f6KNSGVOU1LO/ZOoZfcSxwlsJDQOKSu8erN0SH48Y=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
@@ -344,17 +340,9 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ
github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58=
github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-redis/redismock/v9 v9.2.0 h1:ZrMYQeKPECZPjOj5u9eyOjg8Nnb0BS9lkVIZ6IpsKLw=
github.com/go-redis/redismock/v9 v9.2.0/go.mod h1:18KHfGDK4Y6c2R0H38EUGWAdc7ZQS9gfYxc94k7rWT0=
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
@@ -372,8 +360,6 @@ github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJA
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
@@ -650,8 +636,8 @@ github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCy
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs=
github.com/knadh/koanf v1.5.0/go.mod h1:Hgyjp4y8v44hpZtPzs7JZfRAW5AhN7KfZcwv1RYggDs=
github.com/knadh/koanf/v2 v2.2.0 h1:FZFwd9bUjpb8DyCWARUBy5ovuhDs1lI87dOEn2K8UVU=
@@ -681,8 +667,6 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6Fm
github.com/leodido/go-syslog/v4 v4.2.0 h1:A7vpbYxsO4e2E8udaurkLlxP5LDpDbmPMsGnuhb7jVk=
github.com/leodido/go-syslog/v4 v4.2.0/go.mod h1:eJ8rUfDN5OS6dOkCOBYlg2a+hbAg6pJa99QXXgMrd98=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b h1:11UHH39z1RhZ5dc4y4r/4koJo6IYFgTRMe/LlwRTEw0=
github.com/leodido/ragel-machinery v0.0.0-20190525184631-5f46317e436b/go.mod h1:WZxr2/6a/Ar9bMDc2rN/LJrE/hF6bXE4LPyDSIxwAfg=
github.com/linode/linodego v1.49.0 h1:MNd3qwvQzbXB5mCpvdCqlUIu1RPA9oC+50LyB9kK+GQ=
@@ -1039,8 +1023,6 @@ github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkF
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/uptrace/bun v1.2.9 h1:OOt2DlIcRUMSZPr6iXDFg/LaQd59kOxbAjpIVHddKRs=
github.com/uptrace/bun v1.2.9/go.mod h1:r2ZaaGs9Ru5bpGTr8GQfp8jp+TlCav9grYCPOu2CJSg=
github.com/uptrace/bun/dialect/pgdialect v1.2.9 h1:caf5uFbOGiXvadV6pA5gn87k0awFFxL1kuuY3SpxnWk=
@@ -1289,8 +1271,8 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -1735,8 +1717,6 @@ google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX
google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78=
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=

View File

@@ -1,150 +0,0 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/gatewaytypes"
"github.com/gorilla/mux"
)
func (provider *provider) addGatewayRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.GetIngestionKeys), handler.OpenAPIDef{
ID: "GetIngestionKeys",
Tags: []string{"gateway"},
Summary: "Get ingestion keys for workspace",
Description: "This endpoint returns the ingestion keys for a workspace",
Request: nil,
RequestContentType: "",
Response: new(gatewaytypes.GettableIngestionKeys),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/search", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.SearchIngestionKeys), handler.OpenAPIDef{
ID: "SearchIngestionKeys",
Tags: []string{"gateway"},
Summary: "Search ingestion keys for workspace",
Description: "This endpoint returns the ingestion keys for a workspace",
Request: nil,
RequestContentType: "",
Response: new(gatewaytypes.GettableIngestionKeys),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.CreateIngestionKey), handler.OpenAPIDef{
ID: "CreateIngestionKey",
Tags: []string{"gateway"},
Summary: "Create ingestion key for workspace",
Description: "This endpoint creates an ingestion key for the workspace",
Request: new(gatewaytypes.PostableIngestionKey),
RequestContentType: "application/json",
Response: new(gatewaytypes.GettableCreatedIngestionKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.UpdateIngestionKey), handler.OpenAPIDef{
ID: "UpdateIngestionKey",
Tags: []string{"gateway"},
Summary: "Update ingestion key for workspace",
Description: "This endpoint updates an ingestion key for the workspace",
Request: new(gatewaytypes.PostableIngestionKey),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.DeleteIngestionKey), handler.OpenAPIDef{
ID: "DeleteIngestionKey",
Tags: []string{"gateway"},
Summary: "Delete ingestion key for workspace",
Description: "This endpoint deletes an ingestion key for the workspace",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}/limits", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.CreateIngestionKeyLimit), handler.OpenAPIDef{
ID: "CreateIngestionKeyLimit",
Tags: []string{"gateway"},
Summary: "Create limit for the ingestion key",
Description: "This endpoint creates an ingestion key limit",
Request: new(gatewaytypes.PostableIngestionKeyLimit),
RequestContentType: "application/json",
Response: new(gatewaytypes.GettableCreatedIngestionKeyLimit),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.UpdateIngestionKeyLimit), handler.OpenAPIDef{
ID: "UpdateIngestionKeyLimit",
Tags: []string{"gateway"},
Summary: "Update limit for the ingestion key",
Description: "This endpoint updates an ingestion key limit",
Request: new(gatewaytypes.UpdatableIngestionKeyLimit),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.DeleteIngestionKeyLimit), handler.OpenAPIDef{
ID: "DeleteIngestionKeyLimit",
Tags: []string{"gateway"},
Summary: "Delete limit for the ingestion key",
Description: "This endpoint deletes an ingestion key limit",
Request: nil,
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/middleware"
@@ -40,7 +39,6 @@ type provider struct {
dashboardModule dashboard.Module
dashboardHandler dashboard.Handler
metricsExplorerHandler metricsexplorer.Handler
gatewayHandler gateway.Handler
}
func NewFactory(
@@ -57,10 +55,9 @@ func NewFactory(
dashboardModule dashboard.Module,
dashboardHandler dashboard.Handler,
metricsExplorerHandler metricsexplorer.Handler,
gatewayHandler gateway.Handler,
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
return newProvider(ctx, providerSettings, config, orgGetter, authz, orgHandler, userHandler, sessionHandler, authDomainHandler, preferenceHandler, globalHandler, promoteHandler, flaggerHandler, dashboardModule, dashboardHandler, metricsExplorerHandler, gatewayHandler)
return newProvider(ctx, providerSettings, config, orgGetter, authz, orgHandler, userHandler, sessionHandler, authDomainHandler, preferenceHandler, globalHandler, promoteHandler, flaggerHandler, dashboardModule, dashboardHandler, metricsExplorerHandler)
})
}
@@ -81,7 +78,6 @@ func newProvider(
dashboardModule dashboard.Module,
dashboardHandler dashboard.Handler,
metricsExplorerHandler metricsexplorer.Handler,
gatewayHandler gateway.Handler,
) (apiserver.APIServer, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
router := mux.NewRouter().UseEncodedPath()
@@ -101,7 +97,6 @@ func newProvider(
dashboardModule: dashboardModule,
dashboardHandler: dashboardHandler,
metricsExplorerHandler: metricsExplorerHandler,
gatewayHandler: gatewayHandler,
}
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
@@ -158,10 +153,6 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addGatewayRoutes(router); err != nil {
return err
}
return nil
}

View File

@@ -6,15 +6,10 @@ import (
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/http/client"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
admin "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/option"
)
const (
@@ -22,28 +17,19 @@ const (
redirectPath string = "/api/v1/complete/google"
)
var scopes []string = []string{"email", "profile"}
var (
scopes []string = []string{"email"}
)
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
store authtypes.AuthNStore
settings factory.ScopedProviderSettings
httpClient *client.Client
store authtypes.AuthNStore
}
func New(ctx context.Context, store authtypes.AuthNStore, providerSettings factory.ProviderSettings) (*AuthN, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/authn/callbackauthn/googlecallbackauthn")
httpClient, err := client.New(settings.Logger(), providerSettings.TracerProvider, providerSettings.MeterProvider)
if err != nil {
return nil, err
}
func New(ctx context.Context, store authtypes.AuthNStore) (*AuthN, error) {
return &AuthN{
store: store,
settings: settings,
httpClient: httpClient,
store: store,
}, nil
}
@@ -72,13 +58,11 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
if err := query.Get("error"); err != "" {
a.settings.Logger().ErrorContext(ctx, "google: error while authenticating", "error", err, "error_description", query.Get("error_description"))
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "google: error while authenticating").WithAdditional(query.Get("error_description"))
}
state, err := authtypes.NewStateFromString(query.Get("state"))
if err != nil {
a.settings.Logger().ErrorContext(ctx, "google: invalid state", "error", err)
return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeInvalidState, "google: invalid state").WithAdditional(err.Error())
}
@@ -92,12 +76,10 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
if err != nil {
var retrieveError *oauth2.RetrieveError
if errors.As(err, &retrieveError) {
a.settings.Logger().ErrorContext(ctx, "google: failed to get token", "error", err, "error_description", retrieveError.ErrorDescription, "body", string(retrieveError.Body))
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: failed to get token").WithAdditional(retrieveError.ErrorDescription)
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: failed to get token").WithAdditional(retrieveError.ErrorDescription).WithAdditional(string(retrieveError.Body))
}
a.settings.Logger().ErrorContext(ctx, "google: failed to get token", "error", err)
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "google: failed to get token")
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "google: failed to get token").WithAdditional(err.Error())
}
rawIDToken, ok := token.Extra("id_token").(string)
@@ -108,8 +90,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().Google.ClientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
a.settings.Logger().ErrorContext(ctx, "google: failed to verify token", "error", err)
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: failed to verify token")
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: failed to verify token").WithAdditional(err.Error())
}
var claims struct {
@@ -120,20 +101,11 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
}
if err := idToken.Claims(&claims); err != nil {
a.settings.Logger().ErrorContext(ctx, "google: missing or invalid claims", "error", err)
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: missing or invalid claims").WithAdditional(err.Error())
}
if claims.HostedDomain != authDomain.StorableAuthDomain().Name {
a.settings.Logger().ErrorContext(ctx, "google: unexpected hd claim", "expected", authDomain.StorableAuthDomain().Name, "actual", claims.HostedDomain)
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: unexpected hd claim")
}
if !authDomain.AuthDomainConfig().Google.InsecureSkipEmailVerified {
if !claims.EmailVerified {
a.settings.Logger().ErrorContext(ctx, "google: email is not verified", "email", claims.Email)
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: email is not verified")
}
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: unexpected hd claim %s", claims.HostedDomain)
}
email, err := valuer.NewEmail(claims.Email)
@@ -141,24 +113,8 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "google: failed to parse email").WithAdditional(err.Error())
}
var groups []string
if authDomain.AuthDomainConfig().Google.FetchGroups {
groups, err = a.fetchGoogleWorkspaceGroups(ctx, claims.Email, authDomain.AuthDomainConfig().Google)
if err != nil {
a.settings.Logger().ErrorContext(ctx, "google: could not fetch groups", "error", err)
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "google: could not fetch groups").WithAdditional(err.Error())
}
return authtypes.NewCallbackIdentity(claims.Name, email, authDomain.StorableAuthDomain().OrgID, state), nil
allowedGroups := authDomain.AuthDomainConfig().Google.AllowedGroups
if len(allowedGroups) > 0 {
groups = filterGroups(groups, allowedGroups)
if len(groups) == 0 {
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "google: user %q is not in any allowed groups", claims.Email).WithAdditional(allowedGroups...)
}
}
}
return authtypes.NewCallbackIdentity(claims.Name, email, authDomain.StorableAuthDomain().OrgID, state, groups, ""), nil
}
func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
@@ -180,90 +136,3 @@ func (a *AuthN) oauth2Config(siteURL *url.URL, authDomain *authtypes.AuthDomain,
}).String(),
}
}
func (a *AuthN) fetchGoogleWorkspaceGroups(ctx context.Context, userEmail string, config *authtypes.GoogleConfig) ([]string, error) {
adminEmail := config.GetAdminEmailForDomain(userEmail)
if adminEmail == "" {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no admin email configured for domain of %s", userEmail)
}
jwtConfig, err := google.JWTConfigFromJSON([]byte(config.ServiceAccountJSON), admin.AdminDirectoryGroupReadonlyScope)
if err != nil {
a.settings.Logger().ErrorContext(ctx, "google: invalid service account credentials", "error", err)
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid service account credentials")
}
jwtConfig.Subject = adminEmail
customCtx := context.WithValue(ctx, oauth2.HTTPClient, a.httpClient.Client())
adminService, err := admin.NewService(ctx, option.WithHTTPClient(jwtConfig.Client(customCtx)))
if err != nil {
a.settings.Logger().ErrorContext(ctx, "google: unable to create directory service", "error", err)
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "unable to create directory service")
}
checkedGroups := make(map[string]struct{})
return a.getGroups(ctx, adminService, userEmail, config.FetchTransitiveGroupMembership, checkedGroups)
}
// Recursive method
func (a *AuthN) getGroups(ctx context.Context, adminService *admin.Service, userEmail string, fetchTransitive bool, checkedGroups map[string]struct{}) ([]string, error) {
var userGroups []string
var pageToken string
for {
call := adminService.Groups.List().UserKey(userEmail)
if pageToken != "" {
call = call.PageToken(pageToken)
}
groupList, err := call.Context(ctx).Do()
if err != nil {
a.settings.Logger().ErrorContext(ctx, "google: unable to list groups", "error", err)
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "unable to list groups")
}
for _, group := range groupList.Groups {
if _, exists := checkedGroups[group.Email]; exists {
continue
}
checkedGroups[group.Email] = struct{}{}
userGroups = append(userGroups, group.Email)
if fetchTransitive {
transitiveGroups, err := a.getGroups(ctx, adminService, group.Email, fetchTransitive, checkedGroups)
if err != nil {
a.settings.Logger().ErrorContext(ctx, "google: unable to list transitive groups", "error", err)
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "unable to list transitive groups")
}
userGroups = append(userGroups, transitiveGroups...)
}
}
pageToken = groupList.NextPageToken
if pageToken == "" {
break
}
}
return userGroups, nil
}
func filterGroups(userGroups, allowedGroups []string) []string {
allowed := make(map[string]struct{}, len(allowedGroups))
for _, g := range allowedGroups {
allowed[g] = struct{}{} // just to make o(1) searches
}
var filtered []string
for _, g := range userGroups {
if _, ok := allowed[g]; ok {
filtered = append(filtered, g)
}
}
return filtered
}

View File

@@ -112,7 +112,7 @@ func (b *base) WithUrl(u string) *base {
}
}
// WithAdditional adds additional messages to the base error and returns a new base error.
// WithUrl adds additional messages to the base error and returns a new base error.
func (b *base) WithAdditional(a ...string) *base {
return &base{
t: b.t,

View File

@@ -4,9 +4,14 @@ import (
"net/url"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
)
var (
ErrCodeInvalidGatewayConfig = errors.MustNewCode("invalid_gateway_config")
)
type Config struct {
URL *url.URL `mapstructure:"url"`
}

View File

@@ -1,60 +0,0 @@
package gateway
import (
"context"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/gatewaytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
ErrCodeGatewayUnsupported = errors.MustNewCode("gateway_unsupported")
ErrCodeInvalidGatewayConfig = errors.MustNewCode("invalid_gateway_config")
)
type Gateway interface {
// Get Ingestions Keys (this is supposed to be for the current user but for now in gateway code this is ignoring the consumer user)
GetIngestionKeys(ctx context.Context, orgID valuer.UUID, page, perPage int) (*gatewaytypes.GettableIngestionKeys, error)
// Search Ingestion Keys by Name (this is supposed to be for the current user but for now in gateway code this is ignoring the consumer user)
SearchIngestionKeysByName(ctx context.Context, orgID valuer.UUID, name string, page, perPage int) (*gatewaytypes.GettableIngestionKeys, error)
// Create Ingestion Key
CreateIngestionKey(ctx context.Context, orgID valuer.UUID, name string, tags []string, expiresAt time.Time) (*gatewaytypes.GettableCreatedIngestionKey, error)
// Update Ingestion Key
UpdateIngestionKey(ctx context.Context, orgID valuer.UUID, keyID string, name string, tags []string, expiresAt time.Time) error
// Delete Ingestion Key
DeleteIngestionKey(ctx context.Context, orgID valuer.UUID, keyID string) error
// Create Ingestion Key Limit
CreateIngestionKeyLimit(ctx context.Context, orgID valuer.UUID, keyID string, signal string, limitConfig gatewaytypes.LimitConfig, tags []string) (*gatewaytypes.GettableCreatedIngestionKeyLimit, error)
// Update Ingestion Key Limit
UpdateIngestionKeyLimit(ctx context.Context, orgID valuer.UUID, limitID string, limitConfig gatewaytypes.LimitConfig, tags []string) error
// Delete Ingestion Key Limit
DeleteIngestionKeyLimit(ctx context.Context, orgID valuer.UUID, limitID string) error
}
type Handler interface {
GetIngestionKeys(http.ResponseWriter, *http.Request)
SearchIngestionKeys(http.ResponseWriter, *http.Request)
CreateIngestionKey(http.ResponseWriter, *http.Request)
UpdateIngestionKey(http.ResponseWriter, *http.Request)
DeleteIngestionKey(http.ResponseWriter, *http.Request)
CreateIngestionKeyLimit(http.ResponseWriter, *http.Request)
UpdateIngestionKeyLimit(http.ResponseWriter, *http.Request)
DeleteIngestionKeyLimit(http.ResponseWriter, *http.Request)
}

View File

@@ -1,287 +0,0 @@
package gateway
import (
"encoding/json"
"net/http"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/gatewaytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
const (
DefaultPage = 1
DefaultPageSize = 10
)
type handler struct {
gateway Gateway
}
func NewHandler(gateway Gateway) Handler {
return &handler{
gateway: gateway,
}
}
func (handler *handler) GetIngestionKeys(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
pageString := r.URL.Query().Get("page")
perPageString := r.URL.Query().Get("per_page")
page, err := parseIntWithDefaultValue(pageString, DefaultPage)
if err != nil {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "page must be a valid integer"))
return
}
perPage, err := parseIntWithDefaultValue(perPageString, DefaultPageSize)
if err != nil {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "per_page must be a valid integer"))
return
}
response, err := handler.gateway.GetIngestionKeys(ctx, orgID, page, perPage)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, response)
}
func (handler *handler) SearchIngestionKeys(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
pageString := r.URL.Query().Get("page")
perPageString := r.URL.Query().Get("per_page")
name := r.URL.Query().Get("name")
page, err := parseIntWithDefaultValue(pageString, DefaultPage)
if err != nil {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "page must be a valid integer"))
return
}
perPage, err := parseIntWithDefaultValue(perPageString, DefaultPageSize)
if err != nil {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "per_page must be a valid integer"))
return
}
response, err := handler.gateway.SearchIngestionKeysByName(ctx, orgID, name, page, perPage)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, response)
}
func (handler *handler) CreateIngestionKey(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
var req gatewaytypes.PostableIngestionKey
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body"))
return
}
response, err := handler.gateway.CreateIngestionKey(ctx, orgID, req.Name, req.Tags, req.ExpiresAt)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, response)
}
func (handler *handler) UpdateIngestionKey(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
keyID := mux.Vars(r)["keyId"]
if keyID == "" {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "keyId is required"))
return
}
var req gatewaytypes.PostableIngestionKey
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body"))
return
}
err = handler.gateway.UpdateIngestionKey(ctx, orgID, keyID, req.Name, req.Tags, req.ExpiresAt)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) DeleteIngestionKey(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
keyID := mux.Vars(r)["keyId"]
if keyID == "" {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "keyId is required"))
return
}
err = handler.gateway.DeleteIngestionKey(ctx, orgID, keyID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) CreateIngestionKeyLimit(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
keyID := mux.Vars(r)["keyId"]
if keyID == "" {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "keyId is required"))
return
}
var req gatewaytypes.PostableIngestionKeyLimit
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body"))
return
}
response, err := handler.gateway.CreateIngestionKeyLimit(ctx, orgID, keyID, req.Signal, req.Config, req.Tags)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, response)
}
func (handler *handler) UpdateIngestionKeyLimit(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
limitID := mux.Vars(r)["limitId"]
if limitID == "" {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "limitId is required"))
return
}
var req gatewaytypes.UpdatableIngestionKeyLimit
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body"))
return
}
err = handler.gateway.UpdateIngestionKeyLimit(ctx, orgID, limitID, req.Config, req.Tags)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) DeleteIngestionKeyLimit(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
limitID := mux.Vars(r)["limitId"]
if limitID == "" {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "limitId is required"))
return
}
err = handler.gateway.DeleteIngestionKeyLimit(ctx, orgID, limitID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func parseIntWithDefaultValue(value string, defaultValue int) (int, error) {
if value == "" {
return defaultValue, nil
}
result, err := strconv.Atoi(value)
if err != nil {
return 0, err
}
return result, nil
}

View File

@@ -1,56 +0,0 @@
package noopgateway
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/types/gatewaytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type provider struct{}
func NewProviderFactory() factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return factory.NewProviderFactory(factory.MustNewName("noop"), func(ctx context.Context, ps factory.ProviderSettings, c gateway.Config) (gateway.Gateway, error) {
return New(ctx, ps, c)
})
}
func New(_ context.Context, _ factory.ProviderSettings, _ gateway.Config) (gateway.Gateway, error) {
return &provider{}, nil
}
func (p *provider) GetIngestionKeys(_ context.Context, _ valuer.UUID, _, _ int) (*gatewaytypes.GettableIngestionKeys, error) {
return nil, errors.New(errors.TypeUnsupported, gateway.ErrCodeGatewayUnsupported, "unsupported call")
}
func (p *provider) SearchIngestionKeysByName(_ context.Context, _ valuer.UUID, _ string, _, _ int) (*gatewaytypes.GettableIngestionKeys, error) {
return nil, errors.New(errors.TypeUnsupported, gateway.ErrCodeGatewayUnsupported, "unsupported call")
}
func (p *provider) CreateIngestionKey(_ context.Context, _ valuer.UUID, _ string, _ []string, _ time.Time) (*gatewaytypes.GettableCreatedIngestionKey, error) {
return nil, errors.New(errors.TypeUnsupported, gateway.ErrCodeGatewayUnsupported, "unsupported call")
}
func (p *provider) UpdateIngestionKey(_ context.Context, _ valuer.UUID, _ string, _ string, _ []string, _ time.Time) error {
return errors.New(errors.TypeUnsupported, gateway.ErrCodeGatewayUnsupported, "unsupported call")
}
func (p *provider) DeleteIngestionKey(_ context.Context, _ valuer.UUID, _ string) error {
return errors.New(errors.TypeUnsupported, gateway.ErrCodeGatewayUnsupported, "unsupported call")
}
func (p *provider) CreateIngestionKeyLimit(_ context.Context, _ valuer.UUID, _ string, _ string, _ gatewaytypes.LimitConfig, _ []string) (*gatewaytypes.GettableCreatedIngestionKeyLimit, error) {
return nil, errors.New(errors.TypeUnsupported, gateway.ErrCodeGatewayUnsupported, "unsupported call")
}
func (p *provider) UpdateIngestionKeyLimit(_ context.Context, _ valuer.UUID, _ string, _ gatewaytypes.LimitConfig, _ []string) error {
return errors.New(errors.TypeUnsupported, gateway.ErrCodeGatewayUnsupported, "unsupported call")
}
func (p *provider) DeleteIngestionKeyLimit(_ context.Context, _ valuer.UUID, _ string) error {
return errors.New(errors.TypeUnsupported, gateway.ErrCodeGatewayUnsupported, "unsupported call")
}

View File

@@ -16,8 +16,8 @@ func NewHandler(global global.Global) global.Handler {
return &handler{global: global}
}
func (handler *handler) GetConfig(rw http.ResponseWriter, r *http.Request) {
cfg := handler.global.GetConfig()
func (handker *handler) GetConfig(rw http.ResponseWriter, r *http.Request) {
cfg := handker.global.GetConfig()
render.Success(rw, http.StatusOK, types.NewGettableGlobalConfig(cfg.ExternalURL, cfg.IngestionURL))
}

View File

@@ -9,12 +9,10 @@ import (
var (
ErrCodeInvalidRequestBody = errors.MustNewCode("invalid_request_body")
ErrCodeInvalidRequestField = errors.MustNewCode("invalid_request_field")
ErrCodeInvalidRequestQuery = errors.MustNewCode("invalid_request_query")
)
var (
JSON BindingBody = &jsonBinding{}
Query BindingQuery = &queryBinding{}
JSON Binding = &jsonBinding{}
)
type bindBodyOptions struct {
@@ -36,10 +34,6 @@ func WithUseNumber(useNumber bool) BindBodyOption {
}
}
type BindingBody interface {
type Binding interface {
BindBody(body io.Reader, obj any, opts ...BindBodyOption) error
}
type BindingQuery interface {
BindQuery(query map[string][]string, obj any) error
}

View File

@@ -12,7 +12,7 @@ const (
ErrMessageInvalidField string = "request body contains invalid field value"
)
var _ BindingBody = (*jsonBinding)(nil)
var _ Binding = (*jsonBinding)(nil)
type jsonBinding struct{}

View File

@@ -1,23 +0,0 @@
package binding
import (
"github.com/SigNoz/signoz/pkg/errors"
ginbinding "github.com/gin-gonic/gin/binding"
)
const (
ErrMessageInvalidQuery string = "request query contains invalid fields, please verify the format and try again."
)
var _ BindingQuery = (*queryBinding)(nil)
type queryBinding struct{}
func (b *queryBinding) BindQuery(query map[string][]string, obj any) error {
err := ginbinding.MapFormWithTag(obj, query, "query")
if err != nil {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidRequestQuery, ErrMessageInvalidQuery).WithAdditional(err.Error())
}
return nil
}

View File

@@ -1,33 +0,0 @@
package binding
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestQueryBinding_BindQuery(t *testing.T) {
one := 1
zero := 0
testCases := []struct {
name string
query map[string][]string
obj any
expected any
}{
{name: "SingleIntField_NonEmptyValue", query: map[string][]string{"a": {"1"}}, obj: &struct{A int `query:"a"`}{}, expected: &struct{ A int }{A: 1}},
{name: "SingleIntField_EmptyValue", query: map[string][]string{"a": {""}}, obj: &struct{A int `query:"a"`}{}, expected: &struct{ A int }{A: 0}},
{name: "SingleIntField_MissingField", query: map[string][]string{}, obj: &struct{A int `query:"a"`}{}, expected: &struct{ A int }{A: 0}},
{name: "SinglePointerIntField_NonEmptyValue", query: map[string][]string{"a": {"1"}}, obj: &struct{A *int `query:"a"`}{}, expected: &struct{ A *int }{A: &one}},
{name: "SinglePointerIntField_EmptyValue", query: map[string][]string{"a": {""}}, obj: &struct{A *int `query:"a"`}{}, expected: &struct{ A *int }{A: &zero}},
{name: "SinglePointerIntField_MissingField", query: map[string][]string{}, obj: &struct{A *int `query:"a"`}{}, expected: &struct{ A *int }{A: nil}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
err := Query.BindQuery(testCase.query, testCase.obj)
assert.NoError(t, err)
assert.EqualValues(t, testCase.expected, testCase.obj)
})
}
}

View File

@@ -31,13 +31,7 @@ func (plugin *reqResLog) OnRequestStart(request *http.Request) {
string(semconv.ServerAddressKey), host,
string(semconv.ServerPortKey), port,
string(semconv.HTTPRequestSizeKey), request.ContentLength,
}
// only include all the headers if we are at debug level
if plugin.logger.Handler().Enabled(request.Context(), slog.LevelDebug) {
fields = append(fields, "http.request.headers", request.Header)
} else {
fields = append(fields, "http.request.headers", redactSensitiveHeaders(request.Header))
"http.request.headers", request.Header,
}
plugin.logger.InfoContext(request.Context(), "::SENT-REQUEST::", fields...)
@@ -81,24 +75,3 @@ func (plugin *reqResLog) OnError(request *http.Request, err error) {
plugin.logger.ErrorContext(request.Context(), "::UNABLE-TO-SEND-REQUEST::", fields...)
}
func redactSensitiveHeaders(headers http.Header) http.Header {
// maintained list of headers to redact
sensitiveHeaders := map[string]bool{
"Authorization": true,
"Cookie": true,
"X-Signoz-Cloud-Api-Key": true,
}
safeHeaders := make(http.Header)
for header, value := range headers {
if sensitiveHeaders[header] {
safeHeaders[header] = []string{"REDACTED"}
} else {
safeHeaders[header] = value
}
}
return safeHeaders
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -32,9 +31,7 @@ type Module interface {
Delete(context.Context, valuer.UUID, valuer.UUID) error
// Get the IDP info of the domain provided.
GetAuthNProviderInfo(context.Context, *authtypes.AuthDomain) *authtypes.AuthNProviderInfo
statsreporter.StatsCollector
GetAuthNProviderInfo(context.Context, *authtypes.AuthDomain) (*authtypes.AuthNProviderInfo)
}
type Handler interface {

View File

@@ -52,25 +52,3 @@ func (module *module) ListByOrgID(ctx context.Context, orgID valuer.UUID) ([]*au
func (module *module) Update(ctx context.Context, domain *authtypes.AuthDomain) error {
return module.store.Update(ctx, domain)
}
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
domains, err := module.store.ListByOrgID(ctx, orgID)
if err != nil {
return nil, err
}
stats := make(map[string]any)
for _, domain := range domains {
key := "authdomain." + domain.AuthDomainConfig().AuthNProvider.StringValue() + ".count"
if value, ok := stats[key]; ok {
stats[key] = value.(int64) + 1
} else {
stats[key] = int64(1)
}
}
stats["authdomain.count"] = len(domains)
return stats, nil
}

View File

@@ -123,7 +123,7 @@ func (module *module) DeprecatedCreateSessionByEmailPassword(ctx context.Context
}
if !factorPassword.Equals(password) {
return nil, errors.New(errors.TypeUnauthenticated, types.ErrCodeIncorrectPassword, "invalid email or password")
return nil, errors.New(errors.TypeUnauthenticated, types.ErrCodeIncorrectPassword, "invalid email orpassword")
}
identity := authtypes.NewIdentity(users[0].ID, users[0].OrgID, users[0].Email, users[0].Role)
@@ -157,15 +157,7 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
return "", err
}
authDomain, err := module.authDomain.GetByOrgIDAndID(ctx, callbackIdentity.OrgID, callbackIdentity.State.DomainID)
if err != nil {
return "", err
}
roleMapping := authDomain.AuthDomainConfig().RoleMapping
role := roleMapping.NewRoleFromCallbackIdentity(callbackIdentity)
user, err := types.NewUser(callbackIdentity.Name, callbackIdentity.Email, role, callbackIdentity.OrgID)
user, err := types.NewUser(callbackIdentity.Name, callbackIdentity.Email, types.RoleViewer, callbackIdentity.OrgID)
if err != nil {
return "", err
}

View File

@@ -318,14 +318,13 @@ func (q *querier) applyFormulas(ctx context.Context, results map[string]*qbtypes
}
// Check if we're dealing with time series or scalar data
switch req.RequestType {
case qbtypes.RequestTypeTimeSeries:
if req.RequestType == qbtypes.RequestTypeTimeSeries {
result := q.processTimeSeriesFormula(ctx, results, formula, req)
if result != nil {
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
results[name] = result
}
case qbtypes.RequestTypeScalar:
} else if req.RequestType == qbtypes.RequestTypeScalar {
result := q.processScalarFormula(ctx, results, formula, req)
if result != nil {
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
@@ -582,14 +581,11 @@ func (q *querier) filterDisabledQueries(results map[string]*qbtypes.Result, req
}
// formatScalarResultsAsTable formats scalar results as a unified table for UI display
func (q *querier) formatScalarResultsAsTable(results map[string]*qbtypes.Result, req *qbtypes.QueryRangeRequest) map[string]any {
func (q *querier) formatScalarResultsAsTable(results map[string]*qbtypes.Result, _ *qbtypes.QueryRangeRequest) map[string]any {
if len(results) == 0 {
return map[string]any{"table": &qbtypes.ScalarData{}}
}
// apply default sorting if no order specified
applyDefaultSort := !req.HasOrderSpecified()
// Convert all results to ScalarData first
scalarResults := make(map[string]*qbtypes.ScalarData)
for name, result := range results {
@@ -604,13 +600,13 @@ func (q *querier) formatScalarResultsAsTable(results map[string]*qbtypes.Result,
if len(scalarResults) == 1 {
for _, sd := range scalarResults {
if hasMultipleQueries(sd) {
return map[string]any{"table": deduplicateRows(sd, applyDefaultSort)}
return map[string]any{"table": deduplicateRows(sd)}
}
}
}
// Otherwise merge all results
merged := mergeScalarData(scalarResults, applyDefaultSort)
merged := mergeScalarData(scalarResults)
return map[string]any{"table": merged}
}
@@ -691,7 +687,7 @@ func hasMultipleQueries(sd *qbtypes.ScalarData) bool {
}
// deduplicateRows removes duplicate rows based on group columns
func deduplicateRows(sd *qbtypes.ScalarData, applyDefaultSort bool) *qbtypes.ScalarData {
func deduplicateRows(sd *qbtypes.ScalarData) *qbtypes.ScalarData {
// Find group column indices
groupIndices := []int{}
for i, col := range sd.Columns {
@@ -700,9 +696,8 @@ func deduplicateRows(sd *qbtypes.ScalarData, applyDefaultSort bool) *qbtypes.Sca
}
}
// Build unique rows map, preserve order
// Build unique rows map
uniqueRows := make(map[string][]any)
var keyOrder []string
for _, row := range sd.Data {
key := buildRowKey(row, groupIndices)
if existing, found := uniqueRows[key]; found {
@@ -716,20 +711,17 @@ func deduplicateRows(sd *qbtypes.ScalarData, applyDefaultSort bool) *qbtypes.Sca
rowCopy := make([]any, len(row))
copy(rowCopy, row)
uniqueRows[key] = rowCopy
keyOrder = append(keyOrder, key)
}
}
// Convert back to slice, preserve the original order
// Convert back to slice
data := make([][]any, 0, len(uniqueRows))
for _, key := range keyOrder {
data = append(data, uniqueRows[key])
for _, row := range uniqueRows {
data = append(data, row)
}
// sort by first aggregation (descending) if no order was specified
if applyDefaultSort {
sortByFirstAggregation(data, sd.Columns)
}
// Sort by first aggregation column
sortByFirstAggregation(data, sd.Columns)
return &qbtypes.ScalarData{
Columns: sd.Columns,
@@ -738,7 +730,7 @@ func deduplicateRows(sd *qbtypes.ScalarData, applyDefaultSort bool) *qbtypes.Sca
}
// mergeScalarData merges multiple scalar data results
func mergeScalarData(results map[string]*qbtypes.ScalarData, applyDefaultSort bool) *qbtypes.ScalarData {
func mergeScalarData(results map[string]*qbtypes.ScalarData) *qbtypes.ScalarData {
// Collect unique group columns
groupCols := []string{}
groupColMap := make(map[string]*qbtypes.ColumnDescriptor)
@@ -778,12 +770,10 @@ func mergeScalarData(results map[string]*qbtypes.ScalarData, applyDefaultSort bo
}
}
// Merge rows, preserve order
// Merge rows
rowMap := make(map[string][]any)
var keyOrder []string
for _, queryName := range queryNames {
sd := results[queryName]
for queryName, sd := range results {
// Create index mappings
groupMap := make(map[string]int)
for i, col := range sd.Columns {
@@ -812,7 +802,6 @@ func mergeScalarData(results map[string]*qbtypes.ScalarData, applyDefaultSort bo
newRow[i] = "n/a"
}
rowMap[key] = newRow
keyOrder = append(keyOrder, key)
}
// Set aggregation values for this query
@@ -836,16 +825,14 @@ func mergeScalarData(results map[string]*qbtypes.ScalarData, applyDefaultSort bo
}
}
// Convert to slice, preserving insertion order
// Convert to slice
data := make([][]any, 0, len(rowMap))
for _, key := range keyOrder {
data = append(data, rowMap[key])
for _, row := range rowMap {
data = append(data, row)
}
// sort by first aggregation (descending) if no order was specified
if applyDefaultSort {
sortByFirstAggregation(data, columns)
}
// Sort by first aggregation column
sortByFirstAggregation(data, columns)
return &qbtypes.ScalarData{
Columns: columns,
@@ -901,7 +888,7 @@ func sortByFirstAggregation(data [][]any, columns []*qbtypes.ColumnDescriptor) {
// compareValues compares two values for sorting (handles n/a and numeric types)
func compareValues(a, b any) int {
// n/a values gets pushed to the end
// Handle n/a values
if a == "n/a" && b == "n/a" {
return 0
}

View File

@@ -5,7 +5,6 @@ import (
"context"
"fmt"
"log/slog"
"regexp"
"sort"
"strings"
"text/template"
@@ -20,50 +19,6 @@ import (
"github.com/prometheus/prometheus/promql/parser"
)
// unquotedDottedNamePattern matches unquoted identifiers containing dots
// that appear in metric or label name positions. This helps detect queries
// using the old syntax that needs migration to UTF-8 quoted syntax.
// Examples it matches: k8s.pod.name, deployment.environment, http.status_code
var unquotedDottedNamePattern = regexp.MustCompile(`(?:^|[{,(\s])([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_]+)+)(?:[}\s,=!~)\[]|$)`)
// quotedMetricOutsideBracesPattern matches the incorrect syntax where a quoted
// metric name appears outside of braces followed by a selector block.
// Example: "kube_pod_status_ready_time"{"condition"="true"}
// This is a common mistake when migrating to UTF-8 syntax.
var quotedMetricOutsideBracesPattern = regexp.MustCompile(`"([^"]+)"\s*\{`)
// enhancePromQLError adds helpful context to PromQL parse errors,
// particularly for UTF-8 syntax migration issues where metric and label
// names containing dots need to be quoted.
func enhancePromQLError(query string, parseErr error) error {
errMsg := parseErr.Error()
if matches := quotedMetricOutsideBracesPattern.FindStringSubmatch(query); len(matches) > 1 {
metricName := matches[1]
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid promql query: %s. Hint: The metric name should be inside the braces. Use {\"__name__\"=\"%s\", ...} or {\"%s\", ...} instead of \"%s\"{...}",
errMsg,
metricName,
metricName,
metricName,
)
}
if matches := unquotedDottedNamePattern.FindStringSubmatch(query); len(matches) > 1 {
dottedName := matches[1]
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid promql query: %s. Hint: Metric and label names containing dots require quoted notation in the new UTF-8 syntax, e.g., use \"%s\" instead of %s",
errMsg,
dottedName,
dottedName,
)
}
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query: %s", errMsg)
}
type promqlQuery struct {
logger *slog.Logger
promEngine prometheus.Prometheus
@@ -126,7 +81,7 @@ func (q *promqlQuery) removeAllVarMatchers(query string, vars map[string]qbv5.Va
expr, err := parser.ParseExpr(query)
if err != nil {
return "", enhancePromQLError(query, err)
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query %q", query)
}
// Create visitor and walk the AST
@@ -206,7 +161,7 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
)
if err != nil {
return nil, enhancePromQLError(query, err)
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query %q", query)
}
res := qry.Exec(ctx)

View File

@@ -2,10 +2,8 @@ package querier
import (
"log/slog"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/errors"
qbv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/assert"
)
@@ -174,268 +172,3 @@ func TestRemoveAllVarMatchers(t *testing.T) {
})
}
}
func TestEnhancePromQLError(t *testing.T) {
parseErr := errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "unexpected character: '.' at position 12")
t.Run("dotted name patterns", func(t *testing.T) {
tests := []struct {
name string
query string
wantDottedNameHint bool
wantDottedNameExample string
}{
{
name: "query with unquoted dotted metric name",
query: `sum(rate(k8s.container.restarts[5m]))`,
wantDottedNameHint: true,
wantDottedNameExample: "k8s.container.restarts",
},
{
name: "query with unquoted dotted label in group by",
query: `sum by (k8s.pod.name) (rate(requests_total[5m]))`,
wantDottedNameHint: true,
wantDottedNameExample: "k8s.pod.name",
},
{
name: "query with unquoted dotted label in filter",
query: `requests_total{k8s.namespace.name="default"}`,
wantDottedNameHint: true,
wantDottedNameExample: "k8s.namespace.name",
},
{
name: "query with multiple unquoted dotted names",
query: `sum by (k8s.pod.name, deployment.environment) (increase(k8s.container.restarts[15m]))`,
wantDottedNameHint: true,
wantDottedNameExample: "k8s.pod.name", // should match first one
},
{
name: "query without dotted names - no hint",
query: `sum(rate(http_requests_total[5m]))`,
wantDottedNameHint: false,
},
{
name: "query with properly quoted dotted names - no hint",
query: `sum(rate({"k8s.container.restarts"}[5m]))`,
wantDottedNameHint: false,
},
{
name: "query with dotted name inside regex string - no hint",
query: `requests_total{pod=~"k8s.pod.name.*"}`,
wantDottedNameHint: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := enhancePromQLError(tt.query, parseErr)
errMsg := err.Error()
assert.True(t, strings.Contains(errMsg, parseErr.Error()),
"error should contain original parse error message")
if tt.wantDottedNameHint {
assert.True(t, strings.Contains(errMsg, "Hint:"),
"error should contain hint for dotted name query")
assert.True(t, strings.Contains(errMsg, "UTF-8 syntax"),
"error should mention UTF-8 syntax")
assert.True(t, strings.Contains(errMsg, tt.wantDottedNameExample),
"error should contain the dotted name example: %s", tt.wantDottedNameExample)
} else {
assert.False(t, strings.Contains(errMsg, "Hint:"),
"error should not contain hint for non-dotted-name query")
}
})
}
})
t.Run("quoted metric outside braces patterns", func(t *testing.T) {
tests := []struct {
name string
query string
wantHint bool
wantMetricInHint string
}{
{
name: "quoted metric name followed by selector",
query: `"kube_pod_status_ready_time"{"condition"="true"}`,
wantHint: true,
wantMetricInHint: "kube_pod_status_ready_time",
},
{
name: "quoted metric with space before brace",
query: `"kube_pod_labels" {"label"!=""}`,
wantHint: true,
wantMetricInHint: "kube_pod_labels",
},
{
name: "complex query with quoted metric outside braces",
query: `min by (namespace) ("kube_pod_status_ready_time"{"condition"="true"})`,
wantHint: true,
wantMetricInHint: "kube_pod_status_ready_time",
},
{
name: "label_replace with quoted metric outside braces",
query: `label_replace("kube_pod_labels"{"label_cnpg_io_cluster"!=""}, "cluster","$1","label","(.+)")`,
wantHint: true,
wantMetricInHint: "kube_pod_labels",
},
{
name: "correctly formatted query - no hint",
query: `{"kube_pod_status_ready_time", condition="true"}`,
wantHint: false,
},
{
name: "old syntax without quotes - no hint for this pattern",
query: `kube_pod_status_ready_time{condition="true"}`,
wantHint: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := enhancePromQLError(tt.query, parseErr)
errMsg := err.Error()
assert.True(t, strings.Contains(errMsg, parseErr.Error()),
"error should contain original parse error message")
if tt.wantHint {
assert.True(t, strings.Contains(errMsg, "Hint:"),
"error should contain hint")
assert.True(t, strings.Contains(errMsg, "inside the braces"),
"error should mention putting metric inside braces")
assert.True(t, strings.Contains(errMsg, tt.wantMetricInHint),
"error should contain the metric name: %s", tt.wantMetricInHint)
}
})
}
})
}
func TestUnquotedDottedNamePattern(t *testing.T) {
tests := []struct {
name string
input string
expected string // empty string means no match expected
}{
{
name: "metric name at start",
input: "k8s.pod.name",
expected: "k8s.pod.name",
},
{
name: "label in group by clause",
input: "sum by (k8s.pod.name) (rate(x[5m]))",
expected: "k8s.pod.name",
},
{
name: "label in filter",
input: "metric{k8s.namespace.name=\"default\"}",
expected: "k8s.namespace.name",
},
{
name: "metric with underscore and dots",
input: "http_server.request.duration",
expected: "http_server.request.duration",
},
{
name: "quoted metric name - no match",
input: `{"k8s.pod.name"}`,
expected: "",
},
{
name: "inside regex string - no match",
input: `{pod=~"k8s.pod.name.*"}`,
expected: "",
},
{
name: "simple metric without dots - no match",
input: "http_requests_total",
expected: "",
},
{
name: "single dot only - no match",
input: "a.b",
expected: "a.b",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matches := unquotedDottedNamePattern.FindStringSubmatch(tt.input)
if tt.expected == "" {
assert.True(t, len(matches) < 2 || matches[1] == "",
"expected no match for input %q but got %v", tt.input, matches)
} else {
assert.True(t, len(matches) >= 2,
"expected match for input %q but got none", tt.input)
if len(matches) >= 2 {
assert.Equal(t, tt.expected, matches[1],
"unexpected match for input %q", tt.input)
}
}
})
}
}
func TestQuotedMetricOutsideBracesPattern(t *testing.T) {
tests := []struct {
name string
input string
expected string // empty string means no match expected
}{
{
name: "quoted metric followed by braces",
input: `"kube_pod_status_ready_time"{"condition"="true"}`,
expected: "kube_pod_status_ready_time",
},
{
name: "quoted metric with space before brace",
input: `"kube_pod_labels" {"label"!=""}`,
expected: "kube_pod_labels",
},
{
name: "quoted metric in label_replace",
input: `label_replace("kube_pod_labels"{"x"="y"}, "a","b","c","d")`,
expected: "kube_pod_labels",
},
{
name: "quoted metric with dots",
input: `"k8s.container.restarts"{"pod"="test"}`,
expected: "k8s.container.restarts",
},
{
name: "correct UTF-8 syntax - no match",
input: `{"kube_pod_status_ready_time", condition="true"}`,
expected: "",
},
{
name: "old syntax without quotes - no match",
input: `kube_pod_status_ready_time{condition="true"}`,
expected: "",
},
{
name: "quoted string in label value - no match",
input: `metric{label="value"}{other="x"}`,
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matches := quotedMetricOutsideBracesPattern.FindStringSubmatch(tt.input)
if tt.expected == "" {
assert.True(t, len(matches) < 2 || matches[1] == "",
"expected no match for input %q but got %v", tt.input, matches)
} else {
assert.True(t, len(matches) >= 2,
"expected match for input %q but got none", tt.input)
if len(matches) >= 2 {
assert.Equal(t, tt.expected, matches[1],
"unexpected match for input %q", tt.input)
}
}
})
}
}

View File

@@ -887,7 +887,7 @@ func ParseQueryRangeParams(r *http.Request) (*v3.QueryRangeParamsV3, *model.ApiE
keys := make([]string, 0, len(queryRangeParams.Variables))
querytemplate.AssignReservedVarsV3(queryRangeParams)
querytemplate.AssignReservedVars(queryRangeParams.Variables, queryRangeParams.Start, queryRangeParams.End)
for k := range queryRangeParams.Variables {
keys = append(keys, k)
@@ -927,7 +927,7 @@ func ParseQueryRangeParams(r *http.Request) (*v3.QueryRangeParamsV3, *model.ApiE
continue
}
querytemplate.AssignReservedVarsV3(queryRangeParams)
querytemplate.AssignReservedVars(queryRangeParams.Variables, queryRangeParams.Start, queryRangeParams.End)
keys := make([]string, 0, len(queryRangeParams.Variables))

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,17 @@
package converter
import "github.com/SigNoz/signoz/pkg/errors"
// Unit represents a unit of measurement
type Unit string
func (u Unit) Validate() error {
if !IsValidUnit(u) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid unit: %s", u)
}
return nil
}
// Value represents a value with a unit of measurement
type Value struct {
F float64
@@ -60,6 +69,27 @@ func FromUnit(u Unit) Converter {
}
}
// IsValidUnit returns true if the given unit is valid
func IsValidUnit(u Unit) bool {
switch u {
// Duration unit
case "ns", "us", "µs", "ms", "s", "m", "h", "d", "min",
// Data unit
"bytes", "decbytes", "bits", "decbits", "kbytes", "decKbytes", "deckbytes", "mbytes", "decMbytes", "decmbytes", "gbytes", "decGbytes", "decgbytes", "tbytes", "decTbytes", "dectbytes", "pbytes", "decPbytes", "decpbytes", "By", "kBy", "MBy", "GBy", "TBy", "PBy",
// Data rate unit
"binBps", "Bps", "binbps", "bps", "KiBs", "Kibits", "KBs", "Kbits", "MiBs", "Mibits", "MBs", "Mbits", "GiBs", "Gibits", "GBs", "Gbits", "TiBs", "Tibits", "TBs", "Tbits", "PiBs", "Pibits", "PBs", "Pbits", "By/s", "kBy/s", "MBy/s", "GBy/s", "TBy/s", "PBy/s", "bit/s", "kbit/s", "Mbit/s", "Gbit/s", "Tbit/s", "Pbit/s",
// Percent unit
"percent", "percentunit", "%",
// Bool unit
"bool", "bool_yes_no", "bool_true_false", "bool_1_0",
// Throughput unit
"cps", "ops", "reqps", "rps", "wps", "iops", "cpm", "opm", "rpm", "wpm", "{count}/s", "{ops}/s", "{req}/s", "{read}/s", "{write}/s", "{iops}/s", "{count}/min", "{ops}/min", "{read}/min", "{write}/min":
return true
default:
return false
}
}
func UnitToName(u string) string {
switch u {
case "ns":

View File

@@ -9,8 +9,9 @@ import (
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/converter"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/pkg/errors"
"go.uber.org/zap"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -601,43 +602,170 @@ func (c *CompositeQuery) Sanitize() {
func (c *CompositeQuery) Validate() error {
if c == nil {
return fmt.Errorf("composite query is required")
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"composite query is required",
)
}
if c.BuilderQueries == nil && c.ClickHouseQueries == nil && c.PromQueries == nil && len(c.Queries) == 0 {
return fmt.Errorf("composite query must contain at least one query type")
}
if c.QueryType == QueryTypeBuilder {
for name, query := range c.BuilderQueries {
if err := query.Validate(c.PanelType); err != nil {
return fmt.Errorf("builder query %s is invalid: %w", name, err)
}
}
}
if c.QueryType == QueryTypeClickHouseSQL {
for name, query := range c.ClickHouseQueries {
if err := query.Validate(); err != nil {
return fmt.Errorf("clickhouse query %s is invalid: %w", name, err)
}
}
}
if c.QueryType == QueryTypePromQL {
for name, query := range c.PromQueries {
if err := query.Validate(); err != nil {
return fmt.Errorf("prom query %s is invalid: %w", name, err)
}
// Validate unit if supplied
if c.Unit != "" {
unit := converter.Unit(c.Unit)
err := unit.Validate()
if err != nil {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid unit: %s",
err.Error(),
)
}
}
if err := c.PanelType.Validate(); err != nil {
return fmt.Errorf("panel type is invalid: %w", err)
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"panel type is invalid: %s",
err.Error(),
)
}
if err := c.QueryType.Validate(); err != nil {
return fmt.Errorf("query type is invalid: %w", err)
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"query type is invalid: %s",
err.Error(),
)
}
if len(c.Queries) == 0 {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"at least one query is required",
)
}
// Validate each query
for i, envelope := range c.Queries {
queryId := qbtypes.GetQueryIdentifier(envelope, i)
switch envelope.Type {
case qbtypes.QueryTypeBuilder, qbtypes.QueryTypeSubQuery:
switch spec := envelope.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
if err := spec.Validate(qbtypes.RequestTypeTimeSeries); err != nil {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid %s: %s",
queryId,
err.Error(),
)
}
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
if err := spec.Validate(qbtypes.RequestTypeTimeSeries); err != nil {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid %s: %s",
queryId,
err.Error(),
)
}
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
if err := spec.Validate(qbtypes.RequestTypeTimeSeries); err != nil {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid %s: %s",
queryId,
err.Error(),
)
}
default:
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"unknown query spec type for %s",
queryId,
)
}
case qbtypes.QueryTypePromQL:
spec, ok := envelope.Spec.(qbtypes.PromQuery)
if !ok {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid spec for %s",
queryId,
)
}
if spec.Query == "" {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"query expression is required for %s",
queryId,
)
}
if err := validatePromQLQuery(spec.Query); err != nil {
return err
}
case qbtypes.QueryTypeClickHouseSQL:
spec, ok := envelope.Spec.(qbtypes.ClickHouseQuery)
if !ok {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid spec for %s",
queryId,
)
}
if spec.Query == "" {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"query expression is required for %s",
queryId,
)
}
if err := validateClickHouseQuery(spec.Query); err != nil {
return err
}
case qbtypes.QueryTypeFormula:
spec, ok := envelope.Spec.(qbtypes.QueryBuilderFormula)
if !ok {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid spec for %s",
queryId,
)
}
if err := spec.Validate(); err != nil {
return err
}
case qbtypes.QueryTypeTraceOperator:
spec, ok := envelope.Spec.(qbtypes.QueryBuilderTraceOperator)
if !ok {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid spec for %s",
queryId,
)
}
err := spec.ValidateTraceOperator(c.Queries)
if err != nil {
return err
}
default:
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"unknown query type '%s' for %s",
envelope.Type,
queryId,
).WithAdditional(
"Valid query types are: builder_query, builder_sub_query, builder_formula, builder_join, promql, clickhouse_sql, trace_operator",
)
}
}
// Check if all queries are disabled
if allDisabled := checkQueriesDisabled(c); allDisabled {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"all queries are disabled - at least one query must be enabled",
)
}
return nil
@@ -1203,7 +1331,7 @@ func (f *FilterSet) Scan(src interface{}) error {
func (f *FilterSet) Value() (driver.Value, error) {
filterSetJson, err := json.Marshal(f)
if err != nil {
return nil, errors.Wrap(err, "could not serialize FilterSet to JSON")
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "could not serialize FilterSet to JSON")
}
return filterSetJson, nil
}

View File

@@ -0,0 +1,137 @@
package v3
import (
"bytes"
"fmt"
"text/template"
"time"
clickhouse "github.com/AfterShip/clickhouse-sql-parser/parser"
"github.com/SigNoz/signoz/pkg/errors"
querytemplate "github.com/SigNoz/signoz/pkg/query-service/utils/queryTemplate"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/prometheus/prometheus/promql/parser"
)
type QueryParseError struct {
StartPosition *int
EndPosition *int
ErrorMessage string
Query string
}
func (e *QueryParseError) Error() string {
if e.StartPosition != nil && e.EndPosition != nil {
return fmt.Sprintf("query parse error: %s at position %d:%d", e.ErrorMessage, *e.StartPosition, *e.EndPosition)
}
return fmt.Sprintf("query parse error: %s", e.ErrorMessage)
}
// validatePromQLQuery validates a PromQL query syntax using the Prometheus parser
func validatePromQLQuery(query string) error {
_, err := parser.ParseExpr(query)
if err != nil {
if syntaxErrs, ok := err.(parser.ParseErrors); ok {
syntaxErr := syntaxErrs[0]
startPosition := int(syntaxErr.PositionRange.Start)
endPosition := int(syntaxErr.PositionRange.End)
return &QueryParseError{
StartPosition: &startPosition,
EndPosition: &endPosition,
ErrorMessage: syntaxErr.Error(),
Query: query,
}
}
}
return err
}
// validateClickHouseQuery validates a ClickHouse SQL query syntax using the ClickHouse parser
func validateClickHouseQuery(query string) error {
// Assign the default template variables with dummy values
variables := make(map[string]interface{})
start := time.Now().UnixMilli()
end := start + 1000
querytemplate.AssignReservedVars(variables, start, end)
// Apply the values for default template variables before parsing the query
tmpl := template.New("clickhouse-query")
tmpl, err := tmpl.Parse(query)
if err != nil {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"failed to parse clickhouse query: %s",
err.Error(),
)
}
var queryBuffer bytes.Buffer
err = tmpl.Execute(&queryBuffer, variables)
if err != nil {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"failed to execute clickhouse query template: %s",
err.Error(),
)
}
// Parse the ClickHouse query with the default template variables applied
p := clickhouse.NewParser(queryBuffer.String())
_, err = p.ParseStmts()
if err != nil {
// TODO: errors returned here is errors.errorString
// we should think on using some other library that parses the CH query in more accurate manner,
// current CH parser does very minimal checks and on just the known keywords, without validating the syntax of given query.
// Sample Error: "line 0:36 expected table name or subquery, got ;\nSELECT department, avg(salary) FROM ;\n ^\n"
return &QueryParseError{
ErrorMessage: err.Error(),
Query: query,
}
}
return nil
}
// checkQueriesDisabled checks if all queries are disabled. Returns true if all queries are disabled, false otherwise.
func checkQueriesDisabled(compositeQuery *CompositeQuery) bool {
for _, envelope := range compositeQuery.Queries {
switch envelope.Type {
case qbtypes.QueryTypeBuilder, qbtypes.QueryTypeSubQuery:
switch spec := envelope.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
if !spec.Disabled {
return false
}
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
if !spec.Disabled {
return false
}
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
if !spec.Disabled {
return false
}
}
case qbtypes.QueryTypeFormula:
if spec, ok := envelope.Spec.(qbtypes.QueryBuilderFormula); ok && !spec.Disabled {
return false
}
case qbtypes.QueryTypeTraceOperator:
if spec, ok := envelope.Spec.(qbtypes.QueryBuilderTraceOperator); ok && !spec.Disabled {
return false
}
case qbtypes.QueryTypeJoin:
if spec, ok := envelope.Spec.(qbtypes.QueryBuilderJoin); ok && !spec.Disabled {
return false
}
case qbtypes.QueryTypePromQL:
if spec, ok := envelope.Spec.(qbtypes.PromQuery); ok && !spec.Disabled {
return false
}
case qbtypes.QueryTypeClickHouseSQL:
if spec, ok := envelope.Spec.(qbtypes.ClickHouseQuery); ok && !spec.Disabled {
return false
}
}
}
// If we reach here, all queries are disabled
return true
}

View File

@@ -0,0 +1,528 @@
package v3
import (
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
func TestValidateCompositeQuery(t *testing.T) {
tests := []struct {
name string
compositeQuery *CompositeQuery
wantErr bool
errContains string
}{
{
name: "nil composite query should return error",
compositeQuery: nil,
wantErr: true,
errContains: "composite query is required",
},
{
name: "empty queries array should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{},
},
wantErr: true,
errContains: "at least one query is required",
},
{
name: "invalid input error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Unit: "some_invalid_unit",
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "rate(http_requests_total[5m])",
},
},
},
},
wantErr: true,
errContains: "invalid unit",
},
{
name: "valid metric builder query should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Unit: "bytes", // valid unit
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "metric_query",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "cpu_usage",
},
},
},
},
},
},
wantErr: false,
},
{
name: "valid log builder query should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Unit: "µs", // valid unit
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Name: "log_query",
Signal: telemetrytypes.SignalLogs,
Aggregations: []qbtypes.LogAggregation{
{
Expression: "count()",
},
},
},
},
},
},
wantErr: false,
},
{
name: "valid trace builder query should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Unit: "MBs", // valid unit
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "trace_query",
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "count()",
},
},
},
},
},
},
wantErr: false,
},
{
name: "valid PromQL query should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Unit: "{req}/s", // valid unit
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "rate(http_requests_total[5m])",
},
},
},
},
wantErr: false,
},
{
name: "valid ClickHouse query should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeClickHouseSQL,
Spec: qbtypes.ClickHouseQuery{
Name: "ch_query",
Query: "SELECT count(*) FROM metrics WHERE metric_name = 'cpu_usage'",
},
},
},
},
wantErr: false,
},
{
name: "valid formula query should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeFormula,
Spec: qbtypes.QueryBuilderFormula{
Name: "formula_query",
Expression: "A + B",
},
},
},
},
wantErr: false,
},
// We've not added support for join query yet
{
name: "valid join query should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeJoin,
Spec: qbtypes.QueryBuilderJoin{
Name: "join_query",
Left: qbtypes.QueryRef{Name: "A"},
Right: qbtypes.QueryRef{Name: "B"},
Type: qbtypes.JoinTypeInner,
On: "service_name",
},
},
},
},
wantErr: true,
errContains: "unknown query type",
},
{
name: "valid trace operator query should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "count()",
},
},
},
},
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "B",
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "count()",
},
},
},
},
{
Type: qbtypes.QueryTypeTraceOperator,
Spec: qbtypes.QueryBuilderTraceOperator{
Name: "trace_operator",
Expression: "A && B",
},
},
},
},
wantErr: false,
},
{
name: "invalid metric builder query - missing aggregation should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "metric_query",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{},
},
},
},
},
wantErr: true,
errContains: "invalid",
},
{
name: "invalid PromQL query - empty query should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "",
},
},
},
},
wantErr: true,
errContains: "query expression is required",
},
{
name: "invalid PromQL query - syntax error should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "rate(http_requests_total[5m",
},
},
},
},
wantErr: true,
errContains: "unclosed left parenthesis",
},
{
name: "invalid ClickHouse query - empty query should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeClickHouseSQL,
Spec: qbtypes.ClickHouseQuery{
Name: "ch_query",
Query: "",
},
},
},
},
wantErr: true,
errContains: "query expression is required",
},
{
name: "invalid ClickHouse query - syntax error should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeClickHouseSQL,
Spec: qbtypes.ClickHouseQuery{
Name: "ch_query",
Query: "SELECT * FROM metrics WHERE",
},
},
},
},
wantErr: true,
errContains: "query parse error",
},
{
name: "invalid formula query - empty expression should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeFormula,
Spec: qbtypes.QueryBuilderFormula{
Name: "formula_query",
Expression: "",
},
},
},
},
wantErr: true,
errContains: "formula expression cannot be blank",
},
{
name: "invalid trace operator query - empty expression should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeTraceOperator,
Spec: qbtypes.QueryBuilderTraceOperator{
Name: "trace_operator",
Expression: "",
},
},
},
},
wantErr: true,
errContains: "expression cannot be empty",
},
{
name: "all queries disabled should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "metric_query",
Disabled: true,
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "cpu_usage",
},
},
},
},
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "rate(http_requests_total[5m])",
Disabled: true,
},
},
},
},
wantErr: true,
errContains: "all queries are disabled",
},
{
name: "mixed disabled and enabled queries should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "metric_query",
Disabled: true,
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "cpu_usage",
},
},
},
},
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "rate(http_requests_total[5m])",
Disabled: false,
},
},
},
},
wantErr: false,
},
{
name: "multiple valid queries should pass",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "metric_query",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "cpu_usage",
},
},
},
},
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "rate(http_requests_total[5m])",
},
},
{
Type: qbtypes.QueryTypeClickHouseSQL,
Spec: qbtypes.ClickHouseQuery{
Name: "ch_query",
Query: "SELECT count(*) FROM metrics WHERE metric_name = 'cpu_usage'",
},
},
},
},
wantErr: false,
},
{
name: "invalid query in multiple queries should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "metric_query",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "cpu_usage",
},
},
},
},
{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "invalid promql syntax [",
},
},
},
},
wantErr: true,
errContains: "query parse error",
},
{
name: "unknown query type should return error",
compositeQuery: &CompositeQuery{
QueryType: QueryTypeBuilder,
PanelType: PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryType{String: valuer.NewString("invalid_query_type")},
Spec: qbtypes.PromQuery{
Name: "prom_query",
Query: "rate(http_requests_total[5m])",
},
},
},
},
wantErr: true,
errContains: "unknown query type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.compositeQuery.Validate()
if tt.wantErr {
require.Error(t, err)
if tt.errContains != "" {
require.Contains(t, err.Error(), tt.errContains)
}
} else {
require.NoError(t, err)
}
})
}
}

View File

@@ -133,7 +133,7 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*v
Variables: make(map[string]interface{}, 0),
NoCache: true,
}
querytemplate.AssignReservedVarsV3(params)
querytemplate.AssignReservedVars(params.Variables, start, end)
for name, chQuery := range r.ruleCondition.CompositeQuery.ClickHouseQueries {
if chQuery.Disabled {
continue

View File

@@ -2,26 +2,21 @@ package querytemplate
import (
"fmt"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
)
// AssignReservedVars assigns values for go template vars. assumes that
// model.QueryRangeParamsV3.Start and End are Unix Nano timestamps
func AssignReservedVarsV3(queryRangeParams *v3.QueryRangeParamsV3) {
queryRangeParams.Variables["start_timestamp"] = queryRangeParams.Start / 1000
queryRangeParams.Variables["end_timestamp"] = queryRangeParams.End / 1000
func AssignReservedVars(variables map[string]interface{}, start int64, end int64) {
variables["start_timestamp"] = start / 1000
variables["end_timestamp"] = end / 1000
queryRangeParams.Variables["start_timestamp_ms"] = queryRangeParams.Start
queryRangeParams.Variables["end_timestamp_ms"] = queryRangeParams.End
variables["start_timestamp_ms"] = start
variables["end_timestamp_ms"] = end
queryRangeParams.Variables["SIGNOZ_START_TIME"] = queryRangeParams.Start
queryRangeParams.Variables["SIGNOZ_END_TIME"] = queryRangeParams.End
variables["SIGNOZ_START_TIME"] = start
variables["SIGNOZ_END_TIME"] = end
queryRangeParams.Variables["start_timestamp_nano"] = queryRangeParams.Start * 1e6
queryRangeParams.Variables["end_timestamp_nano"] = queryRangeParams.End * 1e6
queryRangeParams.Variables["start_datetime"] = fmt.Sprintf("toDateTime(%d)", queryRangeParams.Start/1000)
queryRangeParams.Variables["end_datetime"] = fmt.Sprintf("toDateTime(%d)", queryRangeParams.End/1000)
variables["start_timestamp_nano"] = start * 1e6
variables["end_timestamp_nano"] = end * 1e6
variables["start_datetime"] = fmt.Sprintf("toDateTime(%d)", start/1000)
variables["end_datetime"] = fmt.Sprintf("toDateTime(%d)", end/1000)
}

View File

@@ -14,7 +14,7 @@ import (
func NewAuthNs(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
emailPasswordAuthN := emailpasswordauthn.New(store)
googleCallbackAuthN, err := googlecallbackauthn.New(ctx, store, providerSettings)
googleCallbackAuthN, err := googlecallbackauthn.New(ctx, store)
if err != nil {
return nil, err
}

View File

@@ -3,7 +3,6 @@ package signoz
import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/global/signozglobal"
"github.com/SigNoz/signoz/pkg/licensing"
@@ -40,10 +39,9 @@ type Handlers struct {
MetricsExplorer metricsexplorer.Handler
Global global.Handler
FlaggerHandler flagger.Handler
GatewayHandler gateway.Handler
}
func NewHandlers(modules Modules, providerSettings factory.ProviderSettings, querier querier.Querier, licensing licensing.Licensing, global global.Global, flaggerService flagger.Flagger, gatewayService gateway.Gateway) Handlers {
func NewHandlers(modules Modules, providerSettings factory.ProviderSettings, querier querier.Querier, licensing licensing.Licensing, global global.Global, flaggerService flagger.Flagger) Handlers {
return Handlers{
SavedView: implsavedview.NewHandler(modules.SavedView),
Apdex: implapdex.NewHandler(modules.Apdex),
@@ -56,6 +54,5 @@ func NewHandlers(modules Modules, providerSettings factory.ProviderSettings, que
SpanPercentile: implspanpercentile.NewHandler(modules.SpanPercentile),
Global: signozglobal.NewHandler(global),
FlaggerHandler: flagger.NewHandler(flaggerService),
GatewayHandler: gateway.NewHandler(gatewayService),
}
}

View File

@@ -42,7 +42,7 @@ func TestNewHandlers(t *testing.T) {
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule)
handlers := NewHandlers(modules, providerSettings, nil, nil, nil, nil, nil)
handlers := NewHandlers(modules, providerSettings, nil, nil, nil, nil)
reflectVal := reflect.ValueOf(handlers)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -9,7 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/apiserver/signozapiserver"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/instrumentation"
@@ -48,7 +47,6 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ dashboard.Module }{},
struct{ dashboard.Handler }{},
struct{ metricsexplorer.Handler }{},
struct{ gateway.Handler }{},
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
if err != nil {
return nil, err

View File

@@ -167,9 +167,15 @@ func NewSQLMigrationProviderFactories(
func NewTelemetryStoreProviderFactories() factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]] {
return factory.MustNewNamedMap(
clickhousetelemetrystore.NewFactory(
telemetrystorehook.NewSettingsFactory(),
telemetrystorehook.NewLoggingFactory(),
telemetrystorehook.NewInstrumentationFactory(),
telemetrystore.TelemetryStoreHookFactoryFunc(func(s string) factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config] {
return telemetrystorehook.NewSettingsFactory(s)
}),
telemetrystore.TelemetryStoreHookFactoryFunc(func(s string) factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config] {
return telemetrystorehook.NewLoggingFactory()
}),
telemetrystore.TelemetryStoreHookFactoryFunc(func(s string) factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config] {
return telemetrystorehook.NewInstrumentationFactory(s)
}),
),
)
}
@@ -241,7 +247,6 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
modules.Dashboard,
handlers.Dashboard,
handlers.MetricsExplorer,
handlers.GatewayHandler,
),
)
}

View File

@@ -15,7 +15,6 @@ import (
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
@@ -72,7 +71,6 @@ type SigNoz struct {
Handlers Handlers
QueryParser queryparser.QueryParser
Flagger flagger.Flagger
Gateway gateway.Gateway
}
func New(
@@ -91,7 +89,6 @@ func New(
authNsCallback func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error),
authzCallback func(context.Context, sqlstore.SQLStore) factory.ProviderFactory[authz.AuthZ, authz.Config],
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, role.Module, queryparser.QueryParser, querier.Querier, licensing.Licensing) dashboard.Module,
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
) (*SigNoz, error) {
// Initialize instrumentation
instrumentation, err := instrumentation.New(ctx, config.Instrumentation, version.Info, "signoz")
@@ -339,12 +336,6 @@ func New(
return nil, err
}
gatewayFactory := gatewayProviderFactory(licensing)
gateway, err := gatewayFactory.New(ctx, providerSettings, config.Gateway)
if err != nil {
return nil, err
}
// Initialize authns
store := sqlauthnstore.NewStore(sqlstore)
authNs, err := authNsCallback(ctx, providerSettings, store, licensing)
@@ -391,7 +382,7 @@ func New(
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboardModule)
// Initialize all handlers for the modules
handlers := NewHandlers(modules, providerSettings, querier, licensing, global, flagger, gateway)
handlers := NewHandlers(modules, providerSettings, querier, licensing, global, flagger)
// Initialize the API server
apiserver, err := factory.NewProviderFromNamedMap(
@@ -415,7 +406,6 @@ func New(
licensing,
tokenizer,
config,
modules.AuthDomain,
}
// Initialize stats reporter from the available stats reporter provider factories
@@ -467,6 +457,5 @@ func New(
Handlers: handlers,
QueryParser: queryParser,
Flagger: flagger,
Gateway: gateway,
}, nil
}

View File

@@ -16,13 +16,13 @@ type provider struct {
hooks []telemetrystore.TelemetryStoreHook
}
func NewFactory(hookFactories ...factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config]) factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config] {
func NewFactory(hookFactories ...telemetrystore.TelemetryStoreHookFactoryFunc) factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config] {
return factory.NewProviderFactory(factory.MustNewName("clickhouse"), func(ctx context.Context, providerSettings factory.ProviderSettings, config telemetrystore.Config) (telemetrystore.TelemetryStore, error) {
return New(ctx, providerSettings, config, hookFactories...)
})
}
func New(ctx context.Context, providerSettings factory.ProviderSettings, config telemetrystore.Config, hookFactories ...factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config]) (telemetrystore.TelemetryStore, error) {
func New(ctx context.Context, providerSettings factory.ProviderSettings, config telemetrystore.Config, hookFactories ...telemetrystore.TelemetryStoreHookFactoryFunc) (telemetrystore.TelemetryStore, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/telemetrystore/clickhousetelemetrystore")
options, err := clickhouse.ParseDSN(config.Clickhouse.DSN)
@@ -40,10 +40,14 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
return nil, err
}
var version string
if err := chConn.QueryRow(ctx, "SELECT version()").Scan(&version); err != nil {
return nil, err
}
hooks := make([]telemetrystore.TelemetryStoreHook, len(hookFactories))
for i, hookFactory := range hookFactories {
hook, err := hookFactory.New(ctx, providerSettings, config)
hook, err := hookFactory(version).New(ctx, providerSettings, config)
if err != nil {
return nil, err
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/factory"
)
type TelemetryStore interface {
@@ -19,6 +20,7 @@ type TelemetryStoreHook interface {
AfterQuery(ctx context.Context, event *QueryEvent)
}
type TelemetryStoreHookFactoryFunc func(string) factory.ProviderFactory[TelemetryStoreHook, Config]
func WrapBeforeQuery(hooks []TelemetryStoreHook, ctx context.Context, event *QueryEvent) context.Context {
for _, hook := range hooks {

View File

@@ -13,21 +13,23 @@ import (
)
type instrumentation struct {
clickhouseVersion string
clickhouseCluster string
tracer trace.Tracer
meter metric.Meter
}
func NewInstrumentationFactory() factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config] {
func NewInstrumentationFactory(version string) factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config] {
return factory.NewProviderFactory(factory.MustNewName("instrumentation"), func(ctx context.Context, ps factory.ProviderSettings, c telemetrystore.Config) (telemetrystore.TelemetryStoreHook, error) {
return NewInstrumentation(ctx, ps, c)
return NewInstrumentation(ctx, ps, c, version)
})
}
func NewInstrumentation(ctx context.Context, providerSettings factory.ProviderSettings, config telemetrystore.Config) (telemetrystore.TelemetryStoreHook, error) {
func NewInstrumentation(ctx context.Context, providerSettings factory.ProviderSettings, config telemetrystore.Config, version string) (telemetrystore.TelemetryStoreHook, error) {
meter := providerSettings.MeterProvider.Meter("github.com/SigNoz/signoz/pkg/telemetrystore")
return &instrumentation{
clickhouseVersion: version,
clickhouseCluster: config.Clickhouse.Cluster,
tracer: providerSettings.TracerProvider.Tracer("github.com/SigNoz/signoz/pkg/telemetrystore"),
meter: meter,
@@ -52,6 +54,7 @@ func (hook *instrumentation) AfterQuery(ctx context.Context, event *telemetrysto
attrs = append(
attrs,
semconv.DBStatementKey.String(event.Query),
attribute.String("db.version", hook.clickhouseVersion),
semconv.DBSystemKey.String("clickhouse"),
semconv.DBOperationKey.String(event.Operation),
attribute.String("clickhouse.cluster", hook.clickhouseCluster),

View File

@@ -2,6 +2,7 @@ package telemetrystorehook
import (
"context"
"strings"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/factory"
@@ -10,17 +11,19 @@ import (
)
type provider struct {
clickHouseVersion string
settings telemetrystore.QuerySettings
}
func NewSettingsFactory() factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config] {
func NewSettingsFactory(version string) factory.ProviderFactory[telemetrystore.TelemetryStoreHook, telemetrystore.Config] {
return factory.NewProviderFactory(factory.MustNewName("settings"), func(ctx context.Context, providerSettings factory.ProviderSettings, config telemetrystore.Config) (telemetrystore.TelemetryStoreHook, error) {
return NewSettings(ctx, providerSettings, config)
return NewSettings(ctx, providerSettings, config, version)
})
}
func NewSettings(ctx context.Context, providerSettings factory.ProviderSettings, config telemetrystore.Config) (telemetrystore.TelemetryStoreHook, error) {
func NewSettings(ctx context.Context, providerSettings factory.ProviderSettings, config telemetrystore.Config, version string) (telemetrystore.TelemetryStoreHook, error) {
return &provider{
clickHouseVersion: version,
settings: config.Clickhouse.QuerySettings,
}, nil
}
@@ -72,9 +75,12 @@ func (h *provider) BeforeQuery(ctx context.Context, _ *telemetrystore.QueryEvent
settings["result_overflow_mode"] = ctx.Value("result_overflow_mode")
}
// TODO(srikanthccv): enable it when the "Cannot read all data" issue is fixed
// https://github.com/ClickHouse/ClickHouse/issues/82283
settings["secondary_indices_enable_bulk_filtering"] = false
// ClickHouse version check is added since this setting is not support on version below 25.5
if strings.HasPrefix(h.clickHouseVersion, "25") && !h.settings.SecondaryIndicesEnableBulkFiltering {
// TODO(srikanthccv): enable it when the "Cannot read all data" issue is fixed
// https://github.com/ClickHouse/ClickHouse/issues/82283
settings["secondary_indices_enable_bulk_filtering"] = false
}
ctx = clickhouse.Context(ctx, clickhouse.WithSettings(settings))
return ctx

View File

@@ -44,7 +44,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/tokenizer/jwttokenizer")
if config.JWT.Secret == "" {
settings.Logger().ErrorContext(ctx, "🚨 CRITICAL SECURITY ISSUE: No JWT secret key specified!", "error", "SIGNOZ_TOKENIZER_JWT_SECRET environment variable is not set. This has dire consequences for the security of the application. Without a JWT secret, user sessions are vulnerable to tampering and unauthorized access. Please set the SIGNOZ_TOKENIZER_JWT_SECRET environment variable immediately. For more information, please refer to https://github.com/SigNoz/signoz/issues/8400.")
settings.Logger().ErrorContext(ctx, "🚨 CRITICAL SECURITY ISSUE: No JWT secret key specified!", "error", "SIGNOZ_JWT_SECRET environment variable is not set. This has dire consequences for the security of the application. Without a JWT secret, user sessions are vulnerable to tampering and unauthorized access. Please set the SIGNOZ_TOKENIZER_JWT_SECRET environment variable immediately. For more information, please refer to https://github.com/SigNoz/signoz/issues/8400.")
}
lastObservedAtCache, err := ristretto.NewCache(&ristretto.Config[string, map[valuer.UUID]time.Time]{

View File

@@ -32,12 +32,10 @@ type Identity struct {
}
type CallbackIdentity struct {
Name string `json:"name"`
Email valuer.Email `json:"email"`
OrgID valuer.UUID `json:"orgId"`
State State `json:"state"`
Groups []string `json:"groups,omitempty"`
Role string `json:"role,omitempty"`
Name string `json:"name"`
Email valuer.Email `json:"email"`
OrgID valuer.UUID `json:"orgId"`
State State `json:"state"`
}
type State struct {
@@ -87,14 +85,12 @@ func NewIdentity(userID valuer.UUID, orgID valuer.UUID, email valuer.Email, role
}
}
func NewCallbackIdentity(name string, email valuer.Email, orgID valuer.UUID, state State, groups []string, role string) *CallbackIdentity {
func NewCallbackIdentity(name string, email valuer.Email, orgID valuer.UUID, state State) *CallbackIdentity {
return &CallbackIdentity{
Name: name,
Email: email,
OrgID: orgID,
State: state,
Groups: groups,
Role: role,
Name: name,
Email: email,
OrgID: orgID,
State: state,
}
}

View File

@@ -63,7 +63,6 @@ type AuthDomainConfig struct {
SAML *SamlConfig `json:"samlConfig"`
Google *GoogleConfig `json:"googleAuthConfig"`
OIDC *OIDCConfig `json:"oidcConfig"`
RoleMapping *RoleMapping `json:"roleMapping"`
}
type AuthDomain struct {

View File

@@ -2,14 +2,10 @@ package authtypes
import (
"encoding/json"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
const wildCardDomain = "*"
type GoogleConfig struct {
// ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.
ClientID string `json:"clientId"`
@@ -19,30 +15,6 @@ type GoogleConfig struct {
// What is the meaning of this? Should we remove this?
RedirectURI string `json:"redirectURI"`
// Whether to fetch the Google workspace groups (required additional API scopes)
FetchGroups bool `json:"fetchGroups"`
// Service Account creds JSON stored for Google Admin SDK access
// This is content of the JSON file stored directly into db as string
// Required if FetchGroups is true (unless running on GCE with default credentials)
ServiceAccountJSON string `json:"serviceAccountJson,omitempty"`
// Map of workspace domain to admin email for service account impersonation
// The service account will impersonate this admin to call the directory API
// Use "*" as key for wildcard/default that matches any domain
// Example: {"example.com": "admin@exmaple.com", "*": "fallbackadmin@company.com"}
DomainToAdminEmail map[string]valuer.Email `json:"domainToAdminEmail,omitempty"`
// If true, fetch transitive group membership (recursive - groups that contains other groups)
FetchTransitiveGroupMembership bool `json:"fetchTransitiveGroupMembership,omitempty"`
// Optional list of allowed groups
// If this is present, only users belonging to one of these groups will be allowed to login
AllowedGroups []string `json:"allowedGroups,omitempty"`
// Whether to skip email verification. Defaults to "false"
InsecureSkipEmailVerified bool `json:"insecureSkipEmailVerified"`
}
func (config *GoogleConfig) UnmarshalJSON(data []byte) error {
@@ -61,37 +33,6 @@ func (config *GoogleConfig) UnmarshalJSON(data []byte) error {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "clientSecret is required")
}
if temp.FetchGroups {
if len(temp.DomainToAdminEmail) == 0 {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "domainToAdminEmail is required if fetchGroups is true")
}
if temp.ServiceAccountJSON == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "serviceAccountJSON is required if fetchGroups is true")
}
}
if len(temp.AllowedGroups) > 0 && !temp.FetchGroups {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "fetchGroups must be true when allowedGroups is configured")
}
*config = GoogleConfig(temp)
return nil
}
func (config *GoogleConfig) GetAdminEmailForDomain(userEmail string) string {
domain := extractDomainFromEmail(userEmail)
if adminEmail, ok := config.DomainToAdminEmail[domain]; ok {
return adminEmail.StringValue()
}
return config.DomainToAdminEmail[wildCardDomain].StringValue()
}
func extractDomainFromEmail(email string) string {
if at := strings.LastIndex(email, "@"); at >= 0 {
return email[at+1:]
}
return wildCardDomain
}

View File

@@ -1,133 +0,0 @@
package authtypes
import (
"encoding/json"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
)
type AttributeMapping struct {
// Key which contains the email in the claim/token/attributes map. Defaults to "email"
Email string `json:"email"`
// Key which contains the name in the claim/token/attributes map. Defaults to "name"
Name string `json:"name"`
// Key which contains the groups in the claim/token/attributes map. Defaults to "groups"
Groups string `json:"groups"`
// Key which contains the role in the claim/token/attributes map. Defaults to "role"
Role string `json:"role"`
}
func (attr *AttributeMapping) UnmarshalJSON(data []byte) error {
type Alias AttributeMapping
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if temp.Email == "" {
temp.Email = "email"
}
if temp.Name == "" {
temp.Name = "name"
}
if temp.Groups == "" {
temp.Groups = "groups"
}
if temp.Role == "" {
temp.Role = "role"
}
*attr = AttributeMapping(temp)
return nil
}
type RoleMapping struct {
// Default role any new SSO users. Defaults to "VIEWER"
DefaultRole string `json:"defaultRole"`
// Map of IDP group names to SigNoz roles. Key is group name, value is SigNoz role
GroupMappings map[string]string `json:"groupMappings"`
// If true, use the role claim directly from IDP instead of group mappings
UseRoleAttribute bool `json:"useRoleAttribute"`
}
func (typ *RoleMapping) UnmarshalJSON(data []byte) error {
type Alias RoleMapping
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if temp.DefaultRole != "" {
if _, err := types.NewRole(strings.ToUpper(temp.DefaultRole)); err != nil {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid default role %s", temp.DefaultRole)
}
}
for group, role := range temp.GroupMappings {
if _, err := types.NewRole(strings.ToUpper(role)); err != nil {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid role %s for group %s", role, group)
}
}
*typ = RoleMapping(temp)
return nil
}
func (roleMapping *RoleMapping) NewRoleFromCallbackIdentity(callbackIdentity *CallbackIdentity) types.Role {
if roleMapping == nil {
return types.RoleViewer
}
if roleMapping.UseRoleAttribute && callbackIdentity.Role != "" {
if role, err := types.NewRole(strings.ToUpper(callbackIdentity.Role)); err == nil {
return role
}
}
if len(roleMapping.GroupMappings) > 0 && len(callbackIdentity.Groups) > 0 {
highestRole := types.RoleViewer
found := false
for _, group := range callbackIdentity.Groups {
if mappedRole, exists := roleMapping.GroupMappings[group]; exists {
found = true
if role, err := types.NewRole(strings.ToUpper(mappedRole)); err == nil {
if compareRoles(role, highestRole) > 0 {
highestRole = role
}
}
}
}
if found {
return highestRole
}
}
if roleMapping.DefaultRole != "" {
if role, err := types.NewRole(strings.ToUpper(roleMapping.DefaultRole)); err == nil {
return role
}
}
return types.RoleViewer
}
func compareRoles(a, b types.Role) int {
order := map[types.Role]int{
types.RoleViewer: 0,
types.RoleEditor: 1,
types.RoleAdmin: 2,
}
return order[a] - order[b]
}

View File

@@ -22,7 +22,7 @@ type OIDCConfig struct {
ClientSecret string `json:"clientSecret"`
// Mapping of claims to the corresponding fields in the token.
ClaimMapping AttributeMapping `json:"claimMapping"`
ClaimMapping ClaimMapping `json:"claimMapping"`
// Whether to skip email verification. Defaults to "false"
InsecureSkipEmailVerified bool `json:"insecureSkipEmailVerified"`
@@ -31,6 +31,11 @@ type OIDCConfig struct {
GetUserInfo bool `json:"getUserInfo"`
}
type ClaimMapping struct {
// Configurable key which contains the email claims. Defaults to "email"
Email string `json:"email"`
}
func (config *OIDCConfig) UnmarshalJSON(data []byte) error {
type Alias OIDCConfig
@@ -51,10 +56,8 @@ func (config *OIDCConfig) UnmarshalJSON(data []byte) error {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "clientSecret is required")
}
if temp.ClaimMapping == (AttributeMapping{}) {
if err := json.Unmarshal([]byte("{}"), &temp.ClaimMapping); err != nil {
return err
}
if temp.ClaimMapping.Email == "" {
temp.ClaimMapping.Email = "email"
}
*config = OIDCConfig(temp)

View File

@@ -20,9 +20,6 @@ type SamlConfig struct {
// For providers like jumpcloud, this should be set to true.
// Note: This is the reverse of WantAuthnRequestsSigned. If WantAuthnRequestsSigned is false, then InsecureSkipAuthNRequestsSigned should be true.
InsecureSkipAuthNRequestsSigned bool `json:"insecureSkipAuthNRequestsSigned"`
// Mapping of SAML assertion attributes
AttributeMapping AttributeMapping `json:"attributeMapping"`
}
func (config *SamlConfig) UnmarshalJSON(data []byte) error {
@@ -45,12 +42,6 @@ func (config *SamlConfig) UnmarshalJSON(data []byte) error {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "samlCert is required")
}
if temp.AttributeMapping == (AttributeMapping{}) {
if err := json.Unmarshal([]byte("{}"), &temp.AttributeMapping); err != nil {
return err
}
}
*config = SamlConfig(temp)
return nil
}

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