mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-10 19:00:34 +01:00
Compare commits
34 Commits
nv/11280
...
feat/auth-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a92f33ca5 | ||
|
|
2af4f81349 | ||
|
|
bfc89d79ff | ||
|
|
f9e8ce6f91 | ||
|
|
e0d87e216b | ||
|
|
27603e09d0 | ||
|
|
7b2882abde | ||
|
|
30f1c2d92d | ||
|
|
446dd4589f | ||
|
|
c0c9039428 | ||
|
|
a014e9c0cb | ||
|
|
b898269ddc | ||
|
|
8fdad21a2e | ||
|
|
2e0d25479a | ||
|
|
73c2c15200 | ||
|
|
34203c781f | ||
|
|
1ba9b90855 | ||
|
|
927951b67a | ||
|
|
05ad8d113d | ||
|
|
faad1e659a | ||
|
|
e10a9515ef | ||
|
|
9ad37ad6e6 | ||
|
|
83b2cabbcd | ||
|
|
922800ff98 | ||
|
|
2d3772ef10 | ||
|
|
40e5cb4467 | ||
|
|
b072095a9d | ||
|
|
303908542a | ||
|
|
f52ce461d0 | ||
|
|
2e8aa0f42d | ||
|
|
c688170a13 | ||
|
|
7844fc1fe1 | ||
|
|
e02da843f2 | ||
|
|
0948a983c3 |
4
.github/workflows/build-staging.yaml
vendored
4
.github/workflows/build-staging.yaml
vendored
@@ -64,6 +64,10 @@ jobs:
|
||||
run: |
|
||||
mkdir -p frontend
|
||||
echo 'CI=1' > frontend/.env
|
||||
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_URL="${{ secrets.NP_TUNNEL_URL }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.NP_TUNNEL_DOMAIN }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_APP_ID="${{ secrets.NP_PYLON_APP_ID }}"' >> frontend/.env
|
||||
|
||||
4
.github/workflows/integrationci.yaml
vendored
4
.github/workflows/integrationci.yaml
vendored
@@ -39,10 +39,12 @@ jobs:
|
||||
matrix:
|
||||
suite:
|
||||
- alerts
|
||||
- basepath
|
||||
- callbackauthn
|
||||
- cloudintegrations
|
||||
- dashboard
|
||||
- ingestionkeys
|
||||
- inframonitoring
|
||||
- logspipelines
|
||||
- passwordauthn
|
||||
- preference
|
||||
@@ -83,7 +85,7 @@ jobs:
|
||||
run: |
|
||||
cd tests && uv sync
|
||||
- name: webdriver
|
||||
if: matrix.suite == 'callbackauthn'
|
||||
if: matrix.suite == 'callbackauthn' || matrix.suite == 'basepath'
|
||||
run: |
|
||||
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
|
||||
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list
|
||||
|
||||
@@ -91,7 +91,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
sqlstoreProviderFactories(),
|
||||
signoz.NewTelemetryStoreProviderFactories(),
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
|
||||
return signoz.NewAuthNs(ctx, providerSettings, store, licensing, config.Global)
|
||||
},
|
||||
func(ctx context.Context, sqlstore sqlstore.SQLStore, config authz.Config, _ licensing.Licensing, _ []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
|
||||
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore, config)
|
||||
|
||||
@@ -107,17 +107,17 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
sqlstoreProviderFactories(),
|
||||
signoz.NewTelemetryStoreProviderFactories(),
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
samlCallbackAuthN, err := samlcallbackauthn.New(ctx, store, licensing)
|
||||
samlCallbackAuthN, err := samlcallbackauthn.New(ctx, store, licensing, config.Global)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oidcCallbackAuthN, err := oidccallbackauthn.New(store, licensing, providerSettings)
|
||||
oidcCallbackAuthN, err := oidccallbackauthn.New(store, licensing, providerSettings, config.Global)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing)
|
||||
authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing, config.Global)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -440,6 +440,17 @@ traces:
|
||||
max_depth_to_auto_expand: 5
|
||||
# Threshold below which all spans are returned without windowing.
|
||||
max_limit_to_select_all_spans: 10000
|
||||
flamegraph:
|
||||
# Maximum number of BFS depth levels included in a windowed response.
|
||||
max_selected_levels: 50
|
||||
# Maximum spans per level before sampling is applied.
|
||||
max_spans_per_level: 100
|
||||
# Number of highest-latency spans always included when sampling a level.
|
||||
sampling_top_latency_count: 5
|
||||
# Number of timestamp buckets used for uniform sampling within a level.
|
||||
sampling_bucket_count: 50
|
||||
# Threshold below which all spans are returned without windowing or sampling.
|
||||
select_all_spans_limit: 100000
|
||||
|
||||
##################### Authz #################################
|
||||
authz:
|
||||
|
||||
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.127.1
|
||||
image: signoz/signoz:v0.128.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.127.1
|
||||
image: signoz/signoz:v0.128.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.127.1}
|
||||
image: signoz/signoz:${VERSION:-v0.128.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.127.1}
|
||||
image: signoz/signoz:${VERSION:-v0.128.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
|
||||
@@ -470,25 +470,6 @@ components:
|
||||
role:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesAuthDomainConfig:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AuthtypesSamlConfig'
|
||||
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
|
||||
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
|
||||
properties:
|
||||
googleAuthConfig:
|
||||
$ref: '#/components/schemas/AuthtypesGoogleConfig'
|
||||
oidcConfig:
|
||||
$ref: '#/components/schemas/AuthtypesOIDCConfig'
|
||||
roleMapping:
|
||||
$ref: '#/components/schemas/AuthtypesRoleMapping'
|
||||
samlConfig:
|
||||
$ref: '#/components/schemas/AuthtypesSamlConfig'
|
||||
ssoEnabled:
|
||||
type: boolean
|
||||
ssoType:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
type: object
|
||||
AuthtypesAuthNProvider:
|
||||
enum:
|
||||
- google_auth
|
||||
@@ -515,6 +496,48 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
AuthtypesAuthProviderEnvelope:
|
||||
discriminator:
|
||||
mapping:
|
||||
google_auth: '#/components/schemas/AuthtypesAuthProviderGoogle'
|
||||
oidc: '#/components/schemas/AuthtypesAuthProviderOIDC'
|
||||
saml: '#/components/schemas/AuthtypesAuthProviderSAML'
|
||||
propertyName: type
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AuthtypesAuthProviderSAML'
|
||||
- $ref: '#/components/schemas/AuthtypesAuthProviderOIDC'
|
||||
- $ref: '#/components/schemas/AuthtypesAuthProviderGoogle'
|
||||
type: object
|
||||
AuthtypesAuthProviderGoogle:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesGoogleConfig'
|
||||
type:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
required:
|
||||
- type
|
||||
- config
|
||||
type: object
|
||||
AuthtypesAuthProviderOIDC:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesOIDCConfig'
|
||||
type:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
required:
|
||||
- type
|
||||
- config
|
||||
type: object
|
||||
AuthtypesAuthProviderSAML:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesSAMLConfig'
|
||||
type:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProvider'
|
||||
required:
|
||||
- type
|
||||
- config
|
||||
type: object
|
||||
AuthtypesCallbackAuthNSupport:
|
||||
properties:
|
||||
provider:
|
||||
@@ -526,8 +549,6 @@ components:
|
||||
properties:
|
||||
authNProviderInfo:
|
||||
$ref: '#/components/schemas/AuthtypesAuthNProviderInfo'
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -537,6 +558,12 @@ components:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
provider:
|
||||
$ref: '#/components/schemas/AuthtypesAuthProviderEnvelope'
|
||||
roleMapping:
|
||||
$ref: '#/components/schemas/AuthtypesRoleMapping'
|
||||
ssoEnabled:
|
||||
type: boolean
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -634,10 +661,14 @@ components:
|
||||
type: object
|
||||
AuthtypesPostableAuthDomain:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
|
||||
name:
|
||||
type: string
|
||||
provider:
|
||||
$ref: '#/components/schemas/AuthtypesAuthProviderEnvelope'
|
||||
roleMapping:
|
||||
$ref: '#/components/schemas/AuthtypesRoleMapping'
|
||||
ssoEnabled:
|
||||
type: boolean
|
||||
type: object
|
||||
AuthtypesPostableEmailPasswordSession:
|
||||
properties:
|
||||
@@ -710,7 +741,7 @@ components:
|
||||
useRoleAttribute:
|
||||
type: boolean
|
||||
type: object
|
||||
AuthtypesSamlConfig:
|
||||
AuthtypesSAMLConfig:
|
||||
properties:
|
||||
attributeMapping:
|
||||
$ref: '#/components/schemas/AuthtypesAttributeMapping'
|
||||
@@ -745,8 +776,12 @@ components:
|
||||
type: object
|
||||
AuthtypesUpdatableAuthDomain:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
|
||||
provider:
|
||||
$ref: '#/components/schemas/AuthtypesAuthProviderEnvelope'
|
||||
roleMapping:
|
||||
$ref: '#/components/schemas/AuthtypesRoleMapping'
|
||||
ssoEnabled:
|
||||
type: boolean
|
||||
type: object
|
||||
AuthtypesUserRole:
|
||||
properties:
|
||||
@@ -1360,6 +1395,10 @@ components:
|
||||
- sqs
|
||||
- storageaccountsblob
|
||||
- cdnprofile
|
||||
- virtualmachine
|
||||
- appservice
|
||||
- containerapp
|
||||
- aks
|
||||
type: string
|
||||
CloudintegrationtypesServiceMetadata:
|
||||
properties:
|
||||
@@ -6638,6 +6677,70 @@ components:
|
||||
- attribute
|
||||
- resource
|
||||
type: string
|
||||
SpantypesFlamegraphSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
durationNano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
event:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesEvent'
|
||||
type: array
|
||||
hasError:
|
||||
type: boolean
|
||||
level:
|
||||
format: int64
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
parentSpanId:
|
||||
type: string
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
spanId:
|
||||
type: string
|
||||
timestamp:
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- spanId
|
||||
- parentSpanId
|
||||
- timestamp
|
||||
- durationNano
|
||||
- hasError
|
||||
- name
|
||||
- level
|
||||
- event
|
||||
- attributes
|
||||
- resource
|
||||
type: object
|
||||
SpantypesGettableFlamegraphTrace:
|
||||
properties:
|
||||
endTimestampMillis:
|
||||
format: int64
|
||||
type: integer
|
||||
hasMore:
|
||||
type: boolean
|
||||
spans:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesFlamegraphSpan'
|
||||
type: array
|
||||
type: array
|
||||
startTimestampMillis:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- spans
|
||||
- startTimestampMillis
|
||||
- endTimestampMillis
|
||||
- hasMore
|
||||
type: object
|
||||
SpantypesGettableSpanMapperGroups:
|
||||
properties:
|
||||
items:
|
||||
@@ -6658,11 +6761,6 @@ components:
|
||||
type: object
|
||||
SpantypesGettableWaterfallTrace:
|
||||
properties:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanAggregationResult'
|
||||
nullable: true
|
||||
type: array
|
||||
endTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
@@ -6703,6 +6801,15 @@ components:
|
||||
traceId:
|
||||
type: string
|
||||
type: object
|
||||
SpantypesPostableFlamegraph:
|
||||
properties:
|
||||
selectFields:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
type: array
|
||||
selectedSpanId:
|
||||
type: string
|
||||
type: object
|
||||
SpantypesPostableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -6741,14 +6848,6 @@ components:
|
||||
type: object
|
||||
SpantypesPostableWaterfall:
|
||||
properties:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
minimum: 0
|
||||
type: integer
|
||||
selectedSpanId:
|
||||
type: string
|
||||
uncollapsedSpans:
|
||||
@@ -20535,12 +20634,11 @@ paths:
|
||||
summary: Put profile in Zeus for a deployment.
|
||||
tags:
|
||||
- zeus
|
||||
/api/v3/traces/{traceID}/waterfall:
|
||||
/api/v3/traces/{traceID}/flamegraph:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Returns the waterfall view of spans for a given trace ID with tree
|
||||
structure, metadata, and windowed pagination
|
||||
operationId: GetWaterfall
|
||||
description: Returns the flamegraph view of spans for a given trace ID.
|
||||
operationId: GetFlamegraph
|
||||
parameters:
|
||||
- in: path
|
||||
name: traceID
|
||||
@@ -20551,7 +20649,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpantypesPostableWaterfall'
|
||||
$ref: '#/components/schemas/SpantypesPostableFlamegraph'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -20559,7 +20657,7 @@ paths:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableWaterfallTrace'
|
||||
$ref: '#/components/schemas/SpantypesGettableFlamegraphTrace'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
@@ -20602,7 +20700,7 @@ paths:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get waterfall view for a trace
|
||||
summary: Get flamegraph view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v4/traces/{traceID}/waterfall:
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/http/client"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
@@ -26,13 +28,14 @@ var defaultScopes []string = []string{"email", "profile", oidc.ScopeOpenID}
|
||||
var _ authn.CallbackAuthN = (*AuthN)(nil)
|
||||
|
||||
type AuthN struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
store authtypes.AuthNStore
|
||||
licensing licensing.Licensing
|
||||
httpClient *client.Client
|
||||
settings factory.ScopedProviderSettings
|
||||
store authtypes.AuthNStore
|
||||
licensing licensing.Licensing
|
||||
httpClient *client.Client
|
||||
globalConfig global.Config
|
||||
}
|
||||
|
||||
func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings) (*AuthN, error) {
|
||||
func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings, globalConfig global.Config) (*AuthN, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn")
|
||||
|
||||
httpClient, err := client.New(providerSettings.Logger, providerSettings.TracerProvider, providerSettings.MeterProvider)
|
||||
@@ -41,15 +44,16 @@ func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSett
|
||||
}
|
||||
|
||||
return &AuthN{
|
||||
settings: settings,
|
||||
store: store,
|
||||
licensing: licensing,
|
||||
httpClient: httpClient,
|
||||
settings: settings,
|
||||
store: store,
|
||||
licensing: licensing,
|
||||
httpClient: httpClient,
|
||||
globalConfig: globalConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
|
||||
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderOIDC {
|
||||
if authDomain.AuthDomainConfig().Provider.Type != authtypes.AuthNProviderOIDC {
|
||||
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "domain type is not oidc")
|
||||
}
|
||||
|
||||
@@ -102,14 +106,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims == nil && authDomain.AuthDomainConfig().OIDC.GetUserInfo {
|
||||
if claims == nil && authDomain.AuthDomainConfig().Oidc().GetUserInfo {
|
||||
claims, err = a.claimsFromUserInfo(ctx, oidcProvider, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
emailClaim, ok := claims[authDomain.AuthDomainConfig().OIDC.ClaimMapping.Email].(string)
|
||||
emailClaim, ok := claims[authDomain.AuthDomainConfig().Oidc().ClaimMapping.Email].(string)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email in claims")
|
||||
}
|
||||
@@ -119,7 +123,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: failed to parse email").WithAdditional(err.Error())
|
||||
}
|
||||
|
||||
if !authDomain.AuthDomainConfig().OIDC.InsecureSkipEmailVerified {
|
||||
if !authDomain.AuthDomainConfig().Oidc().InsecureSkipEmailVerified {
|
||||
emailVerifiedClaim, ok := claims["email_verified"].(bool)
|
||||
if !ok {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "oidc: missing email_verified in claims")
|
||||
@@ -131,14 +135,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
}
|
||||
|
||||
name := ""
|
||||
if nameClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Name; nameClaim != "" {
|
||||
if nameClaim := authDomain.AuthDomainConfig().Oidc().ClaimMapping.Name; nameClaim != "" {
|
||||
if n, ok := claims[nameClaim].(string); ok {
|
||||
name = n
|
||||
}
|
||||
}
|
||||
|
||||
var groups []string
|
||||
if groupsClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Groups; groupsClaim != "" {
|
||||
if groupsClaim := authDomain.AuthDomainConfig().Oidc().ClaimMapping.Groups; groupsClaim != "" {
|
||||
if claimValue, exists := claims[groupsClaim]; exists {
|
||||
switch g := claimValue.(type) {
|
||||
case []any:
|
||||
@@ -157,7 +161,7 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
|
||||
}
|
||||
|
||||
role := ""
|
||||
if roleClaim := authDomain.AuthDomainConfig().OIDC.ClaimMapping.Role; roleClaim != "" {
|
||||
if roleClaim := authDomain.AuthDomainConfig().Oidc().ClaimMapping.Role; roleClaim != "" {
|
||||
if r, ok := claims[roleClaim].(string); ok {
|
||||
role = r
|
||||
}
|
||||
@@ -173,11 +177,11 @@ func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDoma
|
||||
}
|
||||
|
||||
func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (*oidc.Provider, *oauth2.Config, error) {
|
||||
if authDomain.AuthDomainConfig().OIDC.IssuerAlias != "" {
|
||||
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().OIDC.IssuerAlias)
|
||||
if authDomain.AuthDomainConfig().Oidc().IssuerAlias != "" {
|
||||
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().Oidc().IssuerAlias)
|
||||
}
|
||||
|
||||
oidcProvider, err := oidc.NewProvider(ctx, authDomain.AuthDomainConfig().OIDC.Issuer)
|
||||
oidcProvider, err := oidc.NewProvider(ctx, authDomain.AuthDomainConfig().Oidc().Issuer)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -190,14 +194,14 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
|
||||
}
|
||||
|
||||
return oidcProvider, &oauth2.Config{
|
||||
ClientID: authDomain.AuthDomainConfig().OIDC.ClientID,
|
||||
ClientSecret: authDomain.AuthDomainConfig().OIDC.ClientSecret,
|
||||
ClientID: authDomain.AuthDomainConfig().Oidc().ClientID,
|
||||
ClientSecret: authDomain.AuthDomainConfig().Oidc().ClientSecret,
|
||||
Endpoint: oidcProvider.Endpoint(),
|
||||
Scopes: scopes,
|
||||
RedirectURL: (&url.URL{
|
||||
Scheme: siteURL.Scheme,
|
||||
Host: siteURL.Host,
|
||||
Path: redirectPath,
|
||||
Path: path.Join(a.globalConfig.ExternalPath(), redirectPath),
|
||||
}).String(),
|
||||
}, nil
|
||||
}
|
||||
@@ -208,7 +212,7 @@ func (a *AuthN) claimsFromIDToken(ctx context.Context, authDomain *authtypes.Aut
|
||||
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "oidc: no id_token in token response")
|
||||
}
|
||||
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().OIDC.ClientID})
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: authDomain.AuthDomainConfig().Oidc().ClientID})
|
||||
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "oidc: failed to verify token").WithAdditional(err.Error())
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -24,19 +26,21 @@ const (
|
||||
var _ authn.CallbackAuthN = (*AuthN)(nil)
|
||||
|
||||
type AuthN struct {
|
||||
store authtypes.AuthNStore
|
||||
licensing licensing.Licensing
|
||||
store authtypes.AuthNStore
|
||||
licensing licensing.Licensing
|
||||
globalConfig global.Config
|
||||
}
|
||||
|
||||
func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Licensing) (*AuthN, error) {
|
||||
func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Licensing, globalConfig global.Config) (*AuthN, error) {
|
||||
return &AuthN{
|
||||
store: store,
|
||||
licensing: licensing,
|
||||
store: store,
|
||||
licensing: licensing,
|
||||
globalConfig: globalConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AuthN) LoginURL(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (string, error) {
|
||||
if authDomain.AuthDomainConfig().AuthNProvider != authtypes.AuthNProviderSAML {
|
||||
if authDomain.AuthDomainConfig().Provider.Type != authtypes.AuthNProviderSAML {
|
||||
return "", errors.Newf(errors.TypeInternal, authtypes.ErrCodeAuthDomainMismatch, "saml: domain type is not saml")
|
||||
}
|
||||
|
||||
@@ -97,19 +101,19 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
|
||||
}
|
||||
|
||||
name := ""
|
||||
if nameAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Name; nameAttribute != "" {
|
||||
if nameAttribute := authDomain.AuthDomainConfig().Saml().AttributeMapping.Name; nameAttribute != "" {
|
||||
if val := assertionInfo.Values.Get(nameAttribute); val != "" {
|
||||
name = val
|
||||
}
|
||||
}
|
||||
|
||||
var groups []string
|
||||
if groupAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Groups; groupAttribute != "" {
|
||||
if groupAttribute := authDomain.AuthDomainConfig().Saml().AttributeMapping.Groups; groupAttribute != "" {
|
||||
groups = assertionInfo.Values.GetAll(groupAttribute)
|
||||
}
|
||||
|
||||
role := ""
|
||||
if roleAttribute := authDomain.AuthDomainConfig().SAML.AttributeMapping.Role; roleAttribute != "" {
|
||||
if roleAttribute := authDomain.AuthDomainConfig().Saml().AttributeMapping.Role; roleAttribute != "" {
|
||||
if val := assertionInfo.Values.Get(roleAttribute); val != "" {
|
||||
role = val
|
||||
}
|
||||
@@ -132,17 +136,17 @@ func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDoma
|
||||
return nil, err
|
||||
}
|
||||
|
||||
acsURL := &url.URL{Scheme: siteURL.Scheme, Host: siteURL.Host, Path: redirectPath}
|
||||
acsURL := &url.URL{Scheme: siteURL.Scheme, Host: siteURL.Host, Path: path.Join(a.globalConfig.ExternalPath(), redirectPath)}
|
||||
|
||||
// Note:
|
||||
// The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak.
|
||||
// For AWSSSO, this is the value of Application SAML audience.
|
||||
return &saml2.SAMLServiceProvider{
|
||||
IdentityProviderSSOURL: authDomain.AuthDomainConfig().SAML.SamlIdp,
|
||||
IdentityProviderIssuer: authDomain.AuthDomainConfig().SAML.SamlEntity,
|
||||
IdentityProviderSSOURL: authDomain.AuthDomainConfig().Saml().SamlIdp,
|
||||
IdentityProviderIssuer: authDomain.AuthDomainConfig().Saml().SamlEntity,
|
||||
ServiceProviderIssuer: siteURL.Host,
|
||||
AssertionConsumerServiceURL: acsURL.String(),
|
||||
SignAuthnRequests: !authDomain.AuthDomainConfig().SAML.InsecureSkipAuthNRequestsSigned,
|
||||
SignAuthnRequests: !authDomain.AuthDomainConfig().Saml().InsecureSkipAuthNRequestsSigned,
|
||||
AllowMissingAttributes: true,
|
||||
IDPCertificateStore: certStore,
|
||||
SPKeyStore: dsig.RandomKeyStoreForTest(),
|
||||
@@ -155,15 +159,15 @@ func (a *AuthN) getCertificateStore(authDomain *authtypes.AuthDomain) (dsig.X509
|
||||
}
|
||||
|
||||
var certBytes []byte
|
||||
if strings.Contains(authDomain.AuthDomainConfig().SAML.SamlCert, "-----BEGIN CERTIFICATE-----") {
|
||||
block, _ := pem.Decode([]byte(authDomain.AuthDomainConfig().SAML.SamlCert))
|
||||
if strings.Contains(authDomain.AuthDomainConfig().Saml().SamlCert, "-----BEGIN CERTIFICATE-----") {
|
||||
block, _ := pem.Decode([]byte(authDomain.AuthDomainConfig().Saml().SamlCert))
|
||||
if block == nil {
|
||||
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "no valid pem cert found")
|
||||
}
|
||||
|
||||
certBytes = block.Bytes
|
||||
} else {
|
||||
certData, err := base64.StdEncoding.DecodeString(authDomain.AuthDomainConfig().SAML.SamlCert)
|
||||
certData, err := base64.StdEncoding.DecodeString(authDomain.AuthDomainConfig().Saml().SamlCert)
|
||||
if err != nil {
|
||||
return certStore, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to read certificate: %s", err.Error())
|
||||
}
|
||||
|
||||
@@ -41,6 +41,15 @@ if (typeof window.IntersectionObserver === 'undefined') {
|
||||
(window as any).IntersectionObserver = IntersectionObserverMock;
|
||||
}
|
||||
|
||||
if (typeof window.ResizeObserver === 'undefined') {
|
||||
class ResizeObserverMock {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
(window as any).ResizeObserver = ResizeObserverMock;
|
||||
}
|
||||
|
||||
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
|
||||
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
|
||||
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),
|
||||
|
||||
@@ -5,6 +5,13 @@ import convertObjectIntoParams from 'lib/query/convertObjectIntoParams';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/alerts/getTriggered';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useGetAlerts` hook (or `getAlerts` fetcher) from
|
||||
* `api/generated/services/alerts` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const getTriggered = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createEmail';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const create = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createMsTeams';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const create = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createOpsgenie';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const create = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createPager';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const create = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createSlack';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const create = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/createWebhook';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreateChannel` hook (or `createChannel` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const create = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/delete';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useDeleteChannelByID` hook (or `deleteChannelByID` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const deleteChannel = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/editEmail';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const editEmail = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/editMsTeams';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const editMsTeams = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/editOpsgenie';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const editOpsgenie = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps> | ErrorResponse> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/editPager';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const editPager = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/editSlack';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const editSlack = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/editWebhook';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useUpdateChannelByID` hook (or `updateChannelByID` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const editWebhook = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
|
||||
@@ -5,6 +5,13 @@ import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/channels/get';
|
||||
import { Channels } from 'types/api/channels/getAll';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useGetChannelByID` hook (or `getChannelByID` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const get = async (props: Props): Promise<SuccessResponseV2<Channels>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/channels/${props.id}`);
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { Channels, PayloadProps } from 'types/api/channels/getAll';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useListChannels` hook (or `listChannels` fetcher) from
|
||||
* `api/generated/services/channels` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const getAll = async (): Promise<SuccessResponseV2<Channels[]>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>('/channels');
|
||||
|
||||
@@ -5,6 +5,13 @@ import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constan
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { CreatePublicDashboardProps } from 'types/api/dashboard/public/create';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreatePublicDashboard` hook (or `createPublicDashboard` fetcher) from
|
||||
* `api/generated/services/dashboard` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const createPublicDashboard = async (
|
||||
props: CreatePublicDashboardProps,
|
||||
): Promise<SuccessResponseV2<CreatePublicDashboardProps>> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { GetPublicDashboardDataProps, PayloadProps,PublicDashboardDataProps } from 'types/api/dashboard/public/get';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useGetPublicDashboardData` hook (or `getPublicDashboardData` fetcher) from
|
||||
* `api/generated/services/dashboard` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const getPublicDashboardData = async (props: GetPublicDashboardDataProps): Promise<SuccessResponseV2<PublicDashboardDataProps>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/public/dashboards/${props.id}`);
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { GetPublicDashboardMetaProps, PayloadProps,PublicDashboardMetaProps } from 'types/api/dashboard/public/getMeta';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useGetPublicDashboard` hook (or `getPublicDashboard` fetcher) from
|
||||
* `api/generated/services/dashboard` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const getPublicDashboardMeta = async (props: GetPublicDashboardMetaProps): Promise<SuccessResponseV2<PublicDashboardMetaProps>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/dashboards/${props.id}/public`);
|
||||
|
||||
@@ -6,6 +6,13 @@ import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { GetPublicDashboardWidgetDataProps } from 'types/api/dashboard/public/getWidgetData';
|
||||
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useGetPublicDashboardWidgetQueryRange` hook (or `getPublicDashboardWidgetQueryRange` fetcher) from
|
||||
* `api/generated/services/dashboard` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const getPublicDashboardWidgetData = async (props: GetPublicDashboardWidgetDataProps): Promise<SuccessResponseV2<MetricRangePayloadV5>> => {
|
||||
try {
|
||||
const response = await axios.get(`/public/dashboards/${props.id}/widgets/${props.index}/query_range`, {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps,RevokePublicDashboardAccessProps } from 'types/api/dashboard/public/delete';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useDeletePublicDashboard` hook (or `deletePublicDashboard` fetcher) from
|
||||
* `api/generated/services/dashboard` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const revokePublicDashboardAccess = async (
|
||||
props: RevokePublicDashboardAccessProps,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
|
||||
@@ -5,6 +5,13 @@ import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constan
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { UpdatePublicDashboardProps } from 'types/api/dashboard/public/update';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useUpdatePublicDashboard` hook (or `updatePublicDashboard` fetcher) from
|
||||
* `api/generated/services/dashboard` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const updatePublicDashboard = async (
|
||||
props: UpdatePublicDashboardProps,
|
||||
): Promise<SuccessResponseV2<UpdatePublicDashboardProps>> => {
|
||||
|
||||
@@ -9,6 +9,13 @@ interface ISubstituteVars {
|
||||
compositeQuery: ICompositeMetricQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useReplaceVariables` hook (or `replaceVariables` fetcher) from
|
||||
* `api/generated/services/querier` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
export const getSubstituteVars = async (
|
||||
props?: Partial<QueryRangePayloadV5>,
|
||||
signal?: AbortSignal,
|
||||
|
||||
@@ -8,6 +8,12 @@ import { FieldKeyResponse } from 'types/api/dynamicVariables/getFieldKeys';
|
||||
* Get field keys for a given signal type
|
||||
* @param signal Type of signal (traces, logs, metrics)
|
||||
* @param name Optional search text
|
||||
*
|
||||
* @deprecated Use the generated `useGetFieldsKeys` hook (or `getFieldsKeys` fetcher) from
|
||||
* `api/generated/services/fields` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
export const getFieldKeys = async (
|
||||
signal?: 'traces' | 'logs' | 'metrics',
|
||||
|
||||
@@ -11,6 +11,12 @@ import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
|
||||
* @param name Name of the attribute for which values are being fetched
|
||||
* @param value Optional search text
|
||||
* @param existingQuery Optional existing query - across all present dynamic variables
|
||||
*
|
||||
* @deprecated Use the generated `useGetFieldsValues` hook (or `getFieldsValues` fetcher) from
|
||||
* `api/generated/services/fields` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
export const getFieldValues = async (
|
||||
signal?: 'traces' | 'logs' | 'metrics',
|
||||
|
||||
@@ -2651,6 +2651,10 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
sqs = 'sqs',
|
||||
storageaccountsblob = 'storageaccountsblob',
|
||||
cdnprofile = 'cdnprofile',
|
||||
virtualmachine = 'virtualmachine',
|
||||
appservice = 'appservice',
|
||||
containerapp = 'containerapp',
|
||||
aks = 'aks',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
@@ -7769,6 +7773,77 @@ export enum SpantypesFieldContextDTO {
|
||||
attribute = 'attribute',
|
||||
resource = 'resource',
|
||||
}
|
||||
export type SpantypesFlamegraphSpanDTOAttributes = { [key: string]: unknown };
|
||||
|
||||
export type SpantypesFlamegraphSpanDTOResource = { [key: string]: string };
|
||||
|
||||
export interface SpantypesFlamegraphSpanDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
attributes: SpantypesFlamegraphSpanDTOAttributes;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
durationNano: number;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
event: SpantypesEventDTO[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasError: boolean;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
level: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
parentSpanId: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
resource: SpantypesFlamegraphSpanDTOResource;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
spanId: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableFlamegraphTraceDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
endTimestampMillis: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMore: boolean;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
spans: SpantypesFlamegraphSpanDTO[][];
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
startTimestampMillis: number;
|
||||
}
|
||||
|
||||
export type SpantypesSpanMapperGroupConditionDTOAnyOf = {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -8020,10 +8095,6 @@ export interface SpantypesWaterfallSpanDTO {
|
||||
}
|
||||
|
||||
export interface SpantypesGettableWaterfallTraceDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: SpantypesSpanAggregationResultDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
@@ -8070,6 +8141,17 @@ export interface SpantypesGettableWaterfallTraceDTO {
|
||||
uncollapsedSpans?: string[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableFlamegraphDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
selectedSpanId?: string;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOperationDTO {
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
@@ -8132,15 +8214,6 @@ export interface SpantypesPostableTraceAggregationsDTO {
|
||||
}
|
||||
|
||||
export interface SpantypesPostableWaterfallDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: SpantypesSpanAggregationDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -10424,11 +10497,11 @@ export type GetHosts200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetWaterfallPathParameters = {
|
||||
export type GetFlamegraphPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetWaterfall200 = {
|
||||
data: SpantypesGettableWaterfallTraceDTO;
|
||||
export type GetFlamegraph200 = {
|
||||
data: SpantypesGettableFlamegraphTraceDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -12,13 +12,14 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
GetFlamegraph200,
|
||||
GetFlamegraphPathParameters,
|
||||
GetTraceAggregations200,
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetWaterfall200,
|
||||
GetWaterfallPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableFlamegraphDTO,
|
||||
SpantypesPostableTraceAggregationsDTO,
|
||||
SpantypesPostableWaterfallDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -127,46 +128,46 @@ export const useGetTraceAggregations = <
|
||||
return useMutation(getGetTraceAggregationsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination
|
||||
* @summary Get waterfall view for a trace
|
||||
* Returns the flamegraph view of spans for a given trace ID.
|
||||
* @summary Get flamegraph view for a trace
|
||||
*/
|
||||
export const getWaterfall = (
|
||||
{ traceID }: GetWaterfallPathParameters,
|
||||
spantypesPostableWaterfallDTO?: BodyType<SpantypesPostableWaterfallDTO>,
|
||||
export const getFlamegraph = (
|
||||
{ traceID }: GetFlamegraphPathParameters,
|
||||
spantypesPostableFlamegraphDTO?: BodyType<SpantypesPostableFlamegraphDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetWaterfall200>({
|
||||
url: `/api/v3/traces/${traceID}/waterfall`,
|
||||
return GeneratedAPIInstance<GetFlamegraph200>({
|
||||
url: `/api/v3/traces/${traceID}/flamegraph`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableWaterfallDTO,
|
||||
data: spantypesPostableFlamegraphDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetWaterfallMutationOptions = <
|
||||
export const getGetFlamegraphMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfall>>,
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfall>>,
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['getWaterfall'];
|
||||
const mutationKey = ['getFlamegraph'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
@@ -176,54 +177,54 @@ export const getGetWaterfallMutationOptions = <
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof getWaterfall>>,
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return getWaterfall(pathParams, data);
|
||||
return getFlamegraph(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type GetWaterfallMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getWaterfall>>
|
||||
export type GetFlamegraphMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>
|
||||
>;
|
||||
export type GetWaterfallMutationBody =
|
||||
| BodyType<SpantypesPostableWaterfallDTO>
|
||||
export type GetFlamegraphMutationBody =
|
||||
| BodyType<SpantypesPostableFlamegraphDTO>
|
||||
| undefined;
|
||||
export type GetWaterfallMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
export type GetFlamegraphMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get waterfall view for a trace
|
||||
* @summary Get flamegraph view for a trace
|
||||
*/
|
||||
export const useGetWaterfall = <
|
||||
export const useGetFlamegraph = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfall>>,
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof getWaterfall>>,
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallPathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getGetWaterfallMutationOptions(options));
|
||||
return useMutation(getGetFlamegraphMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3
|
||||
|
||||
@@ -5,6 +5,13 @@ import {
|
||||
QueryKeySuggestionsResponseProps,
|
||||
} from 'types/api/querySuggestions/types';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useGetFieldsKeys` hook (or `getFieldsKeys` fetcher) from
|
||||
* `api/generated/services/fields` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
export const getKeySuggestions = (
|
||||
props: QueryKeyRequestProps,
|
||||
): Promise<AxiosResponse<QueryKeySuggestionsResponseProps>> => {
|
||||
|
||||
@@ -5,6 +5,13 @@ import {
|
||||
QueryKeyValueSuggestionsResponseProps,
|
||||
} from 'types/api/querySuggestions/types';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useGetFieldsValues` hook (or `getFieldsValues` fetcher) from
|
||||
* `api/generated/services/fields` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
export const getValueSuggestions = (
|
||||
props: QueryKeyValueRequestProps,
|
||||
): Promise<AxiosResponse<QueryKeyValueSuggestionsResponseProps>> => {
|
||||
|
||||
@@ -15,6 +15,13 @@ export interface CreateRoutingPolicyResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreateRoutePolicy` hook (or `createRoutePolicy` fetcher) from
|
||||
* `api/generated/services/routepolicies` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const createRoutingPolicy = async (
|
||||
props: CreateRoutingPolicyBody,
|
||||
): Promise<
|
||||
|
||||
@@ -8,6 +8,13 @@ export interface DeleteRoutingPolicyResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useDeleteRoutePolicyByID` hook (or `deleteRoutePolicyByID` fetcher) from
|
||||
* `api/generated/services/routepolicies` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const deleteRoutingPolicy = async (
|
||||
routingPolicyId: string,
|
||||
): Promise<
|
||||
|
||||
@@ -20,6 +20,13 @@ export interface GetRoutingPoliciesResponse {
|
||||
data?: ApiRoutingPolicy[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useGetAllRoutePolicies` hook (or `getAllRoutePolicies` fetcher) from
|
||||
* `api/generated/services/routepolicies` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
export const getRoutingPolicies = async (
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
|
||||
@@ -15,6 +15,13 @@ export interface UpdateRoutingPolicyResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useUpdateRoutePolicy` hook (or `updateRoutePolicy` fetcher) from
|
||||
* `api/generated/services/routepolicies` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const updateRoutingPolicy = async (
|
||||
id: string,
|
||||
props: UpdateRoutingPolicyBody,
|
||||
|
||||
@@ -27,7 +27,6 @@ const getTraceV4 = async (
|
||||
{
|
||||
selectedSpanId: props.selectedSpanId,
|
||||
uncollapsedSpans,
|
||||
limit: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/user/resetPassword';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useResetPassword` hook (or `resetPassword` fetcher) from
|
||||
* `api/generated/services/users` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const resetPassword = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { UsersProps } from 'types/api/user/inviteUsers';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreateBulkInvite` hook (or `createBulkInvite` fetcher) from
|
||||
* `api/generated/services/users` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const inviteUsers = async (
|
||||
users: UsersProps,
|
||||
): Promise<SuccessResponseV2<null>> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/user/setInvite';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreateInvite` hook (or `createInvite` fetcher) from
|
||||
* `api/generated/services/users` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const sendInvite = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<PayloadProps>> => {
|
||||
|
||||
@@ -5,6 +5,13 @@ import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps } from 'types/api/preferences/list';
|
||||
import { OrgPreference } from 'types/api/preferences/preference';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useListOrgPreferences` hook (or `listOrgPreferences` fetcher) from
|
||||
* `api/generated/services/preferences` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const listPreference = async (): Promise<
|
||||
SuccessResponseV2<OrgPreference[]>
|
||||
> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { Props } from 'types/api/preferences/update';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useUpdateOrgPreference` hook (or `updateOrgPreference` fetcher) from
|
||||
* `api/generated/services/preferences` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const update = async (props: Props): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.put(`/org/preferences/${props.name}`, {
|
||||
|
||||
@@ -5,6 +5,13 @@ import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps } from 'types/api/preferences/list';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useListUserPreferences` hook (or `listUserPreferences` fetcher) from
|
||||
* `api/generated/services/preferences` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const list = async (): Promise<SuccessResponseV2<UserPreference[]>> => {
|
||||
try {
|
||||
const response = await axios.get<PayloadProps>(`/user/preferences`);
|
||||
|
||||
@@ -5,6 +5,13 @@ import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/preferences/get';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useGetUserPreference` hook (or `getUserPreference` fetcher) from
|
||||
* `api/generated/services/preferences` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const get = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<UserPreference>> => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { Props } from 'types/api/preferences/update';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useUpdateUserPreference` hook (or `updateUserPreference` fetcher) from
|
||||
* `api/generated/services/preferences` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const update = async (props: Props): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.put(`/user/preferences/${props.name}`, {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { Props, SessionsContext } from 'types/api/v2/sessions/context/get';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useGetSessionContext` hook (or `getSessionContext` fetcher) from
|
||||
* `api/generated/services/sessions` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const get = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponseV2<SessionsContext>> => {
|
||||
|
||||
@@ -3,6 +3,13 @@ import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useDeleteSession` hook (or `deleteSession` fetcher) from
|
||||
* `api/generated/services/sessions` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const deleteSession = async (): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.delete<RawSuccessResponse<null>>('/sessions');
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { Props, Token } from 'types/api/v2/sessions/email_password/post';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useCreateSessionByEmailPassword` hook (or `createSessionByEmailPassword` fetcher) from
|
||||
* `api/generated/services/sessions` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const post = async (props: Props): Promise<SuccessResponseV2<Token>> => {
|
||||
try {
|
||||
const response = await axios.post<RawSuccessResponse<Token>>(
|
||||
|
||||
@@ -4,6 +4,13 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { Props, Token } from 'types/api/v2/sessions/rotate/post';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useRotateSession` hook (or `rotateSession` fetcher) from
|
||||
* `api/generated/services/sessions` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
const post = async (props: Props): Promise<SuccessResponseV2<Token>> => {
|
||||
try {
|
||||
const response = await axios.post<RawSuccessResponse<Token>>(
|
||||
|
||||
@@ -8,6 +8,13 @@ import {
|
||||
QueryRangePayloadV5,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
* @deprecated Use the generated `useQueryRangeV5` hook (or `queryRangeV5` fetcher) from
|
||||
* `api/generated/services/querier` instead. This hand-written client targets the
|
||||
* same endpoint and will be removed once call sites migrate.
|
||||
*
|
||||
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
|
||||
*/
|
||||
export const getQueryRangeV5 = async (
|
||||
props: QueryRangePayloadV5,
|
||||
version: string,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// TODO: Improve the styling of the query aggregation container and its components. - @YounixM , @H4ad
|
||||
|
||||
$dropdown-base-height: 250px;
|
||||
$recents-header-height: 30px;
|
||||
$recent-row-height: 36px;
|
||||
// how many recents are rendered, this caps how tall the dropdown can grow to fit them.
|
||||
$max-recents-shown: 5;
|
||||
|
||||
.code-mirror-where-clause {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -117,7 +123,23 @@
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
min-height: 200px !important;
|
||||
max-height: $dropdown-base-height !important;
|
||||
overflow-y: auto !important;
|
||||
|
||||
// Recents render at the top of the dropdown ahead of regular suggestions.
|
||||
// At the base max-height, having recents in view would crowd out the
|
||||
// suggestion list below. This loop grows the dropdown by one row's worth
|
||||
// of height for every recent present (up to $max-recents-shown), plus the
|
||||
// section header. `:has(> li:nth-of-type(N) .cm-completionIcon-recent)`
|
||||
// matches when the Nth child of <ul> is a recent — i.e. there are at
|
||||
// least N recents visible.
|
||||
@for $i from 1 through $max-recents-shown {
|
||||
&:has(> li:nth-of-type(#{$i}) .cm-completionIcon-recent) {
|
||||
max-height: $dropdown-base-height +
|
||||
$recents-header-height +
|
||||
($i * $recent-row-height) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
@@ -133,6 +155,19 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
completion-section {
|
||||
display: block;
|
||||
padding: 10px 12px 6px;
|
||||
font-size: 10px !important;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l3-foreground, var(--l2-foreground));
|
||||
opacity: 0.7;
|
||||
border-bottom: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
li {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
@@ -159,11 +194,78 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cm-completionDetail {
|
||||
margin-left: auto;
|
||||
font-style: normal;
|
||||
font-size: var(--periscope-font-size-small, 11px);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
&[aria-selected='true'] {
|
||||
background: var(--l3-background) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
}
|
||||
|
||||
li:has(.cm-completionIcon-recent) {
|
||||
&:hover .cm-recent-delete,
|
||||
&[aria-selected='true'] .cm-recent-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
li .cm-completionLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cm-recent-delete {
|
||||
margin-left: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition:
|
||||
opacity 0.12s ease,
|
||||
background-color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--l2-foreground) 18%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '↓↑ to navigate · ↵ to apply · esc to dismiss';
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--l2-foreground);
|
||||
opacity: 0.6;
|
||||
background: color-mix(in srgb, var(--l1-background) 50%, transparent);
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,15 @@ import {
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
import { unquote } from 'utils/stringUtils';
|
||||
|
||||
import { queryExamples } from './constants';
|
||||
import { combineInitialAndUserExpression } from './utils';
|
||||
import { getRecentQueries } from 'lib/recentQueries/getRecentQueries';
|
||||
import type { SignalType } from 'types/api/v5/queryRange';
|
||||
|
||||
import { queryExamples, SUGGESTIONS_SECTION } from './constants';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
getRecentOptions,
|
||||
renderRecentDeleteButton,
|
||||
} from './utils';
|
||||
|
||||
import './QuerySearch.styles.scss';
|
||||
|
||||
@@ -1250,6 +1257,41 @@ function QuerySearch({
|
||||
};
|
||||
}
|
||||
|
||||
const signal = dataSource as SignalType;
|
||||
|
||||
function combinedSuggestions(
|
||||
context: CompletionContext,
|
||||
): CompletionResult | null {
|
||||
const fullDoc = context.state.doc.toString();
|
||||
const recentOptions = getRecentOptions(
|
||||
getRecentQueries(signal, signalSource ?? ''),
|
||||
fullDoc,
|
||||
);
|
||||
const result = autoSuggestions(context);
|
||||
|
||||
const suggestionOptions = (result?.options || []).map((opt) => ({
|
||||
...opt,
|
||||
section: SUGGESTIONS_SECTION,
|
||||
}));
|
||||
|
||||
if (recentOptions.length === 0 && suggestionOptions.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return {
|
||||
from: 0,
|
||||
to: fullDoc.length,
|
||||
options: recentOptions,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
options: [...recentOptions, ...suggestionOptions],
|
||||
};
|
||||
}
|
||||
|
||||
// Effect to handle focus state and trigger suggestions
|
||||
useEffect(() => {
|
||||
const clearTimeout = toggleSuggestions(10);
|
||||
@@ -1398,11 +1440,12 @@ function QuerySearch({
|
||||
})}
|
||||
extensions={[
|
||||
autocompletion({
|
||||
override: [autoSuggestions],
|
||||
override: [combinedSuggestions],
|
||||
defaultKeymap: true,
|
||||
closeOnBlur: true,
|
||||
activateOnTyping: true,
|
||||
maxRenderedOptions: 50,
|
||||
addToOptions: [{ render: renderRecentDeleteButton, position: 100 }],
|
||||
}),
|
||||
javascript({ jsx: false, typescript: false }),
|
||||
EditorView.lineWrapping,
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
export const RECENTS_SECTION = { name: 'Recent searches', rank: 1 } as const;
|
||||
export const SUGGESTIONS_SECTION = { name: 'Suggestions', rank: 2 } as const;
|
||||
|
||||
// Custom CodeMirror Completion.type for recent-query entries. Used to discriminate
|
||||
// recents from regular autocomplete completions in renderers and event handlers.
|
||||
export const RECENT_COMPLETION_TYPE = 'recent';
|
||||
|
||||
// Max number of recents rendered in the autocomplete dropdown.
|
||||
// Do change $max-recents-shown: in QuerySearch.styles.scss if you change this.
|
||||
export const RECENTS_DISPLAY_CAP = 5;
|
||||
|
||||
export const queryExamples = [
|
||||
{
|
||||
label: 'Basic Query',
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
import { closeCompletion, startCompletion } from '@codemirror/autocomplete';
|
||||
import type { Completion } from '@codemirror/autocomplete';
|
||||
import type { EditorView } from '@uiw/react-codemirror';
|
||||
import dayjs from 'dayjs';
|
||||
import { normalizeFilterExpression } from 'lib/recentQueries/normalize';
|
||||
import * as recentQueriesStore from 'lib/recentQueries/recentQueriesStore';
|
||||
import type { RecentQueryEntry } from 'lib/recentQueries/types';
|
||||
import type { SignalType } from 'types/api/v5/queryRange';
|
||||
import 'utils/timeUtils';
|
||||
|
||||
import {
|
||||
RECENT_COMPLETION_TYPE,
|
||||
RECENTS_DISPLAY_CAP,
|
||||
RECENTS_SECTION,
|
||||
} from './constants';
|
||||
|
||||
export function combineInitialAndUserExpression(
|
||||
initial: string,
|
||||
user: string,
|
||||
@@ -38,3 +54,106 @@ export function getUserExpressionFromCombined(
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// Filters and projects a list of recent-query entries into CodeMirror completions.
|
||||
// Entries are supplied by the caller (typically via the useRecents hook) so this
|
||||
// function stays pure and React doesn't have to re-subscribe inside CodeMirror's
|
||||
// autocomplete callback.
|
||||
export function getRecentOptions(
|
||||
entries: RecentQueryEntry[],
|
||||
fullDoc: string,
|
||||
): Completion[] {
|
||||
const normalizedDoc = normalizeFilterExpression(fullDoc);
|
||||
|
||||
const matches = entries
|
||||
.filter((e) => {
|
||||
const normalizedRecent = normalizeFilterExpression(e.filter.expression);
|
||||
if (normalizedRecent === normalizedDoc) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedDoc === '') {
|
||||
return true;
|
||||
}
|
||||
return normalizedRecent.includes(normalizedDoc);
|
||||
})
|
||||
.slice(0, RECENTS_DISPLAY_CAP);
|
||||
|
||||
return matches.map((entry, index) => ({
|
||||
label: entry.filter.expression,
|
||||
type: RECENT_COMPLETION_TYPE,
|
||||
// CodeMirror sorts within a section by boost desc, then label asc. The store
|
||||
// returns entries newest-first, so we mirror that by giving the newest entry
|
||||
// the highest boost — otherwise CM falls back to alphabetical order and the
|
||||
// "most recently used" expectation breaks. Stays within the recents section
|
||||
// because section.rank keeps recents above suggestions regardless of boost.
|
||||
boost: matches.length - index,
|
||||
section: RECENTS_SECTION,
|
||||
detail: dayjs(entry.lastUsedAt).fromNow(),
|
||||
recentId: entry.id,
|
||||
recentSignal: entry.signal,
|
||||
recentSource: entry.source,
|
||||
apply: (view: EditorView): void => {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: entry.filter.expression,
|
||||
},
|
||||
selection: { anchor: entry.filter.expression.length },
|
||||
});
|
||||
closeCompletion(view);
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function renderRecentDeleteButton(
|
||||
completion: Completion,
|
||||
_state: unknown,
|
||||
view: EditorView | null,
|
||||
): Node | null {
|
||||
if (completion.type !== RECENT_COMPLETION_TYPE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const c = completion as Completion & {
|
||||
recentId?: string;
|
||||
recentSignal?: SignalType;
|
||||
recentSource?: string;
|
||||
};
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'cm-recent-delete';
|
||||
btn.setAttribute('aria-label', 'Remove from recent searches');
|
||||
btn.title = 'Remove from recent searches';
|
||||
btn.textContent = '×';
|
||||
queueMicrotask(() => {
|
||||
if (btn.parentElement) {
|
||||
btn.parentElement.title = completion.label;
|
||||
}
|
||||
});
|
||||
|
||||
const stop = (e: Event): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
// CodeMirror's autocomplete closes the popup on pointerdown / mousedown outside
|
||||
// the editor. The delete button lives inside the popup, so we must stop those
|
||||
// events early — otherwise clicking × would dismiss the dropdown before the
|
||||
// click handler fires and the entry wouldn't actually get removed.
|
||||
btn.addEventListener('pointerdown', stop);
|
||||
btn.addEventListener('mousedown', stop);
|
||||
btn.addEventListener('click', (e) => {
|
||||
stop(e);
|
||||
if (!c.recentId || !c.recentSignal) {
|
||||
return;
|
||||
}
|
||||
recentQueriesStore.remove(c.recentId, c.recentSignal, c.recentSource ?? '');
|
||||
if (view) {
|
||||
view.focus();
|
||||
startCompletion(view);
|
||||
}
|
||||
});
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export const AIAssistantOpenSource = {
|
||||
Icon: 'icon',
|
||||
Shortcut: 'shortcut',
|
||||
Cmdk: 'cmdk',
|
||||
TraceDetails: 'trace_details',
|
||||
} as const;
|
||||
export type AIAssistantOpenSource =
|
||||
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];
|
||||
|
||||
@@ -67,3 +67,40 @@
|
||||
background: var(--secondary-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.fallbackBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.fallbackHint {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
color: var(--l2-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fallbackEmail {
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
color: var(--l1-foreground);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.fallbackActions {
|
||||
display: flex;
|
||||
gap: var(--spacing-3);
|
||||
flex-wrap: wrap;
|
||||
padding-top: var(--padding-4);
|
||||
}
|
||||
|
||||
.retryLink {
|
||||
box-sizing: border-box;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,37 @@ jest.mock('utils/basePath', () => ({
|
||||
getBaseUrl: (): string => 'https://test.signoz.io',
|
||||
}));
|
||||
|
||||
function mockMailto(): {
|
||||
mockClick: jest.Mock;
|
||||
appendSpy: jest.SpyInstance;
|
||||
removeSpy: jest.SpyInstance;
|
||||
} {
|
||||
const mockClick = jest.fn();
|
||||
const realCreateElement = document.createElement.bind(document);
|
||||
|
||||
// Create a real anchor so JSDOM's appendChild/removeChild accept it.
|
||||
// Override its click() so no navigation occurs.
|
||||
jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockImplementation((tag: string, options?: ElementCreationOptions) => {
|
||||
if (tag === 'a') {
|
||||
const anchor = realCreateElement('a') as HTMLAnchorElement;
|
||||
anchor.click = mockClick;
|
||||
return anchor;
|
||||
}
|
||||
return realCreateElement(tag, options);
|
||||
});
|
||||
|
||||
const appendSpy = jest.spyOn(document.body, 'appendChild');
|
||||
const removeSpy = jest.spyOn(document.body, 'removeChild');
|
||||
return { mockClick, appendSpy, removeSpy };
|
||||
}
|
||||
|
||||
describe('CancelSubscriptionBanner', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders banner with title and subtitle', () => {
|
||||
render(<CancelSubscriptionBanner />);
|
||||
expect(
|
||||
@@ -35,12 +65,10 @@ describe('CancelSubscriptionBanner', () => {
|
||||
screen.getByText(/Cancelling your subscription would stop your data/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Type/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText(/Enter the word cancel/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('cancel-confirm-input')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /go back/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
screen.getByTestId('cancel-subscription-confirm-btn'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -52,12 +80,10 @@ describe('CancelSubscriptionBanner', () => {
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
|
||||
const confirmButton = screen.getByRole('button', {
|
||||
name: /cancel subscription/i,
|
||||
});
|
||||
const confirmButton = screen.getByTestId('cancel-subscription-confirm-btn');
|
||||
expect(confirmButton).toBeDisabled();
|
||||
|
||||
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
|
||||
const input = screen.getByTestId('cancel-confirm-input');
|
||||
await user.type(input, 'canc');
|
||||
expect(confirmButton).toBeDisabled();
|
||||
|
||||
@@ -73,7 +99,7 @@ describe('CancelSubscriptionBanner', () => {
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
|
||||
const input = screen.getByTestId('cancel-confirm-input');
|
||||
await user.type(input, 'cancel');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /go back/i }));
|
||||
@@ -84,19 +110,11 @@ describe('CancelSubscriptionBanner', () => {
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
expect(screen.getByPlaceholderText(/Enter the word cancel/i)).toHaveValue('');
|
||||
expect(screen.getByTestId('cancel-confirm-input')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('sends mailto to cloud-support with correct subject after typing "cancel"', async () => {
|
||||
const realCreateElement = document.createElement.bind(document);
|
||||
const mockClick = jest.fn();
|
||||
const mockAnchor = { href: '', click: mockClick };
|
||||
jest.spyOn(document, 'createElement').mockImplementation((tag: string) => {
|
||||
if (tag === 'a') {
|
||||
return mockAnchor as unknown as HTMLAnchorElement;
|
||||
}
|
||||
return realCreateElement(tag);
|
||||
});
|
||||
it('fires mailto via DOM-attached anchor and shows fallback view after confirming', async () => {
|
||||
const { mockClick, appendSpy, removeSpy } = mockMailto();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
@@ -104,18 +122,85 @@ describe('CancelSubscriptionBanner', () => {
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
|
||||
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
|
||||
|
||||
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
|
||||
await user.type(input, 'cancel');
|
||||
const appendedAnchor = appendSpy.mock.calls
|
||||
.map(([node]) => node)
|
||||
.find(
|
||||
(node): node is HTMLAnchorElement =>
|
||||
node instanceof HTMLAnchorElement && node.href.startsWith('mailto:'),
|
||||
);
|
||||
expect(appendedAnchor).toBeDefined();
|
||||
expect(mockClick).toHaveBeenCalledTimes(1);
|
||||
expect(removeSpy.mock.calls.some(([node]) => node === appendedAnchor)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/An email draft has been opened/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('cloud-support@signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('copy-email-template-btn')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('retry-mailto-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies email template to clipboard when Copy button is clicked', async () => {
|
||||
mockMailto();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
|
||||
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
|
||||
|
||||
expect(mockAnchor.href).toContain('mailto:cloud-support@signoz.io');
|
||||
expect(mockAnchor.href).toContain('Cancel%20My%20SigNoz%20Subscription');
|
||||
expect(mockClick).toHaveBeenCalledTimes(1);
|
||||
await user.click(screen.getByTestId('copy-email-template-btn'));
|
||||
|
||||
jest.restoreAllMocks();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('copy-email-template-btn')).toHaveTextContent(
|
||||
'Copied!',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('retry link is a native anchor with correct mailto href in fallback view', async () => {
|
||||
mockMailto();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
|
||||
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
|
||||
|
||||
const retryLink = screen.getByTestId('retry-mailto-btn');
|
||||
expect(retryLink.tagName).toBe('A');
|
||||
expect(retryLink).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringContaining('mailto:cloud-support@signoz.io'),
|
||||
);
|
||||
});
|
||||
|
||||
it('closes fallback view when Close is clicked and resets state', async () => {
|
||||
mockMailto();
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
|
||||
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /close/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,100 @@
|
||||
import { useState } from 'react';
|
||||
import { SolidInfoCircle, Undo2, X } from '@signozhq/icons';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
CircleCheck,
|
||||
Copy,
|
||||
MailOpen,
|
||||
SolidInfoCircle,
|
||||
Undo2,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { pick } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
import styles from './CancelSubscriptionBanner.module.scss';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
|
||||
import styles from './CancelSubscriptionBanner.module.scss';
|
||||
|
||||
const SUPPORT_EMAIL = 'cloud-support@signoz.io';
|
||||
const MAX_MAILTO_URI_LENGTH = 1800;
|
||||
|
||||
type DialogView = 'confirm' | 'fallback';
|
||||
|
||||
function buildEmailBody(orgName: string, userEmail: string): string {
|
||||
return [
|
||||
'Hi SigNoz Team,',
|
||||
'',
|
||||
'I would like to cancel my SigNoz Cloud subscription.',
|
||||
'Please find my account details below.',
|
||||
'',
|
||||
'Account Details:',
|
||||
` • SigNoz URL: ${getBaseUrl()}`,
|
||||
...(orgName ? [` • Organization: ${orgName}`] : []),
|
||||
` • Account Email: ${userEmail}`,
|
||||
'',
|
||||
'Reason for Cancellation:',
|
||||
'[Please share the reason for cancellation]',
|
||||
'',
|
||||
'Additional feedback (optional):',
|
||||
'[Any other feedback]',
|
||||
'',
|
||||
'Regards,',
|
||||
'[user name or team name]',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function buildMailtoUri(orgName: string, userEmail: string): string {
|
||||
const subject = encodeURIComponent('Cancel My SigNoz Subscription');
|
||||
const body = encodeURIComponent(buildEmailBody(orgName, userEmail));
|
||||
const full = `mailto:${SUPPORT_EMAIL}?subject=${subject}&body=${body}`;
|
||||
if (full.length <= MAX_MAILTO_URI_LENGTH) {
|
||||
return full;
|
||||
}
|
||||
const shortBody = encodeURIComponent(
|
||||
'Hi SigNoz Team,\n\nI would like to cancel my SigNoz Cloud subscription.\nPlease find my account details and reason for cancellation below.\n\n[Your details here]\n\nRegards,',
|
||||
);
|
||||
return `mailto:${SUPPORT_EMAIL}?subject=${subject}&body=${shortBody}`;
|
||||
}
|
||||
|
||||
function openMailto(uri: string): void {
|
||||
const link = document.createElement('a');
|
||||
link.href = uri;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function CancelSubscriptionBanner(): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dialogView, setDialogView] = useState<DialogView | null>(null);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const { user, org } = useAppContext();
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
if (copyTimerRef.current) {
|
||||
clearTimeout(copyTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const orgName = org?.[0]?.displayName ?? '';
|
||||
const userEmail = user?.email ?? '';
|
||||
|
||||
const handleOpenCancelDialog = (): void => {
|
||||
void logEvent('Billing : Cancel Subscription Clicked', {
|
||||
user: pick(user, ['email', 'displayName', 'role', 'organization']),
|
||||
role: user?.role,
|
||||
});
|
||||
setOpen(true);
|
||||
setDialogView('confirm');
|
||||
};
|
||||
|
||||
const handleContactSupport = (): void => {
|
||||
@@ -29,43 +102,41 @@ function CancelSubscriptionBanner(): JSX.Element {
|
||||
user: pick(user, ['email', 'displayName', 'role', 'organization']),
|
||||
role: user?.role,
|
||||
});
|
||||
const subject = encodeURIComponent('Cancel My SigNoz Subscription');
|
||||
const orgName = org?.[0]?.displayName ?? '';
|
||||
const body = encodeURIComponent(
|
||||
[
|
||||
'Hi SigNoz Team,',
|
||||
'',
|
||||
'I would like to cancel my SigNoz Cloud subscription.',
|
||||
'Please find my account details below.',
|
||||
'',
|
||||
'Account Details:',
|
||||
` • SigNoz URL: ${getBaseUrl()}`,
|
||||
...(orgName ? [` • Organization: ${orgName}`] : []),
|
||||
` • Account Email: ${user?.email ?? ''}`,
|
||||
'',
|
||||
'Reason for Cancellation:',
|
||||
'[Please share the reason for cancellation]',
|
||||
'',
|
||||
'Additional feedback (optional):',
|
||||
'[Any other feedback]',
|
||||
'',
|
||||
'Regards,',
|
||||
'[user name or team name]',
|
||||
].join('\n'),
|
||||
);
|
||||
const link = document.createElement('a');
|
||||
link.href = `mailto:cloud-support@signoz.io?subject=${subject}&body=${body}`;
|
||||
link.click();
|
||||
setOpen(false);
|
||||
openMailto(buildMailtoUri(orgName, userEmail));
|
||||
setConfirmText('');
|
||||
setDialogView('fallback');
|
||||
};
|
||||
|
||||
const handleCopyTemplate = (): void => {
|
||||
void logEvent('Billing : Cancel Subscription Email Template Copied', {
|
||||
user: pick(user, ['email', 'displayName', 'role', 'organization']),
|
||||
role: user?.role,
|
||||
});
|
||||
copyToClipboard(buildEmailBody(orgName, userEmail));
|
||||
setCopied(true);
|
||||
if (copyTimerRef.current) {
|
||||
clearTimeout(copyTimerRef.current);
|
||||
}
|
||||
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleRetryMailto = (): void => {
|
||||
void logEvent('Billing : Cancel Subscription Email Client Reopened', {
|
||||
user: pick(user, ['email', 'displayName', 'role', 'organization']),
|
||||
role: user?.role,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
setOpen(false);
|
||||
if (copyTimerRef.current) {
|
||||
clearTimeout(copyTimerRef.current);
|
||||
}
|
||||
setDialogView(null);
|
||||
setConfirmText('');
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const footer = (
|
||||
const confirmFooter = (
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
@@ -81,12 +152,19 @@ function CancelSubscriptionBanner(): JSX.Element {
|
||||
prefix={<X size={14} />}
|
||||
disabled={confirmText !== 'cancel'}
|
||||
onClick={handleContactSupport}
|
||||
data-testid="cancel-subscription-confirm-btn"
|
||||
>
|
||||
Cancel subscription
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
const fallbackFooter = (
|
||||
<Button variant="solid" color="secondary" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.banner}>
|
||||
@@ -111,27 +189,67 @@ function CancelSubscriptionBanner(): JSX.Element {
|
||||
</Button>
|
||||
</div>
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
open={dialogView !== null}
|
||||
onOpenChange={handleClose}
|
||||
title="Cancel your subscription?"
|
||||
width="narrow"
|
||||
showCloseButton={false}
|
||||
footer={footer}
|
||||
footer={dialogView === 'confirm' ? confirmFooter : fallbackFooter}
|
||||
>
|
||||
<div className={styles.dialogBody}>
|
||||
<p className={styles.dialogDescription}>
|
||||
Cancelling your subscription would stop your data from being ingested to
|
||||
SigNoz. All the data that has been already sent will also be deleted.
|
||||
</p>
|
||||
<p className={styles.dialogConfirmLabel}>
|
||||
Type <code>cancel</code> to confirm the cancellation.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Enter the word cancel..."
|
||||
value={confirmText}
|
||||
onChange={(e): void => setConfirmText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{dialogView === 'confirm' && (
|
||||
<div className={styles.dialogBody}>
|
||||
<p className={styles.dialogDescription}>
|
||||
Cancelling your subscription would stop your data from being ingested to
|
||||
SigNoz. All the data that has been already sent will also be deleted.
|
||||
</p>
|
||||
<p className={styles.dialogConfirmLabel}>
|
||||
Type <code>cancel</code> to confirm the cancellation.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Enter the word cancel..."
|
||||
value={confirmText}
|
||||
onChange={(e): void => setConfirmText(e.target.value)}
|
||||
data-testid="cancel-confirm-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{dialogView === 'fallback' && (
|
||||
<div className={styles.fallbackBody}>
|
||||
<p className={styles.fallbackHint}>
|
||||
An email draft has been opened. If it did not open, send your
|
||||
cancellation request directly to:
|
||||
</p>
|
||||
<span className={styles.fallbackEmail}>{SUPPORT_EMAIL}</span>
|
||||
<div className={styles.fallbackActions}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={copied ? <CircleCheck size={14} /> : <Copy size={14} />}
|
||||
onClick={handleCopyTemplate}
|
||||
data-testid="copy-email-template-btn"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy email template'}
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
data-testid="retry-mailto-btn"
|
||||
>
|
||||
<a
|
||||
href={buildMailtoUri(orgName, userEmail)}
|
||||
onClick={handleRetryMailto}
|
||||
className={styles.retryLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<MailOpen size={14} />
|
||||
Reopen email client
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogWrapper>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import UPlotLegend from 'lib/uPlotV2/components/Legend/UPlotLegend';
|
||||
import {
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
@@ -47,7 +47,7 @@ export default function ChartWrapper({
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Legend
|
||||
<UPlotLegend
|
||||
config={config}
|
||||
position={legendConfig.position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
.pieChartWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pieChartNoData {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
// Size is set inline from the computed chart dimensions (mirrors the uPlot
|
||||
// chart/legend split); this just centres the donut within that box.
|
||||
.pieChartContainer {
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pieChartTooltip {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
background-color: var(--l2-background) !important;
|
||||
border: 1px solid var(--l2-border) !important;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.pieChartTooltipContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pieChartIndicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.pieChartTooltipValue {
|
||||
font-weight: bold;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
// Wraps the shared chart Legend. Its width/height are set inline from the
|
||||
// computed chart dimensions, so the VirtuosoGrid inside gets the same bounded
|
||||
// box (right column / bottom rows) the uPlot charts use.
|
||||
.pieChartLegend {
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Group } from '@visx/group';
|
||||
import { Pie as VisxPie } from '@visx/shape';
|
||||
import { defaultStyles, useTooltip, useTooltipInPortal } from '@visx/tooltip';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { PieChartProps, PieSlice } from '../types';
|
||||
import { calculateChartDimensions } from '../utils';
|
||||
|
||||
import { usePieInteractions } from '../../hooks/usePieInteractions';
|
||||
import PieArc from './PieArc';
|
||||
import PieCenterLabel from './PieCenterLabel';
|
||||
import styles from './Pie.module.scss';
|
||||
import { PieTooltipData } from './types';
|
||||
import { getFillColor } from './utils';
|
||||
|
||||
/**
|
||||
* Donut chart rendered with @visx. Splits its area into chart + legend with the
|
||||
* same `calculateChartDimensions` logic as the uPlot charts (right column /
|
||||
* up-to-two bottom rows), renders the shared chart Legend, and delegates the
|
||||
* arcs, centre total and interaction state to PieArc / PieCenterLabel /
|
||||
* usePieInteractions. Pure presentation — slices are pre-resolved by the caller.
|
||||
*/
|
||||
export default function Pie({
|
||||
data,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
isDarkMode,
|
||||
position = LegendPosition.BOTTOM,
|
||||
id,
|
||||
onSliceClick,
|
||||
'data-testid': testId,
|
||||
}: PieChartProps): JSX.Element {
|
||||
const {
|
||||
active,
|
||||
setActive,
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
} = usePieInteractions(data, id);
|
||||
|
||||
const {
|
||||
tooltipOpen,
|
||||
tooltipLeft,
|
||||
tooltipTop,
|
||||
tooltipData,
|
||||
hideTooltip,
|
||||
showTooltip,
|
||||
} = useTooltip<PieTooltipData>();
|
||||
|
||||
const { containerRef, TooltipInPortal } = useTooltipInPortal({
|
||||
scroll: true,
|
||||
detectBounds: true,
|
||||
});
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const { width: containerWidth, height: containerHeight } =
|
||||
useResizeObserver(wrapperRef);
|
||||
|
||||
// Reuse the uPlot chart/legend split so the donut + legend get the same area
|
||||
// allocation (right column, or up-to-two bottom rows) as every other panel.
|
||||
const { width, height, legendWidth, legendHeight, averageLegendWidth } =
|
||||
useMemo(
|
||||
() =>
|
||||
calculateChartDimensions({
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
legendConfig: { position },
|
||||
seriesLabels: data.map((slice) => slice.label),
|
||||
}),
|
||||
[containerWidth, containerHeight, position, data],
|
||||
);
|
||||
|
||||
// Donut geometry derived from the allocated chart box.
|
||||
const { size, radius, innerRadius } = useMemo(() => {
|
||||
const nextSize = Math.min(width, height);
|
||||
const nextRadius = nextSize * 0.35;
|
||||
return {
|
||||
size: nextSize,
|
||||
radius: nextRadius,
|
||||
innerRadius: nextRadius * 0.6,
|
||||
};
|
||||
}, [width, height]);
|
||||
|
||||
const totalValue = useMemo(
|
||||
() => visibleData.reduce((sum, slice) => sum + slice.value, 0),
|
||||
[visibleData],
|
||||
);
|
||||
|
||||
const labelColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400;
|
||||
const activeColor = active?.color ?? null;
|
||||
|
||||
const handleSliceEnter = useCallback(
|
||||
(slice: PieSlice, centroidX: number, centroidY: number): void => {
|
||||
showTooltip({
|
||||
tooltipData: {
|
||||
label: slice.label,
|
||||
value: getYAxisFormattedValue(
|
||||
slice.value.toString(),
|
||||
yAxisUnit || 'none',
|
||||
decimalPrecision,
|
||||
),
|
||||
color: slice.color,
|
||||
},
|
||||
tooltipTop: centroidY + height / 2,
|
||||
tooltipLeft: centroidX + width / 2,
|
||||
});
|
||||
setActive(slice);
|
||||
},
|
||||
[showTooltip, setActive, yAxisUnit, decimalPrecision, height, width],
|
||||
);
|
||||
|
||||
const handleSliceLeave = useCallback((): void => {
|
||||
hideTooltip();
|
||||
setActive(null);
|
||||
}, [hideTooltip, setActive]);
|
||||
|
||||
if (!data.length) {
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={styles.pieChartWrapper}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className={styles.pieChartNoData}>No data</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isRightLegend = position === LegendPosition.RIGHT;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={styles.pieChartWrapper}
|
||||
style={{ flexDirection: isRightLegend ? 'row' : 'column' }}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className={styles.pieChartContainer} style={{ width, height }}>
|
||||
{size > 0 && (
|
||||
<svg width={width} height={height} ref={containerRef}>
|
||||
<Group top={height / 2} left={width / 2}>
|
||||
<VisxPie
|
||||
data={visibleData}
|
||||
pieValue={(slice: PieSlice): number => slice.value}
|
||||
outerRadius={radius}
|
||||
innerRadius={innerRadius}
|
||||
padAngle={0.01}
|
||||
cornerRadius={3}
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
{(pie): JSX.Element[] =>
|
||||
pie.arcs.map((arc) => (
|
||||
<PieArc
|
||||
key={`arc-${arc.data.label}-${arc.data.value}-${arc.startAngle.toFixed(
|
||||
6,
|
||||
)}`}
|
||||
slice={arc.data}
|
||||
arcPath={pie.path(arc) || ''}
|
||||
centroid={pie.path.centroid(arc)}
|
||||
startAngle={arc.startAngle}
|
||||
endAngle={arc.endAngle}
|
||||
radius={radius}
|
||||
totalValue={totalValue}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
labelColor={labelColor}
|
||||
fill={getFillColor(arc.data.color, activeColor)}
|
||||
onEnter={handleSliceEnter}
|
||||
onLeave={handleSliceLeave}
|
||||
onClick={onSliceClick}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</VisxPie>
|
||||
<PieCenterLabel
|
||||
total={totalValue}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
radius={radius}
|
||||
innerRadius={innerRadius}
|
||||
color={labelColor}
|
||||
/>
|
||||
</Group>
|
||||
</svg>
|
||||
)}
|
||||
{tooltipOpen && tooltipData && (
|
||||
<TooltipInPortal
|
||||
top={tooltipTop}
|
||||
left={tooltipLeft}
|
||||
className={styles.pieChartTooltip}
|
||||
style={{
|
||||
...defaultStyles,
|
||||
color: labelColor,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.pieChartIndicator}
|
||||
style={{ background: tooltipData.color }}
|
||||
/>
|
||||
<div className={styles.pieChartTooltipContent}>
|
||||
<span>{tooltipData.label}</span>
|
||||
<span className={styles.pieChartTooltipValue}>{tooltipData.value}</span>
|
||||
</div>
|
||||
</TooltipInPortal>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={styles.pieChartLegend}
|
||||
style={{
|
||||
width: legendWidth,
|
||||
height: legendHeight,
|
||||
}}
|
||||
>
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
import { PieSlice } from '../types';
|
||||
|
||||
import { getArcGeometry } from './utils';
|
||||
|
||||
// Slices below this share of the total don't get a leader label (too cramped).
|
||||
const MIN_LABEL_SHARE = 0.03;
|
||||
const MAX_LABEL_LENGTH = 15;
|
||||
|
||||
interface PieArcProps {
|
||||
slice: PieSlice;
|
||||
/** SVG path `d` for the arc, from the visx pie generator. */
|
||||
arcPath: string;
|
||||
/** Arc centroid `[x, y]`, used to anchor the leader line and tooltip. */
|
||||
centroid: [number, number];
|
||||
startAngle: number;
|
||||
endAngle: number;
|
||||
radius: number;
|
||||
/** Sum of visible slice values — drives the show-label threshold. */
|
||||
totalValue: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
labelColor: string;
|
||||
/** Resolved fill (already dimmed if another slice is active). */
|
||||
fill: string;
|
||||
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
|
||||
onLeave: () => void;
|
||||
onClick?: (slice: PieSlice) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single donut slice: the arc path plus, for non-tiny slices, a leader line
|
||||
* out to an external label + value. Pure presentation — interaction is
|
||||
* delegated to the `onEnter`/`onLeave`/`onClick` callbacks.
|
||||
*/
|
||||
export default function PieArc({
|
||||
slice,
|
||||
arcPath,
|
||||
centroid,
|
||||
startAngle,
|
||||
endAngle,
|
||||
radius,
|
||||
totalValue,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
labelColor,
|
||||
fill,
|
||||
onEnter,
|
||||
onLeave,
|
||||
onClick,
|
||||
}: PieArcProps): JSX.Element {
|
||||
const { label, value } = slice;
|
||||
const [centroidX, centroidY] = centroid;
|
||||
const { labelX, labelY, lineEndX, lineEndY, textAnchor } = getArcGeometry(
|
||||
startAngle,
|
||||
endAngle,
|
||||
radius,
|
||||
);
|
||||
|
||||
const displayValue = getYAxisFormattedValue(
|
||||
value.toString(),
|
||||
yAxisUnit || 'none',
|
||||
decimalPrecision,
|
||||
);
|
||||
const shortenedLabel =
|
||||
label.length > MAX_LABEL_LENGTH ? `${label.substring(0, 12)}...` : label;
|
||||
const shouldShowLabel = value / totalValue > MIN_LABEL_SHARE;
|
||||
|
||||
return (
|
||||
<g
|
||||
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
|
||||
onMouseLeave={onLeave}
|
||||
onClick={(): void => onClick?.(slice)}
|
||||
>
|
||||
<path d={arcPath} fill={fill} />
|
||||
{shouldShowLabel && (
|
||||
<>
|
||||
<line
|
||||
x1={centroidX}
|
||||
y1={centroidY}
|
||||
x2={lineEndX}
|
||||
y2={lineEndY}
|
||||
stroke={labelColor}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<line
|
||||
x1={lineEndX}
|
||||
y1={lineEndY}
|
||||
x2={labelX}
|
||||
y2={labelY}
|
||||
stroke={labelColor}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={labelX}
|
||||
y={labelY - 8}
|
||||
dy=".33em"
|
||||
fill={labelColor}
|
||||
fontSize={10}
|
||||
textAnchor={textAnchor}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{shortenedLabel}
|
||||
</text>
|
||||
<text
|
||||
x={labelX}
|
||||
y={labelY + 8}
|
||||
dy=".33em"
|
||||
fill={labelColor}
|
||||
fontSize={10}
|
||||
fontWeight="bold"
|
||||
textAnchor={textAnchor}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{displayValue}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
import { getScaledFontSize } from './utils';
|
||||
|
||||
interface PieCenterLabelProps {
|
||||
/** Sum of the visible slice values, shown in the donut hole. */
|
||||
total: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
radius: number;
|
||||
innerRadius: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The total shown in the centre of the donut. Splits the formatted value into
|
||||
* its numeric part and unit so each can be sized independently, and scales the
|
||||
* numeric font down for long values so it never overflows the hole.
|
||||
*/
|
||||
export default function PieCenterLabel({
|
||||
total,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
radius,
|
||||
innerRadius,
|
||||
color,
|
||||
}: PieCenterLabelProps): JSX.Element {
|
||||
const formattedTotal = getYAxisFormattedValue(
|
||||
total.toString(),
|
||||
yAxisUnit || 'none',
|
||||
decimalPrecision,
|
||||
);
|
||||
const matches = formattedTotal.match(/([\d.]+[KMB]?)(.*)$/);
|
||||
const numericTotal = matches?.[1] || formattedTotal;
|
||||
const unitTotal = matches?.[2]?.trim() || '';
|
||||
|
||||
const numericFontSize = getScaledFontSize({
|
||||
text: numericTotal,
|
||||
baseSize: radius * 0.3,
|
||||
innerRadius,
|
||||
});
|
||||
const unitFontSize = numericFontSize * 0.5;
|
||||
|
||||
return (
|
||||
<text textAnchor="middle" dominantBaseline="central" fill={color}>
|
||||
<tspan fontSize={numericFontSize} fontWeight="bold">
|
||||
{numericTotal}
|
||||
</tspan>
|
||||
{unitTotal && (
|
||||
<tspan fontSize={unitFontSize} opacity={0.9} dx={2}>
|
||||
{unitTotal}
|
||||
</tspan>
|
||||
)}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { PieSlice } from '../../types';
|
||||
import Pie from '../Pie';
|
||||
|
||||
jest.mock('hooks/useDimensions', () => ({
|
||||
useResizeObserver: jest.fn().mockReturnValue({ width: 400, height: 300 }),
|
||||
}));
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
getYAxisFormattedValue: jest.fn((value: string) => value),
|
||||
}));
|
||||
|
||||
// VirtuosoGrid only renders a window in jsdom; render every item so we can
|
||||
// assert on legend entries.
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="virtuoso-grid">
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex ?? index}>{itemContent(index, item)}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const DATA: PieSlice[] = [
|
||||
{ label: 'frontend', value: 100, color: '#aa0000' },
|
||||
{ label: 'cart', value: 60, color: '#00aa00' },
|
||||
{ label: 'checkout', value: 40, color: '#0000aa' },
|
||||
];
|
||||
|
||||
function renderPie(
|
||||
props: Partial<React.ComponentProps<typeof Pie>> = {},
|
||||
): void {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Pie data={DATA} isDarkMode={false} data-testid="pie" {...props} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Pie', () => {
|
||||
it('renders the "No data" state for empty data', () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Pie data={[]} isDarkMode={false} data-testid="pie" />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText('No data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders one arc per slice plus the legend entries and centre total', () => {
|
||||
renderPie();
|
||||
|
||||
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(DATA.length);
|
||||
|
||||
const legend = screen.getByTestId('virtuoso-grid');
|
||||
expect(within(legend).getByText('frontend')).toBeInTheDocument();
|
||||
expect(within(legend).getByText('cart')).toBeInTheDocument();
|
||||
expect(within(legend).getByText('checkout')).toBeInTheDocument();
|
||||
|
||||
// Centre total = 100 + 60 + 40 (formatter mocked to echo the value).
|
||||
expect(screen.getByText('200')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lays the legend out in a row for the right position and a column for bottom', () => {
|
||||
const { rerender } = render(
|
||||
<TooltipProvider>
|
||||
<Pie
|
||||
data={DATA}
|
||||
isDarkMode={false}
|
||||
position={LegendPosition.RIGHT}
|
||||
data-testid="pie"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'row' });
|
||||
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<Pie
|
||||
data={DATA}
|
||||
isDarkMode={false}
|
||||
position={LegendPosition.BOTTOM}
|
||||
data-testid="pie"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'column' });
|
||||
});
|
||||
|
||||
it('hides a slice when its legend marker is clicked', () => {
|
||||
renderPie();
|
||||
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(3);
|
||||
|
||||
const marker = document.querySelector(
|
||||
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
|
||||
) as HTMLElement;
|
||||
fireEvent.click(marker);
|
||||
|
||||
// One slice hidden → one fewer arc drawn.
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { PieSlice } from '../../types';
|
||||
import PieArc from '../PieArc';
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
// Echo the raw value so assertions are deterministic.
|
||||
getYAxisFormattedValue: jest.fn((value: string) => value),
|
||||
}));
|
||||
|
||||
const SLICE: PieSlice = { label: 'frontend', value: 50, color: '#f00' };
|
||||
|
||||
function renderArc(props: Partial<React.ComponentProps<typeof PieArc>> = {}): {
|
||||
onEnter: jest.Mock;
|
||||
onLeave: jest.Mock;
|
||||
onClick: jest.Mock;
|
||||
container: HTMLElement;
|
||||
} {
|
||||
const onEnter = jest.fn();
|
||||
const onLeave = jest.fn();
|
||||
const onClick = jest.fn();
|
||||
const { container } = render(
|
||||
<svg>
|
||||
<PieArc
|
||||
slice={SLICE}
|
||||
arcPath="M0,0L1,1"
|
||||
centroid={[10, 20]}
|
||||
startAngle={0}
|
||||
endAngle={Math.PI}
|
||||
radius={100}
|
||||
totalValue={100}
|
||||
labelColor="#fff"
|
||||
fill="#f00"
|
||||
onEnter={onEnter}
|
||||
onLeave={onLeave}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
/>
|
||||
</svg>,
|
||||
);
|
||||
return { onEnter, onLeave, onClick, container };
|
||||
}
|
||||
|
||||
describe('PieArc', () => {
|
||||
it('renders the arc path with the resolved fill', () => {
|
||||
const { container } = renderArc();
|
||||
const path = container.querySelector('path');
|
||||
expect(path).toHaveAttribute('d', 'M0,0L1,1');
|
||||
expect(path).toHaveAttribute('fill', '#f00');
|
||||
});
|
||||
|
||||
it('shows the leader label + value for a slice above the threshold', () => {
|
||||
renderArc(); // 50 / 100 = 0.5
|
||||
expect(screen.getByText('frontend')).toBeInTheDocument();
|
||||
expect(screen.getByText('50')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the leader label for a slice below the 3% threshold', () => {
|
||||
renderArc({ totalValue: 10000 }); // 50 / 10000 = 0.005
|
||||
expect(screen.queryByText('frontend')).not.toBeInTheDocument();
|
||||
// the arc path itself still renders
|
||||
expect(screen.queryByText('50')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('truncates labels longer than 15 chars', () => {
|
||||
renderArc({
|
||||
slice: { label: 'a-really-long-service-name', value: 50, color: '#f00' },
|
||||
});
|
||||
expect(screen.getByText('a-really-lon...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires onEnter with the slice + centroid, and onLeave / onClick', () => {
|
||||
const { onEnter, onLeave, onClick, container } = renderArc();
|
||||
const g = container.querySelector('g') as SVGGElement;
|
||||
|
||||
fireEvent.mouseEnter(g);
|
||||
expect(onEnter).toHaveBeenCalledWith(SLICE, 10, 20);
|
||||
|
||||
fireEvent.mouseLeave(g);
|
||||
expect(onLeave).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(g);
|
||||
expect(onClick).toHaveBeenCalledWith(SLICE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
import PieCenterLabel from '../PieCenterLabel';
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
getYAxisFormattedValue: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockFormat = getYAxisFormattedValue as jest.MockedFunction<
|
||||
typeof getYAxisFormattedValue
|
||||
>;
|
||||
|
||||
function renderInSvg(node: JSX.Element): ReturnType<typeof render> {
|
||||
// PieCenterLabel returns an SVG <text>, so it needs an <svg> host.
|
||||
return render(<svg>{node}</svg>);
|
||||
}
|
||||
|
||||
describe('PieCenterLabel', () => {
|
||||
const baseProps = {
|
||||
total: 3700,
|
||||
radius: 100,
|
||||
innerRadius: 60,
|
||||
color: '#fff',
|
||||
};
|
||||
|
||||
it('renders the formatted total (numeric + unit suffix) as one numeric tspan when there is no separate unit', () => {
|
||||
mockFormat.mockReturnValue('3.7K');
|
||||
renderInSvg(<PieCenterLabel {...baseProps} />);
|
||||
expect(screen.getByText('3.7K')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('splits the numeric part and the trailing unit into separate tspans', () => {
|
||||
mockFormat.mockReturnValue('1.2 MB');
|
||||
renderInSvg(<PieCenterLabel {...baseProps} />);
|
||||
expect(screen.getByText('1.2')).toBeInTheDocument();
|
||||
expect(screen.getByText('MB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes the unit + precision through to the formatter', () => {
|
||||
mockFormat.mockReturnValue('100');
|
||||
renderInSvg(<PieCenterLabel {...baseProps} total={100} yAxisUnit="bytes" />);
|
||||
expect(mockFormat).toHaveBeenCalledWith('100', 'bytes', undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
getArcGeometry,
|
||||
getFillColor,
|
||||
getScaledFontSize,
|
||||
lightenColor,
|
||||
} from '../utils';
|
||||
|
||||
describe('Pie utils', () => {
|
||||
describe('getScaledFontSize', () => {
|
||||
it('returns the base size for empty text', () => {
|
||||
expect(getScaledFontSize({ text: '', baseSize: 30, innerRadius: 100 })).toBe(
|
||||
30,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not scale short text (length <= 3)', () => {
|
||||
// scaleFactor = max(0.3, 1) = 1 → baseSize, capped by innerRadius * 0.9.
|
||||
expect(
|
||||
getScaledFontSize({ text: '3.7', baseSize: 30, innerRadius: 100 }),
|
||||
).toBe(30);
|
||||
});
|
||||
|
||||
it('scales longer text down', () => {
|
||||
// length 8 → scaleFactor = max(0.3, 1 - 5 * 0.09) = 0.55 → 30 * 0.55.
|
||||
expect(
|
||||
getScaledFontSize({ text: '12345678', baseSize: 30, innerRadius: 100 }),
|
||||
).toBeCloseTo(16.5);
|
||||
});
|
||||
|
||||
it('floors the scale factor at 0.3 for very long text', () => {
|
||||
// length 20 → 1 - 17 * 0.09 < 0.3 → floored to 0.3 → 100 * 0.3.
|
||||
expect(
|
||||
getScaledFontSize({
|
||||
text: '12345678901234567890',
|
||||
baseSize: 100,
|
||||
innerRadius: 1000,
|
||||
}),
|
||||
).toBeCloseTo(30);
|
||||
});
|
||||
|
||||
it('caps the size at 90% of the inner radius', () => {
|
||||
expect(
|
||||
getScaledFontSize({ text: '3.7', baseSize: 200, innerRadius: 10 }),
|
||||
).toBeCloseTo(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getArcGeometry', () => {
|
||||
it('places the label below for a slice centred at the top (angle 0)', () => {
|
||||
const g = getArcGeometry(0, 0, 100);
|
||||
expect(g.labelX).toBeCloseTo(0);
|
||||
expect(g.labelY).toBeCloseTo(-130);
|
||||
expect(g.lineEndX).toBeCloseTo(0);
|
||||
expect(g.lineEndY).toBeCloseTo(-110);
|
||||
// sin(0) is not > 0 → anchor end.
|
||||
expect(g.textAnchor).toBe('end');
|
||||
});
|
||||
|
||||
it('anchors to the start on the right half (angle pi/2)', () => {
|
||||
const g = getArcGeometry(0, Math.PI, 100);
|
||||
expect(g.labelX).toBeCloseTo(130);
|
||||
expect(g.labelY).toBeCloseTo(0);
|
||||
expect(g.textAnchor).toBe('start');
|
||||
});
|
||||
|
||||
it('anchors to the end on the left half (angle 3pi/2)', () => {
|
||||
const g = getArcGeometry(Math.PI, 2 * Math.PI, 100);
|
||||
expect(g.labelX).toBeCloseTo(-130);
|
||||
expect(g.textAnchor).toBe('end');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lightenColor', () => {
|
||||
it('converts a #rrggbb hex to rgba at the given opacity', () => {
|
||||
expect(lightenColor('#ff0000', 0.4)).toBe('rgba(255, 0, 0, 0.4)');
|
||||
});
|
||||
|
||||
it('accepts hex without a leading #', () => {
|
||||
expect(lightenColor('00ff00', 0.4)).toBe('rgba(0, 255, 0, 0.4)');
|
||||
});
|
||||
|
||||
it('returns the original colour when it is not parseable hex', () => {
|
||||
expect(lightenColor('rgba(0,0,0,1)', 0.4)).toBe('rgba(0,0,0,1)');
|
||||
expect(lightenColor('red', 0.4)).toBe('red');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFillColor', () => {
|
||||
it('returns the colour unchanged when nothing is active', () => {
|
||||
expect(getFillColor('#ff0000', null)).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('returns the colour unchanged for the active slice', () => {
|
||||
expect(getFillColor('#ff0000', '#ff0000')).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('dims non-active slices to 40% opacity', () => {
|
||||
expect(getFillColor('#00ff00', '#ff0000')).toBe('rgba(0, 255, 0, 0.4)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Pie-local types. Kept out of the component / util files so each stays focused
|
||||
* (per the one-component-per-file + dedicated-types rules). Shared chart types
|
||||
* (PieSlice, PieChartProps) live in the parent charts/types.ts.
|
||||
*/
|
||||
|
||||
export interface ScaledFontSizeArgs {
|
||||
text: string;
|
||||
baseSize: number;
|
||||
innerRadius: number;
|
||||
}
|
||||
|
||||
export interface ArcGeometry {
|
||||
/** Outer point where the leader label sits. */
|
||||
labelX: number;
|
||||
labelY: number;
|
||||
/** Elbow point where the leader line bends toward the label. */
|
||||
lineEndX: number;
|
||||
lineEndY: number;
|
||||
/** Anchor the label left/right depending on which half of the circle it's in. */
|
||||
textAnchor: 'start' | 'end';
|
||||
}
|
||||
|
||||
export interface ParsedRgb {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
/** Resolved tooltip payload shown when a slice is hovered. */
|
||||
export interface PieTooltipData {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Pure presentation helpers for the Pie chart. Kept out of the component file
|
||||
* so the renderer stays declarative (per the one-component-per-file rule).
|
||||
*/
|
||||
|
||||
import { ArcGeometry, ParsedRgb, ScaledFontSizeArgs } from './types';
|
||||
|
||||
/**
|
||||
* Shrinks the centre-total font as the text gets longer so it never overflows
|
||||
* the donut hole. Ported from the V1 PiePanelWrapper.
|
||||
*/
|
||||
export function getScaledFontSize({
|
||||
text,
|
||||
baseSize,
|
||||
innerRadius,
|
||||
}: ScaledFontSizeArgs): number {
|
||||
if (!text) {
|
||||
return baseSize;
|
||||
}
|
||||
|
||||
const { length } = text;
|
||||
// More aggressive scaling for very long numbers.
|
||||
const scaleFactor = Math.max(0.3, 1 - (length - 3) * 0.09);
|
||||
// Don't use more than 90% of the inner radius.
|
||||
const maxSize = innerRadius * 0.9;
|
||||
|
||||
return Math.min(baseSize * scaleFactor, maxSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the leader-line / label geometry for one arc from its angular span.
|
||||
* Pulled out of the render prop so the SVG markup stays declarative.
|
||||
*/
|
||||
export function getArcGeometry(
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
radius: number,
|
||||
): ArcGeometry {
|
||||
const angle = (startAngle + endAngle) / 2;
|
||||
const labelRadius = radius * 1.3;
|
||||
const lineEndRadius = radius * 1.1;
|
||||
return {
|
||||
labelX: Math.sin(angle) * labelRadius,
|
||||
labelY: -Math.cos(angle) * labelRadius,
|
||||
lineEndX: Math.sin(angle) * lineEndRadius,
|
||||
lineEndY: -Math.cos(angle) * lineEndRadius,
|
||||
textAnchor: Math.sin(angle) > 0 ? 'start' : 'end',
|
||||
};
|
||||
}
|
||||
|
||||
// Parses `#rrggbb` into its components. Returns null for anything else (e.g. an
|
||||
// already-rgba string), letting callers fall back to the original colour.
|
||||
function hexToRgb(color: string): ParsedRgb | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an rgba() string for `color` at the given opacity. Used to dim the
|
||||
* non-hovered slices. Falls back to the original colour if it can't be parsed.
|
||||
*/
|
||||
export function lightenColor(color: string, opacity: number): string {
|
||||
const rgb = hexToRgb(color);
|
||||
if (!rgb) {
|
||||
return color;
|
||||
}
|
||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the fill for a slice given the currently-hovered slice colour:
|
||||
* everything but the active slice dims to 40% opacity. With nothing hovered
|
||||
* (`activeColor === null`) every slice keeps its full colour.
|
||||
*/
|
||||
export function getFillColor(
|
||||
color: string,
|
||||
activeColor: string | null,
|
||||
): string {
|
||||
if (activeColor === null) {
|
||||
return color;
|
||||
}
|
||||
return activeColor === color ? color : lightenColor(color, 0.4);
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
IRenderTooltipFooterArgs,
|
||||
LegendConfig,
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
TooltipClickData,
|
||||
ChartClickData,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
@@ -22,10 +23,10 @@ interface BaseChartProps {
|
||||
/** Key that pins the tooltip while hovering. Defaults to DEFAULT_PIN_TOOLTIP_KEY ('l'). */
|
||||
pinKey?: string;
|
||||
/** Called when the user clicks the uPlot overlay. Receives resolved click data. */
|
||||
onClick?: (clickData: TooltipClickData) => void;
|
||||
onClick?: (clickData: ChartClickData) => void;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
|
||||
pinnedTooltipElement?: (clickData: ChartClickData) => React.ReactNode;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
@@ -69,3 +70,36 @@ export type ChartProps =
|
||||
| TimeSeriesChartProps
|
||||
| BarChartProps
|
||||
| HistogramChartProps;
|
||||
|
||||
/**
|
||||
* One resolved pie/donut slice: a display label, its (already parsed) positive
|
||||
* numeric value, and the colour used for the arc + legend swatch.
|
||||
*/
|
||||
export interface PieSlice {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the Pie chart. Unlike the others above, Pie is NOT uPlot-based
|
||||
* (it renders with @visx), so it deliberately does not extend BaseChartProps /
|
||||
* UPlotBasedChartProps — it takes pre-resolved slices and self-measures its
|
||||
* draw area rather than receiving a uPlot config + aligned data.
|
||||
*/
|
||||
export interface PieChartProps {
|
||||
data: PieSlice[];
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
isDarkMode: boolean;
|
||||
/** Legend placement. Drives the chart-vs-legend layout. Default BOTTOM. */
|
||||
position?: LegendPosition;
|
||||
/**
|
||||
* Widget id used to persist per-slice hide/unhide state to localStorage
|
||||
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
|
||||
*/
|
||||
id?: string;
|
||||
/** Fired when a slice (or its legend entry) is clicked. */
|
||||
onSliceClick?: (slice: PieSlice) => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import {
|
||||
getStoredSeriesVisibility,
|
||||
updateSeriesVisibilityToLocalStorage,
|
||||
} from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import { PieSlice } from '../../charts/types';
|
||||
import { usePieInteractions } from '../usePieInteractions';
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
);
|
||||
|
||||
const mockGetStored = getStoredSeriesVisibility as jest.MockedFunction<
|
||||
typeof getStoredSeriesVisibility
|
||||
>;
|
||||
const mockUpdateStored =
|
||||
updateSeriesVisibilityToLocalStorage as jest.MockedFunction<
|
||||
typeof updateSeriesVisibilityToLocalStorage
|
||||
>;
|
||||
|
||||
const DATA: PieSlice[] = [
|
||||
{ label: 'frontend', value: 100, color: '#a' },
|
||||
{ label: 'cart', value: 60, color: '#b' },
|
||||
{ label: 'checkout', value: 40, color: '#c' },
|
||||
];
|
||||
|
||||
// Builds a fake legend click/move event: `e.target.closest('[data-legend-item-id]')`
|
||||
// resolves to the item at `index`, and `e.target.dataset.isLegendMarker` flags marker clicks.
|
||||
function legendEvent(
|
||||
index: number | null,
|
||||
isMarker = false,
|
||||
): MouseEvent<HTMLDivElement> {
|
||||
const itemEl =
|
||||
index == null ? null : { dataset: { legendItemId: String(index) } };
|
||||
return {
|
||||
target: {
|
||||
closest: (): unknown => itemEl,
|
||||
dataset: { isLegendMarker: isMarker ? 'true' : undefined },
|
||||
},
|
||||
} as unknown as MouseEvent<HTMLDivElement>;
|
||||
}
|
||||
|
||||
describe('usePieInteractions', () => {
|
||||
beforeEach(() => {
|
||||
mockGetStored.mockReturnValue(null);
|
||||
mockUpdateStored.mockReset();
|
||||
});
|
||||
|
||||
it('starts with everything visible and nothing focused', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
expect(result.current.active).toBeNull();
|
||||
});
|
||||
|
||||
describe('marker click (toggle one)', () => {
|
||||
it('hides then unhides the clicked slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true)));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
|
||||
expect(result.current.legendItems[1].show).toBe(false);
|
||||
expect(mockUpdateStored).toHaveBeenLastCalledWith('panel-1', [
|
||||
{ label: 'frontend', show: true },
|
||||
{ label: 'cart', show: false },
|
||||
{ label: 'checkout', show: true },
|
||||
]);
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true)));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
expect(result.current.legendItems[1].show).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('label click (isolate / reset)', () => {
|
||||
it('isolates the clicked slice, then resets on a second click', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(0, false)));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
|
||||
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(0, false)));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hover', () => {
|
||||
it('focuses the hovered slice and clears on leave', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendMouseMove(legendEvent(2)));
|
||||
expect(result.current.active).toStrictEqual(DATA[2]);
|
||||
expect(result.current.focusedSeriesIndex).toBe(2);
|
||||
|
||||
act(() => result.current.onLegendMouseLeave());
|
||||
expect(result.current.active).toBeNull();
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('does not focus a hidden slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true))); // hide cart
|
||||
act(() => result.current.onLegendMouseMove(legendEvent(1)));
|
||||
|
||||
expect(result.current.active).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
it('does not write to storage when no id is provided', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
act(() => result.current.onLegendClick(legendEvent(0, true)));
|
||||
expect(mockUpdateStored).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rehydrates hidden slices from storage on mount (matched by label)', () => {
|
||||
mockGetStored.mockReturnValue([
|
||||
{ label: 'frontend', show: true },
|
||||
{ label: 'cart', show: false },
|
||||
{ label: 'checkout', show: true },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
|
||||
expect(result.current.legendItems[1].show).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import type { Dispatch, MouseEvent, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
getStoredSeriesVisibility,
|
||||
updateSeriesVisibilityToLocalStorage,
|
||||
} from '../panels/utils/legendVisibilityUtils';
|
||||
import { PieSlice } from '../charts/types';
|
||||
|
||||
export interface UsePieInteractionsResult {
|
||||
/** The hovered/focused slice (drives donut dimming + tooltip). */
|
||||
active: PieSlice | null;
|
||||
setActive: Dispatch<SetStateAction<PieSlice | null>>;
|
||||
/** Slices currently shown (hidden ones removed). */
|
||||
visibleData: PieSlice[];
|
||||
/** Legend item per slice (`show` reflects hide state). */
|
||||
legendItems: LegendItem[];
|
||||
/** Index of the active slice for the legend's focus highlight, or null. */
|
||||
focusedSeriesIndex: number | null;
|
||||
onLegendClick: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseMove: (e: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseLeave: () => void;
|
||||
}
|
||||
|
||||
// Reads the slice index off the nearest `[data-legend-item-id]` ancestor of the
|
||||
// event target (the shared Legend tags each item with its seriesIndex).
|
||||
function getLegendIndex(e: MouseEvent<HTMLDivElement>): number | null {
|
||||
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>(
|
||||
'[data-legend-item-id]',
|
||||
);
|
||||
const id = el?.dataset.legendItemId;
|
||||
return id != null ? Number(id) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pie interaction + derived state: hover/focus, slice hide/unhide (mirroring the
|
||||
* uPlot legend — marker toggles one, label isolates), and persistence of the
|
||||
* hidden set to localStorage (keyed by `id`, matched by label) so it survives
|
||||
* reloads. Returns the visible slices, legend items, focus index, and the
|
||||
* legend container handlers.
|
||||
*/
|
||||
export function usePieInteractions(
|
||||
data: PieSlice[],
|
||||
id?: string,
|
||||
): UsePieInteractionsResult {
|
||||
const [active, setActive] = useState<PieSlice | null>(null);
|
||||
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const isolatedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
data.map((slice, index) => ({
|
||||
seriesIndex: index,
|
||||
label: slice.label,
|
||||
color: slice.color,
|
||||
show: !hiddenIndices.has(index),
|
||||
})),
|
||||
[data, hiddenIndices],
|
||||
);
|
||||
|
||||
// Hidden slices drop out so the remaining arcs + centre total recompute.
|
||||
const visibleData = useMemo(
|
||||
() => data.filter((_, index) => !hiddenIndices.has(index)),
|
||||
[data, hiddenIndices],
|
||||
);
|
||||
|
||||
// Rehydrate hide/unhide from localStorage (matched by label) whenever the
|
||||
// data set changes — including first load and every refetch, since the store
|
||||
// is the source of truth and toggles write back to it.
|
||||
useEffect(() => {
|
||||
if (!id || !data.length) {
|
||||
return;
|
||||
}
|
||||
const stored = getStoredSeriesVisibility(id);
|
||||
if (!stored) {
|
||||
return;
|
||||
}
|
||||
const hidden = new Set<number>();
|
||||
data.forEach((slice, index) => {
|
||||
if (stored.find((s) => s.label === slice.label)?.show === false) {
|
||||
hidden.add(index);
|
||||
}
|
||||
});
|
||||
setHiddenIndices(hidden);
|
||||
}, [id, data]);
|
||||
|
||||
// Apply a new hidden set and persist it (label + show) to localStorage.
|
||||
const applyHidden = useCallback(
|
||||
(hidden: Set<number>): void => {
|
||||
setHiddenIndices(hidden);
|
||||
if (id) {
|
||||
updateSeriesVisibilityToLocalStorage(
|
||||
id,
|
||||
data.map((slice, index) => ({
|
||||
label: slice.label,
|
||||
show: !hidden.has(index),
|
||||
})),
|
||||
);
|
||||
}
|
||||
},
|
||||
[id, data],
|
||||
);
|
||||
|
||||
const onLegendMouseMove = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(e);
|
||||
// Don't focus/dim for hidden slices — they aren't on the donut.
|
||||
setActive(index != null && !hiddenIndices.has(index) ? data[index] : null);
|
||||
},
|
||||
[data, hiddenIndices],
|
||||
);
|
||||
|
||||
// Marker click toggles just that slice on/off; label click isolates it
|
||||
// (clicking the isolated one again resets to all) — mirrors the uPlot legend.
|
||||
const onLegendClick = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(e);
|
||||
if (index == null) {
|
||||
return;
|
||||
}
|
||||
const isMarker = (e.target as HTMLElement).dataset.isLegendMarker;
|
||||
|
||||
if (isMarker) {
|
||||
const next = new Set(hiddenIndices);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
}
|
||||
applyHidden(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const isReset = isolatedIndexRef.current === index;
|
||||
isolatedIndexRef.current = isReset ? null : index;
|
||||
if (isReset) {
|
||||
applyHidden(new Set());
|
||||
return;
|
||||
}
|
||||
const next = new Set<number>();
|
||||
data.forEach((_, i) => {
|
||||
if (i !== index) {
|
||||
next.add(i);
|
||||
}
|
||||
});
|
||||
applyHidden(next);
|
||||
},
|
||||
[data, hiddenIndices, applyHidden],
|
||||
);
|
||||
|
||||
const onLegendMouseLeave = useCallback((): void => setActive(null), []);
|
||||
|
||||
const focusedIndex = active ? data.indexOf(active) : -1;
|
||||
|
||||
return {
|
||||
active,
|
||||
setActive,
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
};
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export const deploymentWidgetInfo = [
|
||||
yAxisUnit: '',
|
||||
},
|
||||
{
|
||||
title: 'Memory usage, request, limits)',
|
||||
title: 'Memory usage, request, limits',
|
||||
yAxisUnit: 'bytes',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -69,7 +69,7 @@ export const jobWidgetInfo = [
|
||||
yAxisUnit: '',
|
||||
},
|
||||
{
|
||||
title: 'Memory usage, request, limits',
|
||||
title: 'Memory Usage',
|
||||
yAxisUnit: 'bytes',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -703,7 +703,7 @@ export const getNamespaceMetricsQueryPayload = (
|
||||
],
|
||||
having: [],
|
||||
legend: `{{${k8sPodNameKey}}}`,
|
||||
limit: 20,
|
||||
limit: 10,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
@@ -1014,8 +1014,8 @@ export const getNamespaceMetricsQueryPayload = (
|
||||
id: '5f2a55c5',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: k8sStatefulsetNameKey,
|
||||
key: k8sStatefulsetNameKey,
|
||||
id: k8sNamespaceNameKey,
|
||||
key: k8sNamespaceNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
|
||||
@@ -317,9 +317,9 @@ export const getVolumeMetricsQueryPayload = (
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: 'k8s_volume_inodes_used--float64----true',
|
||||
id: 'k8s_volume_inodes_used--float64--Gauge--true',
|
||||
key: k8sVolumeInodesUsedKey,
|
||||
type: '',
|
||||
type: 'Gauge',
|
||||
},
|
||||
aggregateOperator: 'avg',
|
||||
dataSource: DataSource.METRICS,
|
||||
@@ -409,9 +409,9 @@ export const getVolumeMetricsQueryPayload = (
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: 'k8s_volume_inodes--float64----true',
|
||||
id: 'k8s_volume_inodes--float64--Gauge--true',
|
||||
key: k8sVolumeInodesKey,
|
||||
type: '',
|
||||
type: 'Gauge',
|
||||
},
|
||||
aggregateOperator: 'avg',
|
||||
dataSource: DataSource.METRICS,
|
||||
@@ -501,9 +501,9 @@ export const getVolumeMetricsQueryPayload = (
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: 'k8s_volume_inodes_free--float64----true',
|
||||
id: 'k8s_volume_inodes_free--float64--Gauge--true',
|
||||
key: k8sVolumeInodesFreeKey,
|
||||
type: '',
|
||||
type: 'Gauge',
|
||||
},
|
||||
aggregateOperator: 'avg',
|
||||
dataSource: DataSource.METRICS,
|
||||
|
||||
@@ -1619,6 +1619,9 @@ export const getHostQueryPayload = (
|
||||
const diskOpTimeKey = dotMetricsEnabled
|
||||
? 'system.disk.operation_time'
|
||||
: 'system_disk_operation_time';
|
||||
const diskOpsKey = dotMetricsEnabled
|
||||
? 'system.disk.operations'
|
||||
: 'system_disk_operations';
|
||||
const diskPendingKey = dotMetricsEnabled
|
||||
? 'system.disk.pending_operations'
|
||||
: 'system_disk_pending_operations';
|
||||
@@ -2375,9 +2378,24 @@ export const getHostQueryPayload = (
|
||||
op: 'AND',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
groupBy: [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
id: 'direction--string--tag--false',
|
||||
|
||||
key: 'direction',
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
id: 'device--string--tag--false',
|
||||
|
||||
key: 'device',
|
||||
type: 'tag',
|
||||
},
|
||||
],
|
||||
having: [],
|
||||
legend: 'system disk io',
|
||||
legend: '{{device}}::{{direction}}',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
@@ -2409,9 +2427,9 @@ export const getHostQueryPayload = (
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: 'system_disk_operation_time--float64--Sum--true',
|
||||
id: 'system_disk_operations--float64--Sum--true',
|
||||
|
||||
key: diskOpTimeKey,
|
||||
key: diskOpsKey,
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'rate',
|
||||
@@ -2421,7 +2439,7 @@ export const getHostQueryPayload = (
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: 'diskop_f1',
|
||||
id: 'diskops_f1',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'host_name--string--tag--false',
|
||||
@@ -2454,7 +2472,7 @@ export const getHostQueryPayload = (
|
||||
],
|
||||
having: [
|
||||
{
|
||||
columnName: `SUM(${diskOpTimeKey})`,
|
||||
columnName: `SUM(${diskOpsKey})`,
|
||||
op: '>',
|
||||
value: 0,
|
||||
},
|
||||
@@ -2557,6 +2575,88 @@ export const getHostQueryPayload = (
|
||||
start,
|
||||
end,
|
||||
},
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TIME_SERIES,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: 'system_disk_operation_time--float64--Sum--true',
|
||||
|
||||
key: diskOpTimeKey,
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'rate',
|
||||
dataSource: DataSource.METRICS,
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: 'diskoptime_f1',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'host_name--string--tag--false',
|
||||
|
||||
key: hostNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: hostName,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
id: 'device--string--tag--false',
|
||||
|
||||
key: 'device',
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
id: 'direction--string--tag--false',
|
||||
|
||||
key: 'direction',
|
||||
type: 'tag',
|
||||
},
|
||||
],
|
||||
having: [
|
||||
{
|
||||
columnName: `SUM(${diskOpTimeKey})`,
|
||||
op: '>',
|
||||
value: 0,
|
||||
},
|
||||
],
|
||||
legend: '{{device}}::{{direction}}',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'rate',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||
id: 'a8b3d2e1-4f5c-4a6b-9c8d-7e2f1a0b3c4f',
|
||||
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
variables: {},
|
||||
formatForWeb: false,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -2631,5 +2731,5 @@ export const hostWidgetInfo = [
|
||||
{ title: 'System disk io (bytes transferred)', yAxisUnit: 'bytes' },
|
||||
{ title: 'System disk operations/s', yAxisUnit: 'short' },
|
||||
{ title: 'Queue size', yAxisUnit: 'short' },
|
||||
{ title: 'Disk operations time', yAxisUnit: 's' },
|
||||
{ title: 'System disk operation time/s', yAxisUnit: 's' },
|
||||
];
|
||||
|
||||
@@ -96,14 +96,28 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { domainToAdminEmailList, ...rest } = config;
|
||||
const {
|
||||
domainToAdminEmailList,
|
||||
allowedGroups,
|
||||
serviceAccountJson,
|
||||
domainToAdminEmail: _domainToAdminEmail,
|
||||
fetchTransitiveGroupMembership,
|
||||
...rest
|
||||
} = config;
|
||||
const domainToAdminEmail = convertDomainMappingsToRecord(
|
||||
domainToAdminEmailList,
|
||||
);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
domainToAdminEmail: domainToAdminEmail ?? {},
|
||||
...(rest.fetchGroups
|
||||
? {
|
||||
allowedGroups,
|
||||
serviceAccountJson,
|
||||
domainToAdminEmail: domainToAdminEmail ?? {},
|
||||
fetchTransitiveGroupMembership,
|
||||
}
|
||||
: { domainToAdminEmail: {} }),
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
@@ -129,7 +143,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
|
||||
return {
|
||||
...rest,
|
||||
groupMappings: groupMappings ?? {},
|
||||
groupMappings: rest.useRoleAttribute ? undefined : (groupMappings ?? {}),
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { AuthtypesGettableAuthDomainDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import CreateEdit from '../CreateEdit/CreateEdit';
|
||||
import {
|
||||
AUTH_DOMAINS_UPDATE_ENDPOINT,
|
||||
mockDomainWithRoleMapping,
|
||||
mockGoogleAuthDomain,
|
||||
mockGoogleAuthWithWorkspaceGroups,
|
||||
mockOidcWithClaimMapping,
|
||||
mockSamlWithAttributeMapping,
|
||||
mockUpdateSuccessResponse,
|
||||
} from './mocks';
|
||||
|
||||
// @signozhq/ui/button internal effects block form.validateFields() in tests
|
||||
jest.mock('@signozhq/ui/button', () => ({
|
||||
...jest.requireActual('@signozhq/ui/button'),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
loading,
|
||||
disabled,
|
||||
'aria-label': ariaLabel,
|
||||
prefix,
|
||||
suffix,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
'aria-label'?: string;
|
||||
prefix?: React.ReactNode;
|
||||
suffix?: React.ReactNode;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{prefix}
|
||||
{children}
|
||||
{suffix}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
type SavedPayload = {
|
||||
config: {
|
||||
googleAuthConfig?: Record<string, unknown>;
|
||||
samlConfig?: Record<string, unknown>;
|
||||
oidcConfig?: Record<string, unknown>;
|
||||
roleMapping?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
async function submitForm(
|
||||
record: AuthtypesGettableAuthDomainDTO,
|
||||
): Promise<SavedPayload> {
|
||||
const requests: SavedPayload[] = [];
|
||||
|
||||
server.use(
|
||||
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
|
||||
requests.push((await req.json()) as SavedPayload);
|
||||
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
render(<CreateEdit isCreate={false} record={record} onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
await waitFor(() => expect(requests).toHaveLength(1));
|
||||
|
||||
return requests[0];
|
||||
}
|
||||
|
||||
describe('CreateEdit — payload sanitization', () => {
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
describe('Google Auth', () => {
|
||||
it('sends core fields and omits workspace fields when fetchGroups is not set', async () => {
|
||||
const payload = await submitForm(mockGoogleAuthDomain);
|
||||
|
||||
const g = payload.config.googleAuthConfig;
|
||||
expect(g?.clientId).toBe('test-client-id');
|
||||
expect(g?.clientSecret).toBe('test-client-secret');
|
||||
expect(g?.allowedGroups).toBeUndefined();
|
||||
expect(g?.serviceAccountJson).toBeUndefined();
|
||||
expect(g?.fetchTransitiveGroupMembership).toBeUndefined();
|
||||
expect(g?.domainToAdminEmail).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('strips workspace fields when fetchGroups is false', async () => {
|
||||
const payload = await submitForm({
|
||||
...mockGoogleAuthWithWorkspaceGroups,
|
||||
config: {
|
||||
...mockGoogleAuthWithWorkspaceGroups.config,
|
||||
googleAuthConfig: {
|
||||
...mockGoogleAuthWithWorkspaceGroups.config?.googleAuthConfig,
|
||||
fetchGroups: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const g = payload.config.googleAuthConfig;
|
||||
expect(g?.fetchGroups).toBe(false);
|
||||
expect(g?.allowedGroups).toBeUndefined();
|
||||
expect(g?.serviceAccountJson).toBeUndefined();
|
||||
expect(g?.fetchTransitiveGroupMembership).toBeUndefined();
|
||||
expect(g?.domainToAdminEmail).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('includes all workspace fields when fetchGroups is true', async () => {
|
||||
const payload = await submitForm(mockGoogleAuthWithWorkspaceGroups);
|
||||
|
||||
const g = payload.config.googleAuthConfig;
|
||||
expect(g?.fetchGroups).toBe(true);
|
||||
expect(g?.serviceAccountJson).toBe('{"type": "service_account"}');
|
||||
expect(g?.fetchTransitiveGroupMembership).toBe(true);
|
||||
expect(g?.allowedGroups).toStrictEqual([
|
||||
'allowed-group-1',
|
||||
'allowed-group-2',
|
||||
]);
|
||||
expect(g?.domainToAdminEmail).toStrictEqual({
|
||||
'google-groups.com': 'admin@google-groups.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SAML', () => {
|
||||
it('sends core and attributeMapping fields', async () => {
|
||||
const payload = await submitForm(mockSamlWithAttributeMapping);
|
||||
|
||||
const s = payload.config.samlConfig;
|
||||
expect(s?.samlIdp).toBe('https://idp.saml-attrs.com/sso');
|
||||
expect(s?.samlEntity).toBe('urn:saml-attrs:idp');
|
||||
expect(s?.samlCert).toBe('MOCK_CERTIFICATE_ATTRS');
|
||||
expect(s?.insecureSkipAuthNRequestsSigned).toBe(true);
|
||||
|
||||
const attr = s?.attributeMapping as Record<string, unknown>;
|
||||
expect(attr?.name).toBe('user_display_name');
|
||||
expect(attr?.groups).toBe('member_of');
|
||||
expect(attr?.role).toBe('signoz_role');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC', () => {
|
||||
it('sends all fields including claimMapping', async () => {
|
||||
const payload = await submitForm(mockOidcWithClaimMapping);
|
||||
|
||||
const o = payload.config.oidcConfig;
|
||||
expect(o?.issuer).toBe('https://oidc.claims.com');
|
||||
expect(o?.issuerAlias).toBe('https://alias.claims.com');
|
||||
expect(o?.clientId).toBe('claims-client-id');
|
||||
expect(o?.clientSecret).toBe('claims-client-secret');
|
||||
expect(o?.insecureSkipEmailVerified).toBe(true);
|
||||
expect(o?.getUserInfo).toBe(true);
|
||||
|
||||
const claim = o?.claimMapping as Record<string, unknown>;
|
||||
expect(claim?.email).toBe('user_email');
|
||||
expect(claim?.name).toBe('display_name');
|
||||
expect(claim?.groups).toBe('user_groups');
|
||||
expect(claim?.role).toBe('user_role');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role Mapping', () => {
|
||||
it('strips groupMappings when useRoleAttribute is true', async () => {
|
||||
const payload = await submitForm({
|
||||
...mockDomainWithRoleMapping,
|
||||
config: {
|
||||
...mockDomainWithRoleMapping.config,
|
||||
roleMapping: {
|
||||
...mockDomainWithRoleMapping.config?.roleMapping,
|
||||
useRoleAttribute: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.config.roleMapping?.useRoleAttribute).toBe(true);
|
||||
expect(payload.config.roleMapping?.groupMappings).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends groupMappings when useRoleAttribute is false', async () => {
|
||||
const payload = await submitForm(mockDomainWithRoleMapping);
|
||||
|
||||
expect(payload.config.roleMapping?.useRoleAttribute).toBe(false);
|
||||
expect(payload.config.roleMapping?.groupMappings).toStrictEqual({
|
||||
'admin-group': 'ADMIN',
|
||||
'dev-team': 'EDITOR',
|
||||
viewers: 'VIEWER',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,11 +22,12 @@ export const StyledCheckOutlined = styled(Check)`
|
||||
float: right;
|
||||
`;
|
||||
|
||||
export const TagContainer = styled(Badge)`
|
||||
export const TagContainer = styled(Badge).attrs({
|
||||
color: 'secondary',
|
||||
variant: 'outline',
|
||||
})`
|
||||
&&& {
|
||||
display: flex;
|
||||
border-radius: 3px;
|
||||
padding: 0.1rem 0.2rem;
|
||||
font-weight: 300;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
@@ -38,4 +39,5 @@ export const TagLabel = styled.span`
|
||||
|
||||
export const TagValue = styled.span`
|
||||
text-transform: capitalize;
|
||||
font-weight: 400;
|
||||
`;
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
|
||||
describe('getLabelName', () => {
|
||||
describe('with a legend template', () => {
|
||||
it('substitutes a single variable that exists on the series', () => {
|
||||
const result = getLabelName(
|
||||
{ 'service.name': 'frontend' },
|
||||
'A',
|
||||
'{{service.name}}',
|
||||
);
|
||||
expect(result).toBe('frontend');
|
||||
});
|
||||
|
||||
it('substitutes a template with surrounding literal text', () => {
|
||||
const result = getLabelName(
|
||||
{ 'service.name': 'frontend' },
|
||||
'A',
|
||||
'rate for {{service.name}}',
|
||||
);
|
||||
expect(result).toBe('rate for frontend');
|
||||
});
|
||||
|
||||
it('substitutes multiple variables when all are present', () => {
|
||||
const result = getLabelName(
|
||||
{ 'service.name': 'frontend', 'http.target': 'GET /api' },
|
||||
'A',
|
||||
'{{service.name}} / {{http.target}}',
|
||||
);
|
||||
expect(result).toBe('frontend / GET /api');
|
||||
});
|
||||
|
||||
it('falls back to query name when a referenced variable is missing', () => {
|
||||
const result = getLabelName(
|
||||
{ 'http.target': 'GET /api' },
|
||||
'F1',
|
||||
'{{service.name}}',
|
||||
);
|
||||
expect(result).toBe('F1');
|
||||
});
|
||||
|
||||
it('falls back to query name even if literal text would still render', () => {
|
||||
const result = getLabelName(
|
||||
{ 'http.target': 'GET /api' },
|
||||
'F1',
|
||||
'label = {{label}}',
|
||||
);
|
||||
expect(result).toBe('F1');
|
||||
});
|
||||
|
||||
it('falls back to query name when any of multiple variables is missing', () => {
|
||||
const result = getLabelName(
|
||||
{ 'service.name': 'frontend' },
|
||||
'F1',
|
||||
'{{service.name}} / {{http.target}}',
|
||||
);
|
||||
expect(result).toBe('F1');
|
||||
});
|
||||
|
||||
it('treats a null label value as missing', () => {
|
||||
const result = getLabelName(
|
||||
{ 'service.name': null } as unknown as Record<string, string>,
|
||||
'F1',
|
||||
'{{service.name}}',
|
||||
);
|
||||
expect(result).toBe('F1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('without a legend template', () => {
|
||||
it('returns key="value" pairs for plain labels', () => {
|
||||
const result = getLabelName(
|
||||
{ 'service.name': 'frontend', 'http.target': 'GET /api' },
|
||||
'A',
|
||||
'',
|
||||
);
|
||||
expect(result).toBe('{service.name="frontend",http.target="GET /api"}');
|
||||
});
|
||||
|
||||
it('returns query name when labels are empty', () => {
|
||||
const result = getLabelName({}, 'A', '');
|
||||
expect(result).toBe('A');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,17 +18,6 @@ const getLabelName = (
|
||||
|
||||
const results = variables.map((variable) => metric[variable]);
|
||||
|
||||
// Fall back to query name if any `{{var}}` references a label that
|
||||
// isn't on this series — avoids rendering "undefined" in the legend.
|
||||
const hasMissingVariable = variables.some(
|
||||
(variable, index) =>
|
||||
legends.includes(`{{${variable}}}`) &&
|
||||
(results[index] === undefined || results[index] === null),
|
||||
);
|
||||
if (hasMissingVariable) {
|
||||
return query;
|
||||
}
|
||||
|
||||
let endResult = legends;
|
||||
|
||||
variables.forEach((e, index) => {
|
||||
|
||||
5
frontend/src/lib/recentQueries/constants.ts
Normal file
5
frontend/src/lib/recentQueries/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const STORAGE_KEY_PREFIX = 'qb_recent_v1';
|
||||
export const STORAGE_VERSION = 1;
|
||||
// Maximum entries kept per (signal, source) bucket. Larger than the UI's
|
||||
// RECENTS_DISPLAY_CAP so deleting a visible entry surfaces an older one.
|
||||
export const MAX_ENTRIES = 10;
|
||||
15
frontend/src/lib/recentQueries/getRecentQueries.ts
Normal file
15
frontend/src/lib/recentQueries/getRecentQueries.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { SignalType } from 'types/api/v5/queryRange';
|
||||
|
||||
import * as store from './recentQueriesStore';
|
||||
import type { RecentQueryEntry } from './types';
|
||||
|
||||
// Synchronous, non-subscribing read of the recent-queries bucket for a given
|
||||
// (signal, source). Read-on-demand by design — subscribing here would
|
||||
// reconfigure CodeMirror on every store change and close any open completion
|
||||
// popup. Pair with saveQuery() for the write path.
|
||||
export function getRecentQueries(
|
||||
signal: SignalType,
|
||||
source = '',
|
||||
): RecentQueryEntry[] {
|
||||
return store.list(signal, source);
|
||||
}
|
||||
100
frontend/src/lib/recentQueries/normalize.test.ts
Normal file
100
frontend/src/lib/recentQueries/normalize.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { normalizeFilterExpression } from './normalize';
|
||||
|
||||
describe('normalizeFilterExpression', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(normalizeFilterExpression('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for whitespace-only input', () => {
|
||||
expect(normalizeFilterExpression(' \t ')).toBe('');
|
||||
});
|
||||
|
||||
it('strips whitespace around operators', () => {
|
||||
expect(normalizeFilterExpression(' a = 1 ')).toBe('a=1');
|
||||
expect(normalizeFilterExpression('a = 1')).toBe('a=1');
|
||||
expect(normalizeFilterExpression('a=1')).toBe('a=1');
|
||||
});
|
||||
|
||||
it('lowercases AND / OR / NOT outside quotes', () => {
|
||||
expect(normalizeFilterExpression('A AND B OR NOT C')).toBe('AandBornotC');
|
||||
});
|
||||
|
||||
it('lowercases IN / LIKE / ILIKE / CONTAINS', () => {
|
||||
expect(normalizeFilterExpression('host IN [1, 2] AND name LIKE "foo"')).toBe(
|
||||
'hostin[1,2]andnamelike"foo"',
|
||||
);
|
||||
});
|
||||
|
||||
it('lowercases REGEXP', () => {
|
||||
expect(normalizeFilterExpression('path REGEXP "foo"')).toBe(
|
||||
'pathregexp"foo"',
|
||||
);
|
||||
expect(normalizeFilterExpression('path REGEXP "foo"')).toBe(
|
||||
normalizeFilterExpression('path regexp "foo"'),
|
||||
);
|
||||
});
|
||||
|
||||
it('lowercases HAS / HASANY / HASALL / HASTOKEN function names', () => {
|
||||
expect(normalizeFilterExpression('HAS(tags, "x")')).toBe(
|
||||
normalizeFilterExpression('has(tags, "x")'),
|
||||
);
|
||||
expect(normalizeFilterExpression('HASANY(tags, ["a","b"])')).toBe(
|
||||
normalizeFilterExpression('hasAny(tags, ["a","b"])'),
|
||||
);
|
||||
expect(normalizeFilterExpression('HASALL(tags, ["a","b"])')).toBe(
|
||||
normalizeFilterExpression('hasAll(tags, ["a","b"])'),
|
||||
);
|
||||
expect(normalizeFilterExpression('HASTOKEN(msg, "err")')).toBe(
|
||||
normalizeFilterExpression('hasToken(msg, "err")'),
|
||||
);
|
||||
});
|
||||
|
||||
it('lowercases TRUE / FALSE boolean literals', () => {
|
||||
expect(normalizeFilterExpression('active = TRUE')).toBe(
|
||||
normalizeFilterExpression('active = true'),
|
||||
);
|
||||
expect(normalizeFilterExpression('active = FALSE')).toBe(
|
||||
normalizeFilterExpression('active = false'),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves whitespace and casing inside single-quoted strings', () => {
|
||||
expect(normalizeFilterExpression("a = 'X Y'")).toBe("a='X Y'");
|
||||
});
|
||||
|
||||
it('preserves whitespace and casing inside double-quoted strings', () => {
|
||||
expect(normalizeFilterExpression('a = "X Y"')).toBe('a="X Y"');
|
||||
});
|
||||
|
||||
it('does not lowercase keyword-looking substrings inside quotes', () => {
|
||||
expect(normalizeFilterExpression("msg = 'AND ERROR'")).toBe(
|
||||
"msg='AND ERROR'",
|
||||
);
|
||||
});
|
||||
|
||||
it('handles escaped quotes inside strings', () => {
|
||||
expect(normalizeFilterExpression("msg = 'a\\'b' AND x = 1")).toBe(
|
||||
"msg='a\\'b'andx=1",
|
||||
);
|
||||
});
|
||||
|
||||
it('treats two formattings of the same expression as identical', () => {
|
||||
const a = normalizeFilterExpression(
|
||||
'service.name = "frontend" AND severity = error',
|
||||
);
|
||||
const b = normalizeFilterExpression(
|
||||
'service.name="frontend" and severity=error',
|
||||
);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('preserves unquoted value casing (treats them as identifiers)', () => {
|
||||
expect(normalizeFilterExpression('status = OK')).toBe('status=OK');
|
||||
});
|
||||
|
||||
it('handles mixed quotes in one expression', () => {
|
||||
expect(normalizeFilterExpression(`a = 'X' AND b = "Y"`)).toBe(
|
||||
`a='X'andb="Y"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
56
frontend/src/lib/recentQueries/normalize.ts
Normal file
56
frontend/src/lib/recentQueries/normalize.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
OPERATORS,
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
TRACE_OPERATOR_OPERATORS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
|
||||
// Reserved keywords sourced from the ANTLR grammar constants so this list stays
|
||||
// in sync with the parser. `\b` prevents partial matches inside identifiers
|
||||
// (e.g. `OR` inside `originator`). `TRUE`/`FALSE` are BOOL literals, included
|
||||
// so case variants of boolean values also dedup.
|
||||
const WORD_KEYWORDS = [
|
||||
...Object.keys(OPERATORS).filter((k) => /^[A-Z]+$/.test(k)),
|
||||
...Object.keys(TRACE_OPERATOR_OPERATORS).filter((k) => /^[A-Z]+$/.test(k)),
|
||||
...Object.values(QUERY_BUILDER_FUNCTIONS),
|
||||
'TRUE',
|
||||
'FALSE',
|
||||
];
|
||||
|
||||
const KEYWORDS_RE = new RegExp(`\\b(${WORD_KEYWORDS.join('|')})\\b`, 'gi');
|
||||
|
||||
// Matches single- or double-quoted string literals, supporting escaped quotes
|
||||
// (e.g. `'it\'s'` or `"a \" b"`). We preserve quoted spans verbatim during
|
||||
// normalisation so user-meaningful whitespace and casing inside string values
|
||||
// stays intact: `name = "Foo Bar"` must not collapse to `name="foobar"`.
|
||||
const QUOTED_RE = /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g;
|
||||
|
||||
// Lowercases reserved keywords and strips ALL whitespace from the unquoted regions
|
||||
// of the input. Keywords are normalised so casing variants dedup; whitespace is
|
||||
// dropped so formatting variants (`a=1` vs `a = 1`) dedup too.
|
||||
function processOutsideQuotes(s: string): string {
|
||||
return s.replace(KEYWORDS_RE, (m) => m.toLowerCase()).replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
// Produces a canonical form of a filter expression suitable for dedup-key derivation.
|
||||
// Walks the input alternating between unquoted regions (where we normalise keywords
|
||||
// and whitespace) and quoted regions (which we copy verbatim).
|
||||
export function normalizeFilterExpression(input: string): string {
|
||||
if (!input) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
QUOTED_RE.lastIndex = 0;
|
||||
|
||||
let match = QUOTED_RE.exec(input);
|
||||
while (match !== null) {
|
||||
result += processOutsideQuotes(input.slice(lastIndex, match.index));
|
||||
result += match[0];
|
||||
lastIndex = QUOTED_RE.lastIndex;
|
||||
match = QUOTED_RE.exec(input);
|
||||
}
|
||||
result += processOutsideQuotes(input.slice(lastIndex));
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
247
frontend/src/lib/recentQueries/recentQueriesStore.test.ts
Normal file
247
frontend/src/lib/recentQueries/recentQueriesStore.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { MAX_ENTRIES } from './constants';
|
||||
import * as store from './recentQueriesStore';
|
||||
import type { RecentQueryInput } from './recentQueriesStore';
|
||||
import type { RecentQueryEntry } from './types';
|
||||
|
||||
const baseInput = (
|
||||
overrides: Partial<RecentQueryInput> = {},
|
||||
): RecentQueryInput => ({
|
||||
signal: 'logs',
|
||||
filter: { expression: "service.name = 'frontend'" },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function saveOrThrow(input: RecentQueryInput): RecentQueryEntry {
|
||||
const saved = store.save(input);
|
||||
if (!saved) {
|
||||
throw new Error('expected save to return an entry');
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
describe('recentQueries store', () => {
|
||||
beforeEach(() => {
|
||||
store.useRecentQueriesStore.setState({ buckets: {} });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('save + list', () => {
|
||||
it('saves an entry and lists it', () => {
|
||||
store.save(baseInput());
|
||||
const entries = store.list('logs');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].filter.expression).toBe("service.name = 'frontend'");
|
||||
expect(entries[0].id).toBeTruthy();
|
||||
expect(entries[0].lastUsedAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not save when the filter expression is empty', () => {
|
||||
const result = store.save(baseInput({ filter: { expression: '' } }));
|
||||
expect(result).toBeNull();
|
||||
expect(store.list('logs')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not save when the filter expression is whitespace only', () => {
|
||||
const result = store.save(baseInput({ filter: { expression: ' ' } }));
|
||||
expect(result).toBeNull();
|
||||
expect(store.list('logs')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LRU ordering', () => {
|
||||
it('places the most recently saved entry at the front', () => {
|
||||
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
|
||||
store.save(baseInput({ filter: { expression: 'http.status_code >= 500' } }));
|
||||
store.save(baseInput({ filter: { expression: 'attempt = 1' } }));
|
||||
|
||||
const entries = store.list('logs');
|
||||
expect(entries.map((e) => e.filter.expression)).toStrictEqual([
|
||||
'attempt = 1',
|
||||
'http.status_code >= 500',
|
||||
"severity_text = 'ERROR'",
|
||||
]);
|
||||
});
|
||||
|
||||
it('re-saving an existing filter bumps it to the front', () => {
|
||||
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
|
||||
store.save(baseInput({ filter: { expression: 'http.status_code >= 500' } }));
|
||||
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
|
||||
|
||||
const entries = store.list('logs');
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries.map((e) => e.filter.expression)).toStrictEqual([
|
||||
"severity_text = 'ERROR'",
|
||||
'http.status_code >= 500',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dedup', () => {
|
||||
it('treats formatting variations of the same filter as one entry', () => {
|
||||
store.save(
|
||||
baseInput({
|
||||
filter: { expression: "severity_text = 'ERROR' AND attempt = 1" },
|
||||
}),
|
||||
);
|
||||
store.save(
|
||||
baseInput({
|
||||
filter: { expression: "severity_text='ERROR' and attempt=1" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.list('logs')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signal partitioning', () => {
|
||||
it('saves to the right bucket per signal', () => {
|
||||
store.save(
|
||||
baseInput({
|
||||
signal: 'logs',
|
||||
filter: { expression: "severity_text = 'ERROR'" },
|
||||
}),
|
||||
);
|
||||
store.save(
|
||||
baseInput({
|
||||
signal: 'traces',
|
||||
filter: { expression: "service.name = 'orders-api'" },
|
||||
}),
|
||||
);
|
||||
store.save(
|
||||
baseInput({
|
||||
signal: 'metrics',
|
||||
filter: { expression: 'cpu_usage > 80' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.list('logs')).toHaveLength(1);
|
||||
expect(store.list('traces')).toHaveLength(1);
|
||||
expect(store.list('metrics')).toHaveLength(1);
|
||||
expect(store.list('logs')[0].filter.expression).toBe(
|
||||
"severity_text = 'ERROR'",
|
||||
);
|
||||
expect(store.list('traces')[0].filter.expression).toBe(
|
||||
"service.name = 'orders-api'",
|
||||
);
|
||||
expect(store.list('metrics')[0].filter.expression).toBe('cpu_usage > 80');
|
||||
});
|
||||
|
||||
it('does not leak between signals on dedup', () => {
|
||||
store.save(
|
||||
baseInput({
|
||||
signal: 'logs',
|
||||
filter: { expression: "service.name = 'frontend'" },
|
||||
}),
|
||||
);
|
||||
store.save(
|
||||
baseInput({
|
||||
signal: 'traces',
|
||||
filter: { expression: "service.name = 'frontend'" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.list('logs')).toHaveLength(1);
|
||||
expect(store.list('traces')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LRU cap', () => {
|
||||
it('caps the bucket at MAX_ENTRIES and evicts the oldest', () => {
|
||||
const total = MAX_ENTRIES + 1;
|
||||
for (let i = 0; i < total; i += 1) {
|
||||
store.save(baseInput({ filter: { expression: `attempt = ${i}` } }));
|
||||
}
|
||||
|
||||
const entries = store.list('logs');
|
||||
expect(entries).toHaveLength(MAX_ENTRIES);
|
||||
expect(entries[0].filter.expression).toBe(`attempt = ${total - 1}`);
|
||||
expect(entries.some((e) => e.filter.expression === 'attempt = 0')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('removes an entry by id', () => {
|
||||
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
|
||||
const saved = saveOrThrow(
|
||||
baseInput({ filter: { expression: 'http.status_code >= 500' } }),
|
||||
);
|
||||
store.remove(saved.id, 'logs');
|
||||
|
||||
const entries = store.list('logs');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].filter.expression).toBe("severity_text = 'ERROR'");
|
||||
});
|
||||
|
||||
it('is a no-op when the id does not exist', () => {
|
||||
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
|
||||
store.remove('does-not-exist', 'logs');
|
||||
expect(store.list('logs')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not touch other signals', () => {
|
||||
const logsEntry = saveOrThrow(
|
||||
baseInput({
|
||||
signal: 'logs',
|
||||
filter: { expression: "service.name = 'frontend'" },
|
||||
}),
|
||||
);
|
||||
store.save(
|
||||
baseInput({
|
||||
signal: 'traces',
|
||||
filter: { expression: "service.name = 'frontend'" },
|
||||
}),
|
||||
);
|
||||
|
||||
store.remove(logsEntry.id, 'logs');
|
||||
|
||||
expect(store.list('logs')).toHaveLength(0);
|
||||
expect(store.list('traces')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
it('reads back the same entries after the in-memory state is reset', () => {
|
||||
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
|
||||
store.save(baseInput({ filter: { expression: 'http.status_code >= 500' } }));
|
||||
|
||||
store.useRecentQueriesStore.setState({ buckets: {} });
|
||||
|
||||
const entries = store.list('logs');
|
||||
expect(entries.map((e) => e.filter.expression)).toStrictEqual([
|
||||
'http.status_code >= 500',
|
||||
"severity_text = 'ERROR'",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reactive subscription via zustand', () => {
|
||||
it('notifies zustand subscribers on save', () => {
|
||||
const cb = jest.fn();
|
||||
const unsubscribe = store.useRecentQueriesStore.subscribe(cb);
|
||||
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('notifies zustand subscribers on remove', () => {
|
||||
const saved = saveOrThrow(
|
||||
baseInput({ filter: { expression: "severity_text = 'ERROR'" } }),
|
||||
);
|
||||
const cb = jest.fn();
|
||||
const unsubscribe = store.useRecentQueriesStore.subscribe(cb);
|
||||
store.remove(saved.id, 'logs');
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('stops notifying after unsubscribe', () => {
|
||||
const cb = jest.fn();
|
||||
const unsubscribe = store.useRecentQueriesStore.subscribe(cb);
|
||||
unsubscribe();
|
||||
store.save(baseInput({ filter: { expression: "severity_text = 'ERROR'" } }));
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
144
frontend/src/lib/recentQueries/recentQueriesStore.ts
Normal file
144
frontend/src/lib/recentQueries/recentQueriesStore.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import get from 'api/browser/localstorage/get';
|
||||
import set from 'api/browser/localstorage/set';
|
||||
import type { SignalType } from 'types/api/v5/queryRange';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { MAX_ENTRIES, STORAGE_VERSION } from './constants';
|
||||
import { normalizeFilterExpression } from './normalize';
|
||||
import type { RecentQueriesStoreShape, RecentQueryEntry } from './types';
|
||||
import { bucketKey, makeId, normalizeSource, storageKeyFor } from './utils';
|
||||
|
||||
// Mirrors parsed localStorage so equal raw strings return the same array ref —
|
||||
// preserves Object.is for zustand selector bail-out.
|
||||
const persistedBucketCache = new Map<
|
||||
string,
|
||||
{ raw: string; parsed: RecentQueryEntry[] }
|
||||
>();
|
||||
|
||||
function loadBucketFromStorage(
|
||||
signal: SignalType,
|
||||
source: string,
|
||||
): RecentQueryEntry[] | null {
|
||||
const key = bucketKey(signal, source);
|
||||
try {
|
||||
const raw = get(storageKeyFor(signal, source));
|
||||
if (!raw) {
|
||||
persistedBucketCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
const cached = persistedBucketCache.get(key);
|
||||
if (cached && cached.raw === raw) {
|
||||
return cached.parsed;
|
||||
}
|
||||
const parsedShape = JSON.parse(raw) as RecentQueriesStoreShape;
|
||||
if (
|
||||
parsedShape?.version !== STORAGE_VERSION ||
|
||||
!Array.isArray(parsedShape.entries)
|
||||
) {
|
||||
persistedBucketCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
persistedBucketCache.set(key, { raw, parsed: parsedShape.entries });
|
||||
return parsedShape.entries;
|
||||
} catch {
|
||||
persistedBucketCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveBucketToStorage(
|
||||
signal: SignalType,
|
||||
source: string,
|
||||
entries: RecentQueryEntry[],
|
||||
): void {
|
||||
try {
|
||||
const raw = JSON.stringify({ version: STORAGE_VERSION, entries });
|
||||
if (set(storageKeyFor(signal, source), raw)) {
|
||||
persistedBucketCache.set(bucketKey(signal, source), {
|
||||
raw,
|
||||
parsed: entries,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors (e.g. quota exceeded, JSON.stringify failure).
|
||||
}
|
||||
}
|
||||
|
||||
export type RecentQueryInput = Omit<
|
||||
RecentQueryEntry,
|
||||
'id' | 'lastUsedAt' | 'source'
|
||||
> & {
|
||||
source?: string;
|
||||
};
|
||||
|
||||
type RecentQueriesState = {
|
||||
buckets: Record<string, RecentQueryEntry[]>;
|
||||
save: (entry: RecentQueryInput) => RecentQueryEntry | null;
|
||||
remove: (id: string, signal: SignalType, source?: string) => void;
|
||||
};
|
||||
|
||||
export const useRecentQueriesStore = create<RecentQueriesState>()(
|
||||
(set, get) => ({
|
||||
buckets: {},
|
||||
|
||||
save: (entry): RecentQueryEntry | null => {
|
||||
const normalized = normalizeFilterExpression(entry.filter.expression);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const source = normalizeSource(entry.source);
|
||||
const key = bucketKey(entry.signal, source);
|
||||
|
||||
const current =
|
||||
get().buckets[key] ?? loadBucketFromStorage(entry.signal, source) ?? [];
|
||||
const filtered = current.filter(
|
||||
(e) => normalizeFilterExpression(e.filter.expression) !== normalized,
|
||||
);
|
||||
|
||||
const newEntry: RecentQueryEntry = {
|
||||
...entry,
|
||||
source,
|
||||
id: makeId(entry.signal, source, normalized),
|
||||
lastUsedAt: Date.now(),
|
||||
};
|
||||
|
||||
const next = [newEntry, ...filtered].slice(0, MAX_ENTRIES);
|
||||
set({ buckets: { ...get().buckets, [key]: next } });
|
||||
saveBucketToStorage(entry.signal, source, next);
|
||||
return newEntry;
|
||||
},
|
||||
|
||||
remove: (id, signal, source = ''): void => {
|
||||
const key = bucketKey(signal, source);
|
||||
const current = get().buckets[key] ?? loadBucketFromStorage(signal, source);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const next = current.filter((e) => e.id !== id);
|
||||
if (next.length === current.length) {
|
||||
return;
|
||||
}
|
||||
set({ buckets: { ...get().buckets, [key]: next } });
|
||||
saveBucketToStorage(signal, source, next);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Plain-function wrappers for non-React callers — same pattern as useColumnStore.ts.
|
||||
export function save(entry: RecentQueryInput): RecentQueryEntry | null {
|
||||
return useRecentQueriesStore.getState().save(entry);
|
||||
}
|
||||
|
||||
export function remove(id: string, signal: SignalType, source = ''): void {
|
||||
useRecentQueriesStore.getState().remove(id, signal, source);
|
||||
}
|
||||
|
||||
// Synchronous bucket read with localStorage fallback for non-React callers.
|
||||
export function list(signal: SignalType, source = ''): RecentQueryEntry[] {
|
||||
const key = bucketKey(signal, source);
|
||||
const state = useRecentQueriesStore.getState();
|
||||
if (state.buckets[key]) {
|
||||
return state.buckets[key];
|
||||
}
|
||||
return loadBucketFromStorage(signal, source) ?? [];
|
||||
}
|
||||
113
frontend/src/lib/recentQueries/saveRecentQuery.test.ts
Normal file
113
frontend/src/lib/recentQueries/saveRecentQuery.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
|
||||
import * as store from './recentQueriesStore';
|
||||
import { saveRecentQuery } from './saveRecentQuery';
|
||||
|
||||
jest.mock('utils/queryValidationUtils', () => ({
|
||||
validateQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedValidateQuery = validateQuery as jest.MockedFunction<
|
||||
typeof validateQuery
|
||||
>;
|
||||
|
||||
const buildComposite = (
|
||||
overrides: Partial<IBuilderQuery>[] = [{}],
|
||||
): { builder: { queryData: IBuilderQuery[] } } => ({
|
||||
builder: {
|
||||
queryData: overrides.map((o, i) => ({
|
||||
queryName: `Q${i}`,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: undefined as never,
|
||||
functions: [],
|
||||
filter: { expression: 'service.name = "frontend"' },
|
||||
groupBy: [],
|
||||
expression: `Q${i}`,
|
||||
disabled: false,
|
||||
having: [],
|
||||
limit: null,
|
||||
stepInterval: null,
|
||||
orderBy: [],
|
||||
legend: '',
|
||||
...o,
|
||||
})) as IBuilderQuery[],
|
||||
},
|
||||
});
|
||||
|
||||
describe('saveRecentQuery', () => {
|
||||
beforeEach(() => {
|
||||
store.useRecentQueriesStore.setState({ buckets: {} });
|
||||
localStorage.clear();
|
||||
mockedValidateQuery.mockReturnValue({
|
||||
isValid: true,
|
||||
message: '',
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('saves the composite query when validation passes', () => {
|
||||
saveRecentQuery(buildComposite());
|
||||
|
||||
const entries = store.list('logs');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].filter.expression).toBe('service.name = "frontend"');
|
||||
});
|
||||
|
||||
it('does not save when validateQuery rejects the expression', () => {
|
||||
mockedValidateQuery.mockReturnValue({
|
||||
isValid: false,
|
||||
message: 'bad',
|
||||
errors: [],
|
||||
});
|
||||
|
||||
saveRecentQuery(buildComposite());
|
||||
|
||||
expect(store.list('logs')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not save a builder query with an empty filter expression', () => {
|
||||
saveRecentQuery(buildComposite([{ filter: { expression: '' } }]));
|
||||
|
||||
expect(store.list('logs')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('saves each builder query in the composite separately', () => {
|
||||
saveRecentQuery(
|
||||
buildComposite([
|
||||
{
|
||||
dataSource: DataSource.LOGS,
|
||||
filter: { expression: "service.name = 'frontend'" },
|
||||
},
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
filter: { expression: "service.name = 'orders-api'" },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(store.list('logs')).toHaveLength(1);
|
||||
expect(store.list('traces')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips builder queries whose dataSource is not a supported signal', () => {
|
||||
saveRecentQuery(
|
||||
buildComposite([{ dataSource: 'unknown' as IBuilderQuery['dataSource'] }]),
|
||||
);
|
||||
|
||||
expect(store.list('logs')).toHaveLength(0);
|
||||
expect(store.list('traces')).toHaveLength(0);
|
||||
expect(store.list('metrics')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is a no-op when the composite is null, undefined, or empty', () => {
|
||||
saveRecentQuery(null);
|
||||
saveRecentQuery(undefined);
|
||||
saveRecentQuery({ builder: { queryData: [] } });
|
||||
saveRecentQuery({});
|
||||
|
||||
expect(store.list('logs')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
52
frontend/src/lib/recentQueries/saveRecentQuery.ts
Normal file
52
frontend/src/lib/recentQueries/saveRecentQuery.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import type { SignalType } from 'types/api/v5/queryRange';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
|
||||
import * as store from './recentQueriesStore';
|
||||
|
||||
function toSignal(dataSource: IBuilderQuery['dataSource']): SignalType | null {
|
||||
if (
|
||||
dataSource === 'logs' ||
|
||||
dataSource === 'traces' ||
|
||||
dataSource === 'metrics'
|
||||
) {
|
||||
return dataSource;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type CompositeWithBuilder = {
|
||||
builder?: { queryData?: IBuilderQuery[] };
|
||||
};
|
||||
|
||||
// Persists each builder query in the composite as a recent entry. Call this
|
||||
// only from explicit user-driven Run triggers — reacting to stagedQuery or any
|
||||
// other derived state pollutes recents with navigation/refresh/go-to traffic.
|
||||
export function saveRecentQuery(
|
||||
query: CompositeWithBuilder | null | undefined,
|
||||
): void {
|
||||
const queryData = query?.builder?.queryData;
|
||||
if (!Array.isArray(queryData) || queryData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryData.forEach((q) => {
|
||||
const expression = q.filter?.expression?.trim();
|
||||
if (!expression) {
|
||||
return;
|
||||
}
|
||||
const validation = validateQuery(expression);
|
||||
if (!validation.isValid) {
|
||||
return;
|
||||
}
|
||||
const signal = toSignal(q.dataSource);
|
||||
if (!signal) {
|
||||
return;
|
||||
}
|
||||
store.save({
|
||||
signal,
|
||||
source: q.source ?? '',
|
||||
filter: q.filter ?? { expression: '' },
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user