mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-09 18:40:26 +01:00
Compare commits
51 Commits
ns/flamegr
...
chore/bump
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53c86cd196 | ||
|
|
b072095a9d | ||
|
|
303908542a | ||
|
|
f52ce461d0 | ||
|
|
2e8aa0f42d | ||
|
|
c688170a13 | ||
|
|
7844fc1fe1 | ||
|
|
e02da843f2 | ||
|
|
0948a983c3 | ||
|
|
4f7ebd1ff1 | ||
|
|
15fbe4f788 | ||
|
|
7da7fda283 | ||
|
|
9093b4c442 | ||
|
|
97191eb7a2 | ||
|
|
9222845ce8 | ||
|
|
b3b245ebc5 | ||
|
|
9c9016d49e | ||
|
|
81eadac3a8 | ||
|
|
eec1c45e3f | ||
|
|
ce26458b9f | ||
|
|
7932917f5f | ||
|
|
4cf8125372 | ||
|
|
959e32b6f3 | ||
|
|
d3304af0ed | ||
|
|
df7ef3cdb5 | ||
|
|
f4ede36d3e | ||
|
|
fa7d941266 | ||
|
|
fdb22e6669 | ||
|
|
95adbc31cc | ||
|
|
c5288fc1ea | ||
|
|
86e71151d7 | ||
|
|
4fce33e2b3 | ||
|
|
a0ae4dfd05 | ||
|
|
a0b14e0835 | ||
|
|
987844dbc8 | ||
|
|
e0ad7e487a | ||
|
|
af72a4118b | ||
|
|
dc6a1d0a4e | ||
|
|
2e60ab0f81 | ||
|
|
29ed44a8ce | ||
|
|
76ee298605 | ||
|
|
218f8269dd | ||
|
|
e8effa5b3f | ||
|
|
ca5ff7f617 | ||
|
|
a487b311bc | ||
|
|
6473066193 | ||
|
|
fb0d34ae35 | ||
|
|
ba684acba3 | ||
|
|
184724003a | ||
|
|
a4d3f10da8 | ||
|
|
a71ac2ada6 |
23
.github/CODEOWNERS
vendored
23
.github/CODEOWNERS
vendored
@@ -169,3 +169,26 @@ go.mod @therealpandey
|
||||
## Dashboard V2
|
||||
/frontend/src/pages/DashboardPageV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/DashboardsListPageV2/ @SigNoz/pulse-frontend
|
||||
|
||||
## Infrastructure Monitoring
|
||||
/frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/InfraMonitoringHosts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/InfraMonitoringK8s/ @SigNoz/pulse-frontend
|
||||
|
||||
## Alerts
|
||||
/frontend/src/pages/AlertList/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/AlertDetails/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/CreateAlert/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/EditRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AlertHistory/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertRule/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditAlertV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/FormAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
|
||||
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
2
.github/workflows/build-enterprise.yaml
vendored
2
.github/workflows/build-enterprise.yaml
vendored
@@ -69,6 +69,8 @@ jobs:
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> frontend/.env
|
||||
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> frontend/.env
|
||||
echo 'VITE_ENVIRONMENT="production"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
- name: cache-dotenv
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
|
||||
2
.github/workflows/build-staging.yaml
vendored
2
.github/workflows/build-staging.yaml
vendored
@@ -70,6 +70,8 @@ jobs:
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.NP_APPCUES_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.NP_PYLON_IDENTITY_SECRET }}"' >> frontend/.env
|
||||
echo 'VITE_DOCS_BASE_URL="https://staging.signoz.io"' >> frontend/.env
|
||||
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
- name: cache-dotenv
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
|
||||
2
.github/workflows/gor-signoz.yaml
vendored
2
.github/workflows/gor-signoz.yaml
vendored
@@ -35,6 +35,8 @@ jobs:
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> .env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> .env
|
||||
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> .env
|
||||
echo 'VITE_ENVIRONMENT="production"' >> .env
|
||||
echo 'VITE_VERSION="${{ github.ref_name }}"' >> .env
|
||||
- name: node-setup
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
|
||||
5
.github/workflows/integrationci.yaml
vendored
5
.github/workflows/integrationci.yaml
vendored
@@ -39,10 +39,12 @@ jobs:
|
||||
matrix:
|
||||
suite:
|
||||
- alerts
|
||||
- basepath
|
||||
- callbackauthn
|
||||
- cloudintegrations
|
||||
- dashboard
|
||||
- ingestionkeys
|
||||
- inframonitoring
|
||||
- logspipelines
|
||||
- passwordauthn
|
||||
- preference
|
||||
@@ -52,6 +54,7 @@ jobs:
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
@@ -82,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
|
||||
}
|
||||
|
||||
@@ -432,7 +432,7 @@ cloudintegration:
|
||||
version: v0.0.8
|
||||
|
||||
##################### Trace Detail #####################
|
||||
tracedetail:
|
||||
traces:
|
||||
waterfall:
|
||||
# Number of spans returned per request when the trace is too large to show all at once.
|
||||
span_page_size: 500
|
||||
@@ -440,6 +440,17 @@ tracedetail:
|
||||
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.126.1
|
||||
image: signoz/signoz:v0.127.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.4
|
||||
image: signoz/signoz-otel-collector:v0.144.5
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
@@ -241,7 +241,7 @@ services:
|
||||
replicas: 3
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.4
|
||||
image: signoz/signoz-otel-collector:v0.144.5
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.126.1
|
||||
image: signoz/signoz:v0.127.1
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
@@ -139,7 +139,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.4
|
||||
image: signoz/signoz-otel-collector:v0.144.5
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
@@ -167,7 +167,7 @@ services:
|
||||
replicas: 3
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.4
|
||||
image: signoz/signoz-otel-collector:v0.144.5
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster
|
||||
|
||||
@@ -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.126.1}
|
||||
image: signoz/signoz:${VERSION:-v0.127.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
@@ -204,7 +204,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
|
||||
container_name: signoz-otel-collector
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
@@ -229,7 +229,7 @@ services:
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
|
||||
container_name: signoz-telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -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.126.1}
|
||||
image: signoz/signoz:${VERSION:-v0.127.1}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
|
||||
container_name: signoz-otel-collector
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
@@ -157,7 +157,7 @@ services:
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
|
||||
container_name: signoz-telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,10 +44,11 @@ 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
|
||||
}
|
||||
|
||||
@@ -197,7 +201,7 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
|
||||
RedirectURL: (&url.URL{
|
||||
Scheme: siteURL.Scheme,
|
||||
Host: siteURL.Host,
|
||||
Path: redirectPath,
|
||||
Path: path.Join(a.globalConfig.ExternalPath(), redirectPath),
|
||||
}).String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -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,14 +26,16 @@ 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
|
||||
}
|
||||
|
||||
@@ -132,7 +136,7 @@ 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.
|
||||
|
||||
@@ -355,26 +355,32 @@ func (module *module) GetService(ctx context.Context, orgID valuer.UUID, service
|
||||
|
||||
var integrationService *cloudintegrationtypes.CloudIntegrationService
|
||||
|
||||
if !cloudIntegrationID.IsZero() {
|
||||
storedService, err := module.store.GetServiceByServiceID(ctx, cloudIntegrationID, serviceID)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if storedService != nil {
|
||||
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
|
||||
}
|
||||
|
||||
if err := module.enrichDashboardIDs(ctx, orgID, provider, serviceID, serviceDefinition); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cloudIntegrationID.IsZero() {
|
||||
return cloudintegrationtypes.NewService(provider, serviceDefinition, nil, nil), nil
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewService(*serviceDefinition, integrationService), nil
|
||||
storedService, err := module.store.GetServiceByServiceID(ctx, cloudIntegrationID, serviceID)
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if storedService == nil {
|
||||
return cloudintegrationtypes.NewService(provider, serviceDefinition, nil, nil), nil
|
||||
}
|
||||
|
||||
serviceConfig, err := cloudintegrationtypes.NewServiceConfigFromJSON(provider, storedService.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
integrationService = cloudintegrationtypes.NewCloudIntegrationServiceFromStorable(storedService, serviceConfig)
|
||||
|
||||
slugPrefix := cloudintegrationtypes.CloudIntegrationDashboardSlugPrefix(provider, serviceID)
|
||||
integrationDashboards, err := module.store.ListIntegrationDashboardsBySlugPrefix(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slugPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cloudintegrationtypes.NewService(provider, serviceDefinition, integrationService, integrationDashboards), nil
|
||||
}
|
||||
|
||||
func (module *module) CreateService(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, service *cloudintegrationtypes.CloudIntegrationService, provider cloudintegrationtypes.CloudProviderType) error {
|
||||
@@ -583,20 +589,3 @@ func (module *module) deprovisionDashboards(ctx context.Context, orgID valuer.UU
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enrichDashboardIDs replaces the raw dashboard name in each Dashboard.ID with the provisioned UUID.
|
||||
// TODO: remove this hack and send idiomatic response to client.
|
||||
func (module *module) enrichDashboardIDs(ctx context.Context, orgID valuer.UUID, provider cloudintegrationtypes.CloudProviderType, serviceID cloudintegrationtypes.ServiceID, serviceDefinition *cloudintegrationtypes.ServiceDefinition) error {
|
||||
for i, d := range serviceDefinition.Assets.Dashboards {
|
||||
slug := cloudintegrationtypes.CloudIntegrationDashboardSlug(provider, serviceID, d.ID)
|
||||
row, err := module.store.GetIntegrationDashboardBySlug(ctx, orgID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeNotFound) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
serviceDefinition.Assets.Dashboards[i].ID = row.DashboardID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -221,6 +221,18 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
|
||||
}
|
||||
|
||||
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
|
||||
}
|
||||
|
||||
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
|
||||
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
|
||||
return module.pkgDashboardModule.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
|
||||
interface SafeNavigateOptions {
|
||||
replace?: boolean;
|
||||
state?: unknown;
|
||||
newTab?: boolean;
|
||||
}
|
||||
|
||||
interface SafeNavigateTo {
|
||||
pathname?: string;
|
||||
search?: string;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
type SafeNavigateToType = string | SafeNavigateTo;
|
||||
|
||||
interface UseSafeNavigateReturn {
|
||||
safeNavigate: jest.MockedFunction<
|
||||
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
|
||||
>;
|
||||
}
|
||||
|
||||
export const useSafeNavigate = (): UseSafeNavigateReturn => ({
|
||||
safeNavigate: jest.fn(
|
||||
(_to: SafeNavigateToType, _options?: SafeNavigateOptions) => {},
|
||||
) as jest.MockedFunction<
|
||||
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
|
||||
>,
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
|
||||
const USE_SAFE_NAVIGATE_MOCK_PATH =
|
||||
'<rootDir>/src/__tests__/safeNavigateMock.ts';
|
||||
const LOG_EVENT_MOCK_PATH = '<rootDir>/src/__tests__/logEventMock.ts';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
silent: true,
|
||||
@@ -22,6 +24,8 @@ const config: Config.InitialOptions = {
|
||||
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
|
||||
'^api/common/logEvent$': LOG_EVENT_MOCK_PATH,
|
||||
'^src/api/common/logEvent$': LOG_EVENT_MOCK_PATH,
|
||||
'^constants/env$': '<rootDir>/__mocks__/env.ts',
|
||||
'^src/constants/env$': '<rootDir>/__mocks__/env.ts',
|
||||
'^@signozhq/icons$': '<rootDir>/__mocks__/signozhqIconsMock.tsx',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -351,19 +351,18 @@ function App(): JSX.Element {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
tunnel: process.env.TUNNEL_URL,
|
||||
environment: 'production',
|
||||
environment: process.env.ENVIRONMENT,
|
||||
release: process.env.VERSION,
|
||||
integrations: [
|
||||
// Kept for the `transaction` tag used in routing, even though
|
||||
// tracing is disabled. Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
|
||||
Sentry.browserTracingIntegration(),
|
||||
Sentry.replayIntegration({
|
||||
maskAllText: false,
|
||||
blockAllMedia: false,
|
||||
}),
|
||||
],
|
||||
// Performance Monitoring
|
||||
tracesSampleRate: 1.0, // Capture 100% of the transactions
|
||||
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
|
||||
tracePropagationTargets: [],
|
||||
// Session Replay
|
||||
tracesSampleRate: 0, // Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
|
||||
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
|
||||
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
|
||||
beforeSend(event) {
|
||||
|
||||
11
frontend/src/__tests__/logEventMock.ts
Normal file
11
frontend/src/__tests__/logEventMock.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Shared mock for `api/common/logEvent`.
|
||||
// Wired into jest.config.ts moduleNameMapper, so any import of
|
||||
// `api/common/logEvent` in test code resolves to this file.
|
||||
// Tests can import `logEventMock` to assert analytics calls — Jest's
|
||||
// `clearMocks: true` resets call history between tests.
|
||||
|
||||
export const logEventMock: jest.MockedFunction<
|
||||
(eventName: string, attributes?: Record<string, unknown>) => void
|
||||
> = jest.fn();
|
||||
|
||||
export default logEventMock;
|
||||
29
frontend/src/__tests__/safeNavigateMock.ts
Normal file
29
frontend/src/__tests__/safeNavigateMock.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Shared mock for `hooks/useSafeNavigate`.
|
||||
// Wired into jest.config.ts moduleNameMapper, so any import of
|
||||
// `hooks/useSafeNavigate` in test code resolves to this file.
|
||||
// Tests can import `safeNavigateMock` to assert navigation calls — Jest's
|
||||
// `clearMocks: true` resets call history between tests.
|
||||
|
||||
interface SafeNavigateOptions {
|
||||
replace?: boolean;
|
||||
state?: unknown;
|
||||
newTab?: boolean;
|
||||
}
|
||||
|
||||
interface SafeNavigateTo {
|
||||
pathname?: string;
|
||||
search?: string;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
type SafeNavigateToType = string | SafeNavigateTo;
|
||||
|
||||
export const safeNavigateMock: jest.MockedFunction<
|
||||
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
|
||||
> = jest.fn();
|
||||
|
||||
export const useSafeNavigate = (): {
|
||||
safeNavigate: typeof safeNavigateMock;
|
||||
} => ({
|
||||
safeNavigate: safeNavigateMock,
|
||||
});
|
||||
@@ -3,13 +3,36 @@ import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
export interface DayBreakdownEntry {
|
||||
timestamp: number;
|
||||
total: number;
|
||||
quantity: number;
|
||||
count: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface TierEntry {
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
tierCost: number;
|
||||
}
|
||||
|
||||
export interface BreakdownEntry {
|
||||
type: string;
|
||||
unit: string;
|
||||
dayWiseBreakdown: {
|
||||
breakdown: DayBreakdownEntry[];
|
||||
};
|
||||
tiers?: TierEntry[];
|
||||
}
|
||||
|
||||
export interface UsageResponsePayloadProps {
|
||||
billingPeriodStart: Date;
|
||||
billingPeriodEnd: Date;
|
||||
billingPeriodStart: number;
|
||||
billingPeriodEnd: number;
|
||||
details: {
|
||||
total: number;
|
||||
baseFee: number;
|
||||
breakdown: [];
|
||||
breakdown: BreakdownEntry[];
|
||||
billTotal: number;
|
||||
};
|
||||
discount: number;
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
|
||||
import type {
|
||||
AlertmanagertypesPostableChannelDTO,
|
||||
ConfigReceiverDTO,
|
||||
AlertmanagertypesReceiverDTO,
|
||||
CreateChannel201,
|
||||
DeleteChannelByIDPathParameters,
|
||||
GetChannelByID200,
|
||||
@@ -385,14 +385,14 @@ export const invalidateGetChannelByID = async (
|
||||
*/
|
||||
export const updateChannelByID = (
|
||||
{ id }: UpdateChannelByIDPathParameters,
|
||||
configReceiverDTO?: BodyType<ConfigReceiverDTO>,
|
||||
alertmanagertypesReceiverDTO?: BodyType<AlertmanagertypesReceiverDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/channels/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: configReceiverDTO,
|
||||
data: alertmanagertypesReceiverDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@@ -406,7 +406,7 @@ export const getUpdateChannelByIDMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateChannelByIDPathParameters;
|
||||
data?: BodyType<ConfigReceiverDTO>;
|
||||
data?: BodyType<AlertmanagertypesReceiverDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -415,7 +415,7 @@ export const getUpdateChannelByIDMutationOptions = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateChannelByIDPathParameters;
|
||||
data?: BodyType<ConfigReceiverDTO>;
|
||||
data?: BodyType<AlertmanagertypesReceiverDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
@@ -432,7 +432,7 @@ export const getUpdateChannelByIDMutationOptions = <
|
||||
Awaited<ReturnType<typeof updateChannelByID>>,
|
||||
{
|
||||
pathParams: UpdateChannelByIDPathParameters;
|
||||
data?: BodyType<ConfigReceiverDTO>;
|
||||
data?: BodyType<AlertmanagertypesReceiverDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
@@ -447,7 +447,7 @@ export type UpdateChannelByIDMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateChannelByID>>
|
||||
>;
|
||||
export type UpdateChannelByIDMutationBody =
|
||||
| BodyType<ConfigReceiverDTO>
|
||||
| BodyType<AlertmanagertypesReceiverDTO>
|
||||
| undefined;
|
||||
export type UpdateChannelByIDMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
@@ -463,7 +463,7 @@ export const useUpdateChannelByID = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateChannelByIDPathParameters;
|
||||
data?: BodyType<ConfigReceiverDTO>;
|
||||
data?: BodyType<AlertmanagertypesReceiverDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
@@ -472,7 +472,7 @@ export const useUpdateChannelByID = <
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateChannelByIDPathParameters;
|
||||
data?: BodyType<ConfigReceiverDTO>;
|
||||
data?: BodyType<AlertmanagertypesReceiverDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
@@ -483,14 +483,14 @@ export const useUpdateChannelByID = <
|
||||
* @summary Test notification channel
|
||||
*/
|
||||
export const testChannel = (
|
||||
configReceiverDTO?: BodyType<ConfigReceiverDTO>,
|
||||
alertmanagertypesReceiverDTO?: BodyType<AlertmanagertypesReceiverDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/channels/test`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: configReceiverDTO,
|
||||
data: alertmanagertypesReceiverDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@@ -502,13 +502,13 @@ export const getTestChannelMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<ConfigReceiverDTO> },
|
||||
{ data?: BodyType<AlertmanagertypesReceiverDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<ConfigReceiverDTO> },
|
||||
{ data?: BodyType<AlertmanagertypesReceiverDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['testChannel'];
|
||||
@@ -522,7 +522,7 @@ export const getTestChannelMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof testChannel>>,
|
||||
{ data?: BodyType<ConfigReceiverDTO> }
|
||||
{ data?: BodyType<AlertmanagertypesReceiverDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
@@ -535,7 +535,9 @@ export const getTestChannelMutationOptions = <
|
||||
export type TestChannelMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof testChannel>>
|
||||
>;
|
||||
export type TestChannelMutationBody = BodyType<ConfigReceiverDTO> | undefined;
|
||||
export type TestChannelMutationBody =
|
||||
| BodyType<AlertmanagertypesReceiverDTO>
|
||||
| undefined;
|
||||
export type TestChannelMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
@@ -548,13 +550,13 @@ export const useTestChannel = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<ConfigReceiverDTO> },
|
||||
{ data?: BodyType<AlertmanagertypesReceiverDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<ConfigReceiverDTO> },
|
||||
{ data?: BodyType<AlertmanagertypesReceiverDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getTestChannelMutationOptions(options));
|
||||
@@ -565,14 +567,14 @@ export const useTestChannel = <
|
||||
* @summary Test notification channel (deprecated)
|
||||
*/
|
||||
export const testChannelDeprecated = (
|
||||
configReceiverDTO?: BodyType<ConfigReceiverDTO>,
|
||||
alertmanagertypesReceiverDTO?: BodyType<AlertmanagertypesReceiverDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/testChannel`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: configReceiverDTO,
|
||||
data: alertmanagertypesReceiverDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
@@ -584,13 +586,13 @@ export const getTestChannelDeprecatedMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testChannelDeprecated>>,
|
||||
TError,
|
||||
{ data?: BodyType<ConfigReceiverDTO> },
|
||||
{ data?: BodyType<AlertmanagertypesReceiverDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testChannelDeprecated>>,
|
||||
TError,
|
||||
{ data?: BodyType<ConfigReceiverDTO> },
|
||||
{ data?: BodyType<AlertmanagertypesReceiverDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['testChannelDeprecated'];
|
||||
@@ -604,7 +606,7 @@ export const getTestChannelDeprecatedMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof testChannelDeprecated>>,
|
||||
{ data?: BodyType<ConfigReceiverDTO> }
|
||||
{ data?: BodyType<AlertmanagertypesReceiverDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
@@ -618,7 +620,7 @@ export type TestChannelDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof testChannelDeprecated>>
|
||||
>;
|
||||
export type TestChannelDeprecatedMutationBody =
|
||||
| BodyType<ConfigReceiverDTO>
|
||||
| BodyType<AlertmanagertypesReceiverDTO>
|
||||
| undefined;
|
||||
export type TestChannelDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
@@ -634,13 +636,13 @@ export const useTestChannelDeprecated = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testChannelDeprecated>>,
|
||||
TError,
|
||||
{ data?: BodyType<ConfigReceiverDTO> },
|
||||
{ data?: BodyType<AlertmanagertypesReceiverDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testChannelDeprecated>>,
|
||||
TError,
|
||||
{ data?: BodyType<ConfigReceiverDTO> },
|
||||
{ data?: BodyType<AlertmanagertypesReceiverDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getTestChannelDeprecatedMutationOptions(options));
|
||||
|
||||
@@ -31,11 +31,15 @@ import type {
|
||||
DisconnectAccountPathParameters,
|
||||
GetAccount200,
|
||||
GetAccountPathParameters,
|
||||
GetAccountService200,
|
||||
GetAccountServicePathParameters,
|
||||
GetConnectionCredentials200,
|
||||
GetConnectionCredentialsPathParameters,
|
||||
GetService200,
|
||||
GetServiceParams,
|
||||
GetServicePathParameters,
|
||||
ListAccountServicesMetadata200,
|
||||
ListAccountServicesMetadataPathParameters,
|
||||
ListAccounts200,
|
||||
ListAccountsPathParameters,
|
||||
ListServicesMetadata200,
|
||||
@@ -631,6 +635,227 @@ export const useUpdateAccount = <
|
||||
> => {
|
||||
return useMutation(getUpdateAccountMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint lists the services metadata for the specified account and cloud provider
|
||||
* @summary List account services metadata
|
||||
*/
|
||||
export const listAccountServicesMetadata = (
|
||||
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListAccountServicesMetadata200>({
|
||||
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListAccountServicesMetadataQueryKey = ({
|
||||
cloudProvider,
|
||||
id,
|
||||
}: ListAccountServicesMetadataPathParameters) => {
|
||||
return [
|
||||
`/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services`,
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getListAccountServicesMetadataQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listAccountServicesMetadata>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAccountServicesMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getListAccountServicesMetadataQueryKey({ cloudProvider, id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listAccountServicesMetadata>>
|
||||
> = ({ signal }) => listAccountServicesMetadata({ cloudProvider, id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!(cloudProvider && id),
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAccountServicesMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListAccountServicesMetadataQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listAccountServicesMetadata>>
|
||||
>;
|
||||
export type ListAccountServicesMetadataQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List account services metadata
|
||||
*/
|
||||
|
||||
export function useListAccountServicesMetadata<
|
||||
TData = Awaited<ReturnType<typeof listAccountServicesMetadata>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listAccountServicesMetadata>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListAccountServicesMetadataQueryOptions(
|
||||
{ cloudProvider, id },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List account services metadata
|
||||
*/
|
||||
export const invalidateListAccountServicesMetadata = async (
|
||||
queryClient: QueryClient,
|
||||
{ cloudProvider, id }: ListAccountServicesMetadataPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListAccountServicesMetadataQueryKey({ cloudProvider, id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint gets a service and its configuration for the specified cloud integration account
|
||||
* @summary Get service for account
|
||||
*/
|
||||
export const getAccountService = (
|
||||
{ cloudProvider, id, serviceId }: GetAccountServicePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetAccountService200>({
|
||||
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services/${serviceId}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetAccountServiceQueryKey = ({
|
||||
cloudProvider,
|
||||
id,
|
||||
serviceId,
|
||||
}: GetAccountServicePathParameters) => {
|
||||
return [
|
||||
`/api/v1/cloud_integrations/${cloudProvider}/accounts/${id}/services/${serviceId}`,
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetAccountServiceQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getAccountService>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, id, serviceId }: GetAccountServicePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAccountService>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetAccountServiceQueryKey({ cloudProvider, id, serviceId });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getAccountService>>
|
||||
> = ({ signal }) =>
|
||||
getAccountService({ cloudProvider, id, serviceId }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!(cloudProvider && id && serviceId),
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAccountService>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetAccountServiceQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getAccountService>>
|
||||
>;
|
||||
export type GetAccountServiceQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get service for account
|
||||
*/
|
||||
|
||||
export function useGetAccountService<
|
||||
TData = Awaited<ReturnType<typeof getAccountService>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, id, serviceId }: GetAccountServicePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getAccountService>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetAccountServiceQueryOptions(
|
||||
{ cloudProvider, id, serviceId },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get service for account
|
||||
*/
|
||||
export const invalidateGetAccountService = async (
|
||||
queryClient: QueryClient,
|
||||
{ cloudProvider, id, serviceId }: GetAccountServicePathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetAccountServiceQueryKey({ cloudProvider, id, serviceId }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint updates a service for the specified cloud provider
|
||||
* @summary Update service
|
||||
|
||||
@@ -21,8 +21,10 @@ import type {
|
||||
CreateDashboardV2201,
|
||||
CreatePublicDashboard201,
|
||||
CreatePublicDashboardPathParameters,
|
||||
DashboardtypesPatchableDashboardV2DTO,
|
||||
DashboardtypesPostableDashboardV2DTO,
|
||||
DashboardtypesPostablePublicDashboardDTO,
|
||||
DashboardtypesUpdatableDashboardV2DTO,
|
||||
DashboardtypesUpdatablePublicDashboardDTO,
|
||||
DeletePublicDashboardPathParameters,
|
||||
GetDashboardV2200,
|
||||
@@ -33,7 +35,13 @@ import type {
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
LockDashboardV2PathParameters,
|
||||
PatchDashboardV2200,
|
||||
PatchDashboardV2PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
UnlockDashboardV2PathParameters,
|
||||
UpdateDashboardV2200,
|
||||
UpdateDashboardV2PathParameters,
|
||||
UpdatePublicDashboardPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
@@ -816,3 +824,360 @@ export const invalidateGetDashboardV2 = async (
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Apply is lenient: `remove` on a missing path is a no-op (idempotent) and `add` creates any missing parent objects, rather than failing as strict RFC 6902 would. The resulting dashboard is still validated. Locked dashboards are rejected.
|
||||
* @summary Patch dashboard (v2)
|
||||
*/
|
||||
export const patchDashboardV2 = (
|
||||
{ id }: PatchDashboardV2PathParameters,
|
||||
dashboardtypesPatchableDashboardV2DTONull?: BodyType<DashboardtypesPatchableDashboardV2DTO | null> | null,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PatchDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesPatchableDashboardV2DTONull,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPatchDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['patchDashboardV2'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return patchDashboardV2(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PatchDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>
|
||||
>;
|
||||
export type PatchDashboardV2MutationBody =
|
||||
| BodyType<DashboardtypesPatchableDashboardV2DTO | null>
|
||||
| undefined;
|
||||
export type PatchDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Patch dashboard (v2)
|
||||
*/
|
||||
export const usePatchDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof patchDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: PatchDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPatchDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.
|
||||
* @summary Update dashboard (v2)
|
||||
*/
|
||||
export const updateDashboardV2 = (
|
||||
{ id }: UpdateDashboardV2PathParameters,
|
||||
dashboardtypesUpdatableDashboardV2DTO?: BodyType<DashboardtypesUpdatableDashboardV2DTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateDashboardV2200>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: dashboardtypesUpdatableDashboardV2DTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateDashboardV2'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateDashboardV2(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>
|
||||
>;
|
||||
export type UpdateDashboardV2MutationBody =
|
||||
| BodyType<DashboardtypesUpdatableDashboardV2DTO>
|
||||
| undefined;
|
||||
export type UpdateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update dashboard (v2)
|
||||
*/
|
||||
export const useUpdateDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateDashboardV2>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateDashboardV2PathParameters;
|
||||
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
|
||||
* @summary Unlock dashboard (v2)
|
||||
*/
|
||||
export const unlockDashboardV2 = (
|
||||
{ id }: UnlockDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUnlockDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['unlockDashboardV2'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
{ pathParams: UnlockDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return unlockDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UnlockDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>
|
||||
>;
|
||||
|
||||
export type UnlockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Unlock dashboard (v2)
|
||||
*/
|
||||
export const useUnlockDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof unlockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: UnlockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUnlockDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
|
||||
* @summary Lock dashboard (v2)
|
||||
*/
|
||||
export const lockDashboardV2 = (
|
||||
{ id }: LockDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'PUT',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getLockDashboardV2MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['lockDashboardV2'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
{ pathParams: LockDashboardV2PathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return lockDashboardV2(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type LockDashboardV2MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>
|
||||
>;
|
||||
|
||||
export type LockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Lock dashboard (v2)
|
||||
*/
|
||||
export const useLockDashboardV2 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof lockDashboardV2>>,
|
||||
TError,
|
||||
{ pathParams: LockDashboardV2PathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getLockDashboardV2MutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -134,113 +134,6 @@ export interface AlertmanagertypesGettableRoutePolicyDTO {
|
||||
updatedBy?: string | null;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesMaintenanceKindDTO {
|
||||
fixed = 'fixed',
|
||||
recurring = 'recurring',
|
||||
}
|
||||
export enum AlertmanagertypesMaintenanceStatusDTO {
|
||||
active = 'active',
|
||||
upcoming = 'upcoming',
|
||||
expired = 'expired',
|
||||
}
|
||||
export enum AlertmanagertypesRepeatOnDTO {
|
||||
sunday = 'sunday',
|
||||
monday = 'monday',
|
||||
tuesday = 'tuesday',
|
||||
wednesday = 'wednesday',
|
||||
thursday = 'thursday',
|
||||
friday = 'friday',
|
||||
saturday = 'saturday',
|
||||
}
|
||||
export enum AlertmanagertypesRepeatTypeDTO {
|
||||
daily = 'daily',
|
||||
weekly = 'weekly',
|
||||
monthly = 'monthly',
|
||||
}
|
||||
export interface AlertmanagertypesRecurrenceDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
duration: string;
|
||||
/**
|
||||
* @type string,null
|
||||
* @format date-time
|
||||
*/
|
||||
endTime?: string | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
repeatOn?: AlertmanagertypesRepeatOnDTO[] | null;
|
||||
repeatType: AlertmanagertypesRepeatTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesScheduleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
endTime?: string;
|
||||
recurrence?: AlertmanagertypesRecurrenceDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesPlannedMaintenanceDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
alertIds?: string[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
kind: AlertmanagertypesMaintenanceKindDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
schedule: AlertmanagertypesScheduleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
scope?: string;
|
||||
status: AlertmanagertypesMaintenanceStatusDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface ConfigAuthorizationDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -475,6 +368,130 @@ export interface ConfigSecretURLDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesGoogleChatReceiverConfigDTO {
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
webhook_url?: ConfigSecretURLDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesMaintenanceKindDTO {
|
||||
fixed = 'fixed',
|
||||
recurring = 'recurring',
|
||||
}
|
||||
export enum AlertmanagertypesMaintenanceStatusDTO {
|
||||
active = 'active',
|
||||
upcoming = 'upcoming',
|
||||
expired = 'expired',
|
||||
}
|
||||
export enum AlertmanagertypesRepeatOnDTO {
|
||||
sunday = 'sunday',
|
||||
monday = 'monday',
|
||||
tuesday = 'tuesday',
|
||||
wednesday = 'wednesday',
|
||||
thursday = 'thursday',
|
||||
friday = 'friday',
|
||||
saturday = 'saturday',
|
||||
}
|
||||
export enum AlertmanagertypesRepeatTypeDTO {
|
||||
daily = 'daily',
|
||||
weekly = 'weekly',
|
||||
monthly = 'monthly',
|
||||
}
|
||||
export interface AlertmanagertypesRecurrenceDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
duration: string;
|
||||
/**
|
||||
* @type string,null
|
||||
* @format date-time
|
||||
*/
|
||||
endTime?: string | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
repeatOn?: AlertmanagertypesRepeatOnDTO[] | null;
|
||||
repeatType: AlertmanagertypesRepeatTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesScheduleDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
endTime?: string;
|
||||
recurrence?: AlertmanagertypesRecurrenceDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesPlannedMaintenanceDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
alertIds?: string[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
kind: AlertmanagertypesMaintenanceKindDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
schedule: AlertmanagertypesScheduleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
scope?: string;
|
||||
status: AlertmanagertypesMaintenanceStatusDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface ConfigDiscordConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -1634,6 +1651,10 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
|
||||
* @type array
|
||||
*/
|
||||
email_configs?: ConfigEmailConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
googlechat_configs?: AlertmanagertypesGoogleChatReceiverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -1748,6 +1769,89 @@ export interface AlertmanagertypesPostableRoutePolicyDTO {
|
||||
tags?: string[] | null;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesReceiverDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
discord_configs?: ConfigDiscordConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
email_configs?: ConfigEmailConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
googlechat_configs?: AlertmanagertypesGoogleChatReceiverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
incidentio_configs?: ConfigIncidentioConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jira_configs?: ConfigJiraConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
mattermost_configs?: ConfigMattermostConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
msteams_configs?: ConfigMSTeamsConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
msteamsv2_configs?: ConfigMSTeamsV2ConfigDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
opsgenie_configs?: ConfigOpsGenieConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
pagerduty_configs?: ConfigPagerdutyConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
pushover_configs?: ConfigPushoverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
rocketchat_configs?: ConfigRocketchatConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
slack_configs?: ConfigSlackConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
sns_configs?: ConfigSNSConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
telegram_configs?: ConfigTelegramConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
victorops_configs?: ConfigVictorOpsConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
webex_configs?: ConfigWebexConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
webhook_configs?: ConfigWebhookConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
wechat_configs?: ConfigWechatConfigDTO[];
|
||||
}
|
||||
|
||||
export interface AuthtypesAttributeMappingDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2457,33 +2561,6 @@ export interface CloudintegrationtypesAccountDTO {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesStorableDashboardDataDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesDashboardDTO {
|
||||
definition?: DashboardtypesStorableDashboardDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAssetsDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
dashboards?: CloudintegrationtypesDashboardDTO[] | null;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesAzureConnectionArtifactDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2574,6 +2651,7 @@ export enum CloudintegrationtypesServiceIDDTO {
|
||||
sqs = 'sqs',
|
||||
storageaccountsblob = 'storageaccountsblob',
|
||||
cdnprofile = 'cdnprofile',
|
||||
aks = 'aks',
|
||||
}
|
||||
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
|
||||
/**
|
||||
@@ -2866,6 +2944,54 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
|
||||
providerAccountId?: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesStorableIntegrationDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
dashboardId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
provider: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
slug: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description: string;
|
||||
integrationDashboard?: CloudintegrationtypesStorableIntegrationDashboardDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceAssetsDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
dashboards: CloudintegrationtypesServiceDashboardDTO[];
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesSupportedSignalsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -2877,13 +3003,8 @@ export interface CloudintegrationtypesSupportedSignalsDTO {
|
||||
metrics?: boolean;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesTelemetryCollectionStrategyDTO {
|
||||
aws?: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
|
||||
azure?: CloudintegrationtypesAzureTelemetryCollectionStrategyDTO;
|
||||
}
|
||||
|
||||
export interface CloudintegrationtypesServiceDTO {
|
||||
assets: CloudintegrationtypesAssetsDTO;
|
||||
assets: CloudintegrationtypesServiceAssetsDTO;
|
||||
cloudIntegrationService: CloudintegrationtypesCloudIntegrationServiceDTO | null;
|
||||
dataCollected: CloudintegrationtypesDataCollectedDTO;
|
||||
/**
|
||||
@@ -2899,7 +3020,6 @@ export interface CloudintegrationtypesServiceDTO {
|
||||
*/
|
||||
overview: string;
|
||||
supportedSignals: CloudintegrationtypesSupportedSignalsDTO;
|
||||
telemetryCollectionStrategy: CloudintegrationtypesTelemetryCollectionStrategyDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -2944,85 +3064,6 @@ export interface CommonJSONRefDTO {
|
||||
$ref?: string;
|
||||
}
|
||||
|
||||
export interface ConfigReceiverDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
discord_configs?: ConfigDiscordConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
email_configs?: ConfigEmailConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
incidentio_configs?: ConfigIncidentioConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jira_configs?: ConfigJiraConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
mattermost_configs?: ConfigMattermostConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
msteams_configs?: ConfigMSTeamsConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
msteamsv2_configs?: ConfigMSTeamsV2ConfigDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
opsgenie_configs?: ConfigOpsGenieConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
pagerduty_configs?: ConfigPagerdutyConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
pushover_configs?: ConfigPushoverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
rocketchat_configs?: ConfigRocketchatConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
slack_configs?: ConfigSlackConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
sns_configs?: ConfigSNSConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
telegram_configs?: ConfigTelegramConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
victorops_configs?: ConfigVictorOpsConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
webex_configs?: ConfigWebexConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
webhook_configs?: ConfigWebhookConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
wechat_configs?: ConfigWechatConfigDTO[];
|
||||
}
|
||||
|
||||
export interface CoretypesObjectGroupDTO {
|
||||
resource: CoretypesResourceRefDTO;
|
||||
/**
|
||||
@@ -3089,6 +3130,40 @@ export interface DashboardGridLayoutSpecDTO {
|
||||
repeatVariable?: string;
|
||||
}
|
||||
|
||||
export interface DashboardLinkDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
renderVariables?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
targetBlank?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
tooltip?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface DashboardPanelDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface VariableDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -3705,6 +3780,10 @@ export interface DashboardtypesCustomVariableSpecDTO {
|
||||
customValue: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesStorableDashboardDataDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export enum DashboardtypesSourceDTO {
|
||||
user = 'user',
|
||||
system = 'system',
|
||||
@@ -3786,40 +3865,9 @@ export type DashboardtypesDashboardSpecDTODatasources = {
|
||||
[key: string]: DashboardtypesDatasourceSpecDTO;
|
||||
};
|
||||
|
||||
export interface V1PanelDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
export enum DashboardtypesPanelKindDTO {
|
||||
Panel = 'Panel',
|
||||
}
|
||||
|
||||
export interface V1LinkDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
renderVariables?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
targetBlank?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
tooltip?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTOKind {
|
||||
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
|
||||
}
|
||||
@@ -4061,6 +4109,13 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
time_series = 'time_series',
|
||||
raw = 'raw',
|
||||
raw_stream = 'raw_stream',
|
||||
trace = 'trace',
|
||||
}
|
||||
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind {
|
||||
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
|
||||
}
|
||||
@@ -4365,19 +4420,16 @@ export interface DashboardtypesQuerySpecDTO {
|
||||
}
|
||||
|
||||
export interface DashboardtypesQueryDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind?: string;
|
||||
kind?: Querybuildertypesv5RequestTypeDTO;
|
||||
spec?: DashboardtypesQuerySpecDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelSpecDTO {
|
||||
display?: V1PanelDisplayDTO;
|
||||
display?: DashboardPanelDisplayDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
links?: V1LinkDTO[];
|
||||
links?: DashboardLinkDTO[];
|
||||
plugin?: DashboardtypesPanelPluginDTO;
|
||||
/**
|
||||
* @type array
|
||||
@@ -4386,10 +4438,7 @@ export interface DashboardtypesPanelSpecDTO {
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind?: string;
|
||||
kind?: DashboardtypesPanelKindDTO;
|
||||
spec?: DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
@@ -4403,20 +4452,20 @@ export type DashboardtypesDashboardSpecDTOPanelsAnyOf = {
|
||||
export type DashboardtypesDashboardSpecDTOPanels =
|
||||
DashboardtypesDashboardSpecDTOPanelsAnyOf | null;
|
||||
|
||||
export enum DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpecDTOKind {
|
||||
export enum DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTOKind {
|
||||
Grid = 'Grid',
|
||||
}
|
||||
export interface DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpecDTO {
|
||||
export interface DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTO {
|
||||
/**
|
||||
* @enum Grid
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpecDTOKind;
|
||||
kind: DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTOKind;
|
||||
spec: DashboardGridLayoutSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesLayoutDTO =
|
||||
DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpecDTO;
|
||||
DashboardtypesLayoutEnvelopeGithubComPersesSpecGoDashboardGridLayoutSpecDTO;
|
||||
|
||||
export enum DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind {
|
||||
ListVariable = 'ListVariable',
|
||||
@@ -4520,21 +4569,21 @@ export interface DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDash
|
||||
spec: DashboardtypesListVariableSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpecDTOKind {
|
||||
export enum DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind {
|
||||
TextVariable = 'TextVariable',
|
||||
}
|
||||
export interface DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpecDTO {
|
||||
export interface DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO {
|
||||
/**
|
||||
* @enum TextVariable
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpecDTOKind;
|
||||
kind: DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind;
|
||||
spec: DashboardTextVariableSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesVariableDTO =
|
||||
| DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTO
|
||||
| DashboardtypesVariableEnvelopeGithubComPersesPersesPkgModelApiV1DashboardTextVariableSpecDTO;
|
||||
| DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTO;
|
||||
|
||||
export interface DashboardtypesDashboardSpecDTO {
|
||||
/**
|
||||
@@ -4553,7 +4602,7 @@ export interface DashboardtypesDashboardSpecDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
links?: V1LinkDTO[];
|
||||
links?: DashboardLinkDTO[];
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
@@ -4653,6 +4702,32 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPatchOpDTO {
|
||||
add = 'add',
|
||||
remove = 'remove',
|
||||
replace = 'replace',
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
test = 'test',
|
||||
}
|
||||
export interface DashboardtypesJSONPatchOperationDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description Source JSON Pointer for move/copy ops; ignored for other ops.
|
||||
*/
|
||||
from?: string;
|
||||
op: DashboardtypesPatchOpDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0, /tags/-.
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* @description Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable; /spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /spec/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy.
|
||||
*/
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
|
||||
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
|
||||
@@ -4662,6 +4737,13 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
|
||||
'signoz/ListPanel' = 'signoz/ListPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesPatchableDashboardV2DTO =
|
||||
| DashboardtypesJSONPatchOperationDTO[]
|
||||
| null;
|
||||
|
||||
export interface DashboardtypesPostableDashboardV2DTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -4705,6 +4787,26 @@ export enum DashboardtypesQueryPluginKindDTO {
|
||||
'signoz/ClickHouseSQL' = 'signoz/ClickHouseSQL',
|
||||
'signoz/TraceOperator' = 'signoz/TraceOperator',
|
||||
}
|
||||
export interface DashboardtypesUpdatableDashboardV2DTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
image?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: string;
|
||||
spec: DashboardtypesDashboardSpecDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
tags: TagtypesPostableTagDTO[] | null;
|
||||
}
|
||||
|
||||
export interface DashboardtypesUpdatablePublicDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -6882,13 +6984,6 @@ export type Querybuildertypesv5QueryRangeRequestDTOVariables = {
|
||||
[key: string]: Querybuildertypesv5VariableItemDTO;
|
||||
};
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
time_series = 'time_series',
|
||||
raw = 'raw',
|
||||
raw_stream = 'raw_stream',
|
||||
trace = 'trace',
|
||||
}
|
||||
/**
|
||||
* Request body for the v5 query range endpoint. Supports builder queries (traces, logs, metrics), formulas, joins, trace operators, PromQL, and ClickHouse SQL queries.
|
||||
*/
|
||||
@@ -7675,6 +7770,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
|
||||
@@ -7976,6 +8142,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',
|
||||
@@ -8623,6 +8800,31 @@ export type UpdateAccountPathParameters = {
|
||||
cloudProvider: string;
|
||||
id: string;
|
||||
};
|
||||
export type ListAccountServicesMetadataPathParameters = {
|
||||
cloudProvider: string;
|
||||
id: string;
|
||||
};
|
||||
export type ListAccountServicesMetadata200 = {
|
||||
data: CloudintegrationtypesGettableServicesMetadataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetAccountServicePathParameters = {
|
||||
cloudProvider: string;
|
||||
id: string;
|
||||
serviceId: string;
|
||||
};
|
||||
export type GetAccountService200 = {
|
||||
data: CloudintegrationtypesServiceDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateServicePathParameters = {
|
||||
cloudProvider: string;
|
||||
id: string;
|
||||
@@ -9476,6 +9678,34 @@ export type GetDashboardV2200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PatchDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type PatchDashboardV2200 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateDashboardV2200 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UnlockDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type LockDashboardV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetFeatures200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -10277,6 +10507,17 @@ export type GetHosts200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetFlamegraphPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetFlamegraph200 = {
|
||||
data: SpantypesGettableFlamegraphTraceDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetWaterfallPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
GetFlamegraph200,
|
||||
GetFlamegraphPathParameters,
|
||||
GetTraceAggregations200,
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetWaterfall200,
|
||||
@@ -19,6 +21,7 @@ import type {
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableFlamegraphDTO,
|
||||
SpantypesPostableTraceAggregationsDTO,
|
||||
SpantypesPostableWaterfallDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -126,6 +129,105 @@ export const useGetTraceAggregations = <
|
||||
> => {
|
||||
return useMutation(getGetTraceAggregationsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the flamegraph view of spans for a given trace ID.
|
||||
* @summary Get flamegraph view for a trace
|
||||
*/
|
||||
export const getFlamegraph = (
|
||||
{ traceID }: GetFlamegraphPathParameters,
|
||||
spantypesPostableFlamegraphDTO?: BodyType<SpantypesPostableFlamegraphDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetFlamegraph200>({
|
||||
url: `/api/v3/traces/${traceID}/flamegraph`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableFlamegraphDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetFlamegraphMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['getFlamegraph'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
{
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return getFlamegraph(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type GetFlamegraphMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>
|
||||
>;
|
||||
export type GetFlamegraphMutationBody =
|
||||
| BodyType<SpantypesPostableFlamegraphDTO>
|
||||
| undefined;
|
||||
export type GetFlamegraphMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get flamegraph view for a trace
|
||||
*/
|
||||
export const useGetFlamegraph = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getGetFlamegraphMutationOptions(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
|
||||
|
||||
@@ -120,7 +120,8 @@ export const interceptorRejected = async (
|
||||
!(
|
||||
response.config.url === '/sessions' && response.config.method === 'delete'
|
||||
) &&
|
||||
response.config.url !== '/authz/check'
|
||||
response.config.url !== '/authz/check' &&
|
||||
response.config.url !== '/api/v2/reset_password_tokens/verify'
|
||||
) {
|
||||
try {
|
||||
const accessToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { ApiV3Instance as axios } from 'api';
|
||||
import { omit } from 'lodash-es';
|
||||
import { getWaterfallV4 } from 'api/generated/services/tracedetail';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
GetTraceV3PayloadProps,
|
||||
GetTraceV3SuccessResponse,
|
||||
GetTraceV4PayloadProps,
|
||||
GetTraceV4SuccessResponse,
|
||||
SpanV3,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
const getTraceV3 = async (
|
||||
props: GetTraceV3PayloadProps,
|
||||
): Promise<SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse> => {
|
||||
const getTraceV4 = async (
|
||||
props: GetTraceV4PayloadProps,
|
||||
): Promise<SuccessResponse<GetTraceV4SuccessResponse> | ErrorResponse> => {
|
||||
let uncollapsedSpans = [...props.uncollapsedSpans];
|
||||
if (!props.isSelectedSpanIDUnCollapsed) {
|
||||
uncollapsedSpans = uncollapsedSpans.filter(
|
||||
@@ -19,31 +18,37 @@ const getTraceV3 = async (
|
||||
props.selectedSpanId &&
|
||||
!uncollapsedSpans.includes(props.selectedSpanId)
|
||||
) {
|
||||
// V3 backend only uses uncollapsedSpans list (unlike V2 which also interprets
|
||||
// Backend only uses the uncollapsedSpans list (unlike V2 which also interprets
|
||||
// isSelectedSpanIDUnCollapsed server-side), so explicitly add the selected span
|
||||
uncollapsedSpans.push(props.selectedSpanId);
|
||||
}
|
||||
const postData: GetTraceV3PayloadProps = {
|
||||
...props,
|
||||
uncollapsedSpans,
|
||||
limit: 10000,
|
||||
};
|
||||
const response = await axios.post<GetTraceV3SuccessResponse>(
|
||||
`/traces/${props.traceId}/waterfall`,
|
||||
omit(postData, 'traceId'),
|
||||
const response = await getWaterfallV4(
|
||||
{ traceID: props.traceId },
|
||||
{
|
||||
selectedSpanId: props.selectedSpanId,
|
||||
uncollapsedSpans,
|
||||
limit: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
// V3 API wraps response in { status, data }
|
||||
const rawPayload = (response.data as any).data || response.data;
|
||||
// Generated client unwraps the axios response; .data is the waterfall payload.
|
||||
// Wire spans carry time_unix; SpanV3's timestamp + 'service.name' are derived below.
|
||||
type WireSpan = Omit<SpanV3, 'timestamp' | 'service.name'> & {
|
||||
time_unix: number;
|
||||
};
|
||||
const rawPayload = response.data as unknown as Omit<
|
||||
GetTraceV4SuccessResponse,
|
||||
'spans'
|
||||
> & { spans: WireSpan[] | null };
|
||||
|
||||
// Derive 'service.name' from resource for convenience — only derived field
|
||||
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
|
||||
const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({
|
||||
...span,
|
||||
'service.name': span.resource?.['service.name'] || '',
|
||||
timestamp: span.time_unix,
|
||||
}));
|
||||
|
||||
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
|
||||
// API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
|
||||
// not absolute unix millis like V2. The span timestamps are absolute unix millis.
|
||||
// Convert by using the first span's timestamp as the base if there's a mismatch.
|
||||
let { startTimestampMillis, endTimestampMillis } = rawPayload;
|
||||
@@ -70,4 +75,4 @@ const getTraceV3 = async (
|
||||
};
|
||||
};
|
||||
|
||||
export default getTraceV3;
|
||||
export default getTraceV4;
|
||||
@@ -349,7 +349,7 @@ function convertV5DataByType(
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function convertV5ResponseToLegacy(
|
||||
v5Response: SuccessResponse<MetricRangePayloadV5>,
|
||||
v5Response: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5>,
|
||||
legendMap: Record<string, string>,
|
||||
formatForWeb?: boolean,
|
||||
): SuccessResponse<MetricRangePayloadV3> & { warning?: Warning } {
|
||||
@@ -357,7 +357,7 @@ export function convertV5ResponseToLegacy(
|
||||
const v5Data = payload?.data;
|
||||
|
||||
const aggregationPerQuery =
|
||||
(params as QueryRangeRequestV5)?.compositeQuery?.queries
|
||||
params?.compositeQuery?.queries
|
||||
?.filter((query) => query.type === 'builder_query')
|
||||
.reduce(
|
||||
(acc, query) => {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#prefix__clip0_4062_7291)" stroke-width="1.167" stroke-linecap="round" stroke-linejoin="round"><path d="M7 12.833A5.833 5.833 0 107 1.167a5.833 5.833 0 000 11.666z" fill="#E5484D" stroke="#E5484D"/><path d="M8.75 5.25l-3.5 3.5M5.25 5.25l3.5 3.5" stroke="#121317"/></g><defs><clipPath id="prefix__clip0_4062_7291"><path fill="#fff" d="M0 0h14v14H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 467 B |
@@ -359,8 +359,7 @@ function CustomTimePickerPopoverContent({
|
||||
<Clock
|
||||
color={Color.BG_ROBIN_400}
|
||||
className="timezone-container__clock-icon"
|
||||
height={12}
|
||||
width={12}
|
||||
size={14}
|
||||
/>
|
||||
|
||||
<span className="timezone__name">{timezone.name}</span>
|
||||
|
||||
@@ -27,13 +27,15 @@ function SortableField({
|
||||
field,
|
||||
onRemove,
|
||||
allowDrag,
|
||||
isRequired,
|
||||
}: {
|
||||
field: TelemetryFieldKey;
|
||||
onRemove: (field: TelemetryFieldKey) => void;
|
||||
allowDrag: boolean;
|
||||
isRequired: boolean;
|
||||
}): JSX.Element {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: field.name });
|
||||
useSortable({ id: field.key as string });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -53,15 +55,17 @@ function SortableField({
|
||||
{allowDrag && <GripVertical size={14} />}
|
||||
<span className={styles.fieldKey}>{field.name}</span>
|
||||
</div>
|
||||
<Button
|
||||
className={cx(styles.removeBtn, 'periscope-btn')}
|
||||
variant="outlined"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
onClick={(): void => onRemove(field)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
{!isRequired && (
|
||||
<Button
|
||||
className={cx(styles.removeBtn, 'periscope-btn')}
|
||||
variant="outlined"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
onClick={(): void => onRemove(field)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +75,7 @@ interface AddedFieldsProps {
|
||||
fields: TelemetryFieldKey[];
|
||||
onFieldsChange: (fields: TelemetryFieldKey[]) => void;
|
||||
maxFields?: number;
|
||||
requiredFields?: readonly string[];
|
||||
}
|
||||
|
||||
function AddedFields({
|
||||
@@ -78,14 +83,18 @@ function AddedFields({
|
||||
fields,
|
||||
onFieldsChange,
|
||||
maxFields,
|
||||
requiredFields = [],
|
||||
}: AddedFieldsProps): JSX.Element {
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
// Contract: caller (FieldsSelector) normalizes `fields` so every entry has
|
||||
// `.key` populated. AddedFields reads it directly.
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent): void => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = fields.findIndex((f) => f.name === active.id);
|
||||
const newIndex = fields.findIndex((f) => f.name === over.id);
|
||||
const oldIndex = fields.findIndex((f) => f.key === active.id);
|
||||
const newIndex = fields.findIndex((f) => f.key === over.id);
|
||||
onFieldsChange(arrayMove(fields, oldIndex, newIndex));
|
||||
}
|
||||
};
|
||||
@@ -99,7 +108,7 @@ function AddedFields({
|
||||
);
|
||||
|
||||
const handleRemove = (field: TelemetryFieldKey): void => {
|
||||
onFieldsChange(fields.filter((f) => f.name !== field.name));
|
||||
onFieldsChange(fields.filter((f) => f.key !== field.key));
|
||||
};
|
||||
|
||||
const allowDrag = inputValue.length === 0;
|
||||
@@ -125,16 +134,17 @@ function AddedFields({
|
||||
<div className={styles.noValues}>No values found</div>
|
||||
) : (
|
||||
<SortableContext
|
||||
items={fields.map((f) => f.name)}
|
||||
items={fields.map((f) => f.key as string)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
disabled={!allowDrag}
|
||||
>
|
||||
{filteredFields.map((field) => (
|
||||
<SortableField
|
||||
key={field.name}
|
||||
key={field.key}
|
||||
field={field}
|
||||
onRemove={handleRemove}
|
||||
allowDrag={allowDrag}
|
||||
isRequired={requiredFields.includes(field.key as string)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
@@ -76,11 +76,8 @@
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
// Ant Skeleton.Input rendered inside the loading state — override its
|
||||
// hard-coded width.
|
||||
:global(.ant-skeleton-input) {
|
||||
width: 300px;
|
||||
margin: 8px 12px;
|
||||
width: 50% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +92,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
@@ -132,7 +130,7 @@
|
||||
}
|
||||
|
||||
.isDragDisabled {
|
||||
padding: 6px 12px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.otherFieldItem {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Input } from '@signozhq/ui/input';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -26,33 +27,36 @@ interface FieldsSelectorProps {
|
||||
onClose: () => void;
|
||||
signal: DataSource;
|
||||
maxFields?: number;
|
||||
requiredFields?: readonly string[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
defaultPosition?: { x: number; y: number };
|
||||
}
|
||||
|
||||
function FieldsSelector({
|
||||
isOpen,
|
||||
type FieldsSelectorContentProps = Omit<FieldsSelectorProps, 'isOpen'>;
|
||||
|
||||
// Inner component: holds all hooks + UI. Gets mounted/unmounted via the
|
||||
// outer gate so opening always seeds a fresh draft from `fields`.
|
||||
// Assumes `fields` arrives normalized (key populated) — see outer gate.
|
||||
function FieldsSelectorContent({
|
||||
title,
|
||||
fields,
|
||||
onFieldsChange,
|
||||
onClose,
|
||||
signal,
|
||||
maxFields,
|
||||
requiredFields,
|
||||
width = DEFAULT_PANEL_WIDTH,
|
||||
height,
|
||||
defaultPosition,
|
||||
}: FieldsSelectorProps): JSX.Element | null {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
}: FieldsSelectorContentProps): JSX.Element {
|
||||
const resolvedHeight =
|
||||
height ?? window.innerHeight - DEFAULT_PANEL_HEIGHT_OFFSET;
|
||||
const resolvedPosition = defaultPosition ?? {
|
||||
x: window.innerWidth - width - DEFAULT_PANEL_RIGHT_INSET,
|
||||
y: DEFAULT_PANEL_TOP_INSET,
|
||||
};
|
||||
|
||||
const [draftFields, setDraftFields] = useState<TelemetryFieldKey[]>(fields);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [debouncedInputValue, setDebouncedInputValue] = useState('');
|
||||
@@ -72,15 +76,17 @@ function FieldsSelector({
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(field: TelemetryFieldKey): void => {
|
||||
if (maxFields !== undefined && draftFields.length >= maxFields) {
|
||||
return;
|
||||
}
|
||||
if (draftFields.some((f) => f.name === field.name)) {
|
||||
return;
|
||||
}
|
||||
setDraftFields((prev) => [...prev, field]);
|
||||
setDraftFields((prev) => {
|
||||
if (maxFields !== undefined && prev.length >= maxFields) {
|
||||
return prev;
|
||||
}
|
||||
if (prev.some((f) => f.key === field.key)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, field];
|
||||
});
|
||||
},
|
||||
[draftFields, maxFields],
|
||||
[maxFields],
|
||||
);
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
@@ -99,7 +105,7 @@ function FieldsSelector({
|
||||
() =>
|
||||
!(
|
||||
draftFields.length === fields.length &&
|
||||
draftFields.every((f, i) => f.name === fields[i]?.name)
|
||||
draftFields.every((f, i) => f.key === fields[i]?.key)
|
||||
),
|
||||
[draftFields, fields],
|
||||
);
|
||||
@@ -138,6 +144,7 @@ function FieldsSelector({
|
||||
fields={draftFields}
|
||||
onFieldsChange={setDraftFields}
|
||||
maxFields={maxFields}
|
||||
requiredFields={requiredFields}
|
||||
/>
|
||||
|
||||
<OtherFields
|
||||
@@ -173,4 +180,27 @@ function FieldsSelector({
|
||||
);
|
||||
}
|
||||
|
||||
// Outer gate: normalizes `fields` once (populates `key` so downstream code
|
||||
// can read it directly) and decides whether the inner component renders.
|
||||
// When isOpen flips false→true, the inner remounts → draft state seeds fresh.
|
||||
function FieldsSelector({
|
||||
isOpen,
|
||||
fields,
|
||||
...rest
|
||||
}: FieldsSelectorProps): JSX.Element | null {
|
||||
const normalizedFields = useMemo<TelemetryFieldKey[]>(
|
||||
() =>
|
||||
fields.map((f) => ({
|
||||
...f,
|
||||
key: f.key ?? buildCompositeKey(f.name, f.fieldContext),
|
||||
})),
|
||||
[fields],
|
||||
);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
return <FieldsSelectorContent {...rest} fields={normalizedFields} />;
|
||||
}
|
||||
|
||||
export default FieldsSelector;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import {
|
||||
FieldContext,
|
||||
@@ -47,15 +48,22 @@ function OtherFields({
|
||||
|
||||
const otherFields: TelemetryFieldKey[] = useMemo(() => {
|
||||
const suggestions = Object.values(data?.data.data.keys || {}).flat();
|
||||
const addedNames = new Set(addedFields.map((f) => f.name));
|
||||
return suggestions
|
||||
.filter((attr) => !addedNames.has(attr.name))
|
||||
.map((attr) => ({
|
||||
// Normalize: synthesize `key` once so downstream reads can trust it.
|
||||
const normalizedSuggestions: TelemetryFieldKey[] = suggestions.map(
|
||||
(attr) => ({
|
||||
...attr,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext as string),
|
||||
signal: attr.signal as SignalType,
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType as FieldDataType,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
const addedIds = new Set(
|
||||
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
|
||||
);
|
||||
return normalizedSuggestions.filter(
|
||||
(attr) => !addedIds.has(attr.key as string),
|
||||
);
|
||||
}, [data, addedFields]);
|
||||
|
||||
if (isFetching) {
|
||||
@@ -64,8 +72,13 @@ function OtherFields({
|
||||
<div className={styles.sectionHeader}>OTHER FIELDS</div>
|
||||
<div className={styles.otherList}>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Skeleton.Input active size="small" key={i} />
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={i}
|
||||
className={cx(styles.fieldItem, styles.otherFieldItem)}
|
||||
>
|
||||
<Skeleton.Input active size="small" block />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,7 +96,7 @@ function OtherFields({
|
||||
) : (
|
||||
otherFields.map((attr) => (
|
||||
<div
|
||||
key={attr.name}
|
||||
key={attr.key}
|
||||
className={cx(styles.fieldItem, styles.otherFieldItem)}
|
||||
>
|
||||
<span className={styles.fieldKey}>{attr.name}</span>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import AddedFields from '../AddedFields';
|
||||
|
||||
// AddedFields assumes the caller has populated `key` (the parent
|
||||
// FieldsSelector does this via its normalization useMemo). Tests pre-populate
|
||||
// it directly.
|
||||
const makeField = (name: string, fieldContext = 'log'): TelemetryFieldKey => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldContext: fieldContext as TelemetryFieldKey['fieldContext'],
|
||||
fieldDataType: 'string',
|
||||
key: `${fieldContext}.${name}`,
|
||||
});
|
||||
|
||||
describe('AddedFields — requiredFields', () => {
|
||||
it('renders a Remove button for every field when no requiredFields are passed', () => {
|
||||
const fields = [makeField('a'), makeField('b'), makeField('c')];
|
||||
|
||||
render(
|
||||
<AddedFields inputValue="" fields={fields} onFieldsChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('hides the Remove button for fields whose composite key is in requiredFields', () => {
|
||||
const fields = [makeField('a'), makeField('b'), makeField('c')];
|
||||
|
||||
render(
|
||||
<AddedFields
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.a', 'log.c']}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Only 'b' is removable.
|
||||
const removeButtons = screen.getAllByRole('button', { name: /remove/i });
|
||||
expect(removeButtons).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still renders the field name for required fields', () => {
|
||||
const fields = [makeField('a'), makeField('b')];
|
||||
|
||||
render(
|
||||
<AddedFields
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.a']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('a')).toBeInTheDocument();
|
||||
expect(screen.getByText('b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('locks ONLY the canonical variant — a same-name field from another context stays removable', () => {
|
||||
// Two `body` fields with different contexts. requiredFields holds the
|
||||
// canonical composite key only, so the attribute variant is deletable.
|
||||
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
|
||||
|
||||
render(
|
||||
<AddedFields
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.body']}
|
||||
/>,
|
||||
);
|
||||
|
||||
// One Remove button: the attribute variant. log variant is locked.
|
||||
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not lock anything when a bare name is passed (composite key required)', () => {
|
||||
// Bare `body` no longer matches — matching is composite-key only now.
|
||||
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
|
||||
|
||||
render(
|
||||
<AddedFields
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['body']}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Neither variant locked → both removable.
|
||||
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('treats requiredFields as exact composite-key match (substring does not lock)', () => {
|
||||
const fields = [makeField('body'), makeField('body_extra')];
|
||||
|
||||
render(
|
||||
<AddedFields
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.body']}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 'log.body' locked, 'log.body_extra' removable.
|
||||
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -3,17 +3,12 @@ import { useLocation } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import FeedbackModal from '../FeedbackModal';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
@@ -35,7 +30,6 @@ jest.mock('container/Integrations/utils', () => ({
|
||||
handleContactSupport: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockLogEvent = logEvent as jest.MockedFunction<typeof logEvent>;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
const mockHandleContactSupport = handleContactSupport as jest.Mock;
|
||||
@@ -50,6 +44,7 @@ const mockLocation = {
|
||||
describe('FeedbackModal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
logEventMock.mockReturnValue(Promise.resolve() as never);
|
||||
mockUseLocation.mockReturnValue(mockLocation);
|
||||
mockUseGetTenantLicense.mockReturnValue({
|
||||
isCloudUser: false,
|
||||
@@ -116,7 +111,7 @@ describe('FeedbackModal', () => {
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'feedback',
|
||||
page: mockLocation.pathname,
|
||||
@@ -149,7 +144,7 @@ describe('FeedbackModal', () => {
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'reportBug',
|
||||
page: mockLocation.pathname,
|
||||
@@ -182,7 +177,7 @@ describe('FeedbackModal', () => {
|
||||
await user.type(textarea, testFeedback);
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Feedback: Submitted', {
|
||||
data: testFeedback,
|
||||
type: 'featureRequest',
|
||||
page: mockLocation.pathname,
|
||||
|
||||
@@ -2,16 +2,11 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
|
||||
import HeaderRightSection from '../HeaderRightSection';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
@@ -50,7 +45,6 @@ jest.mock('hooks/useIsAIAssistantEnabled', () => ({
|
||||
useIsAIAssistantEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
const mockLogEvent = logEvent as jest.Mock;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
|
||||
@@ -120,7 +114,7 @@ describe('HeaderRightSection', () => {
|
||||
|
||||
await user.click(feedbackButton!);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Feedback: Clicked', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Feedback: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(screen.getByTestId('feedback-modal')).toBeInTheDocument();
|
||||
@@ -133,7 +127,7 @@ describe('HeaderRightSection', () => {
|
||||
const shareButton = screen.getByRole('button', { name: /share/i });
|
||||
await user.click(shareButton);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Share: Clicked', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Share: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
expect(screen.getByTestId('share-modal')).toBeInTheDocument();
|
||||
@@ -150,7 +144,7 @@ describe('HeaderRightSection', () => {
|
||||
|
||||
await user.click(announcementsButton!);
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Announcements: Clicked', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Announcements: Clicked', {
|
||||
page: mockLocation.pathname,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,18 +5,13 @@ import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
|
||||
import ShareURLModal from '../ShareURLModal';
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
@@ -53,7 +48,6 @@ Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mockLogEvent = logEvent as jest.Mock;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseUrlQuery = useUrlQuery as jest.Mock;
|
||||
const mockUseSelector = useSelector as jest.Mock;
|
||||
@@ -125,7 +119,7 @@ describe('ShareURLModal', () => {
|
||||
await user.click(copyButton);
|
||||
|
||||
expect(mockHandleCopyToClipboard).toHaveBeenCalled();
|
||||
expect(mockLogEvent).toHaveBeenCalledWith('Share: Copy link clicked', {
|
||||
expect(logEventMock).toHaveBeenCalledWith('Share: Copy link clicked', {
|
||||
page: TEST_PATH,
|
||||
URL: expect.any(String),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
|
||||
import { useLogsTableColumns } from '../useLogsTableColumns';
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): { formatTimezoneAdjustedTimestamp: jest.Mock } => ({
|
||||
formatTimezoneAdjustedTimestamp: jest.fn(() => 'TS'),
|
||||
}),
|
||||
}));
|
||||
|
||||
const field = (name: string, type = ''): IField => ({
|
||||
name,
|
||||
type,
|
||||
dataType: 'string',
|
||||
});
|
||||
|
||||
describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
it('prepends stateIndicator and renders user fields in array order', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [field('c'), field('a'), field('b')],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('slots body and timestamp at their position in the fields array', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [
|
||||
field('service.name'),
|
||||
field('body', 'log'),
|
||||
field('request.id'),
|
||||
field('timestamp', 'log'),
|
||||
],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
// body/timestamp appear where the caller placed them, keyed by their
|
||||
// composite IDs ('log.*'); contextless user fields collapse to bare name.
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'service.name',
|
||||
'log.body',
|
||||
'request.id',
|
||||
'log.timestamp',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders a same-name field from another context as a DISTINCT column (no collision)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [field('body', 'log'), field('body', 'attribute')],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
const byId = new Map(result.current.map((c) => [c.id, c]));
|
||||
// Attribute variant is its own column, not a duplicate 'log.body'.
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'log.body',
|
||||
'attribute.body',
|
||||
]);
|
||||
expect(byId.get('log.body')?.enableRemove).toBe(false);
|
||||
expect(byId.get('attribute.body')?.enableRemove).toBe(true);
|
||||
});
|
||||
|
||||
it('applies the same distinct-column treatment to timestamp variants', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [field('timestamp', 'log'), field('timestamp', 'attribute')],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
const byId = new Map(result.current.map((c) => [c.id, c]));
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'log.timestamp',
|
||||
'attribute.timestamp',
|
||||
]);
|
||||
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
|
||||
expect(byId.get('attribute.timestamp')?.enableRemove).toBe(true);
|
||||
});
|
||||
|
||||
it('skips the synthetic "id" field name', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [field('id'), field('a'), field('b')],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the special body/timestamp coldefs (canBeHidden=false), not the generic user field def', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [
|
||||
field('body', 'log'),
|
||||
field('timestamp', 'log'),
|
||||
field('user_field'),
|
||||
],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
const byId = new Map(result.current.map((c) => [c.id, c]));
|
||||
// body + timestamp are locked from the table-X removal pathway.
|
||||
expect(byId.get('log.body')?.canBeHidden).toBe(false);
|
||||
expect(byId.get('log.body')?.enableRemove).toBe(false);
|
||||
expect(byId.get('log.timestamp')?.canBeHidden).toBe(false);
|
||||
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
|
||||
// User-added fields stay removable. User field has type='' so composite
|
||||
// collapses to bare name.
|
||||
expect(byId.get('user_field')?.enableRemove).toBe(true);
|
||||
});
|
||||
|
||||
it('renders only the stateIndicator when fields is empty', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual(['state-indicator']);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getSanitizedLogBody,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
@@ -18,13 +19,11 @@ import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
||||
type UseLogsTableColumnsProps = {
|
||||
fields: IField[];
|
||||
fontSize: FontSize;
|
||||
appendTo?: 'center' | 'end';
|
||||
};
|
||||
|
||||
export function useLogsTableColumns({
|
||||
fields,
|
||||
fontSize,
|
||||
appendTo = 'center',
|
||||
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
@@ -47,74 +46,74 @@ export function useLogsTableColumns({
|
||||
),
|
||||
};
|
||||
|
||||
const fieldColumns: TableColumnDef<ILog>[] = fields
|
||||
.filter((f): boolean => !['id', 'body', 'timestamp'].includes(f.name))
|
||||
.map(
|
||||
(f): TableColumnDef<ILog> => ({
|
||||
id: f.name,
|
||||
header: f.name,
|
||||
accessorFn: (log): unknown => FlatLogData(log)[f.name],
|
||||
enableRemove: true,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
),
|
||||
}),
|
||||
);
|
||||
const timestampCol: TableColumnDef<ILog> = {
|
||||
id: buildCompositeKey('timestamp', 'log'),
|
||||
header: 'Timestamp',
|
||||
accessorFn: (log): unknown => log.timestamp,
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
width: { default: 170, min: 170 },
|
||||
cell: ({ value }): ReactElement => {
|
||||
const ts = value as string | number;
|
||||
const formatted =
|
||||
typeof ts === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
ts / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
return <TanStackTable.Text>{formatted}</TanStackTable.Text>;
|
||||
},
|
||||
};
|
||||
|
||||
const timestampCol: TableColumnDef<ILog> | null = fields.some(
|
||||
(f) => f.name === 'timestamp',
|
||||
)
|
||||
? {
|
||||
id: 'timestamp',
|
||||
header: 'Timestamp',
|
||||
accessorFn: (log): unknown => log.timestamp,
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
width: { default: 170, min: 170 },
|
||||
cell: ({ value }): ReactElement => {
|
||||
const ts = value as string | number;
|
||||
const formatted =
|
||||
typeof ts === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
ts / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
return <TanStackTable.Text>{formatted}</TanStackTable.Text>;
|
||||
},
|
||||
const bodyCol: TableColumnDef<ILog> = {
|
||||
id: buildCompositeKey('body', 'log'),
|
||||
header: 'Body',
|
||||
accessorFn: (log): string => getBodyDisplayString(log.body),
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
width: { default: '100%', min: 300 },
|
||||
cell: ({ value, isActive }): ReactElement => (
|
||||
<TanStackTable.Text
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getSanitizedLogBody(value as string, {
|
||||
shouldEscapeHtml: true,
|
||||
}),
|
||||
}}
|
||||
data-active={isActive}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const makeUserFieldCol = (f: IField): TableColumnDef<ILog> => ({
|
||||
id: buildCompositeKey(f.name, f.type),
|
||||
header: f.name,
|
||||
accessorFn: (log): unknown => FlatLogData(log)[f.name],
|
||||
enableRemove: true,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
),
|
||||
});
|
||||
|
||||
// Match body/timestamp by composite key, not bare name — else a variant
|
||||
// like `attribute.body` collapses onto `log.body`, duplicating the column.
|
||||
const fieldCols = fields
|
||||
.map((f): TableColumnDef<ILog> | null => {
|
||||
if (f.name === 'id') {
|
||||
return null;
|
||||
}
|
||||
: null;
|
||||
|
||||
const bodyCol: TableColumnDef<ILog> | null = fields.some(
|
||||
(f) => f.name === 'body',
|
||||
)
|
||||
? {
|
||||
id: 'body',
|
||||
header: 'Body',
|
||||
accessorFn: (log): string => getBodyDisplayString(log.body),
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
width: { default: '100%', min: 300 },
|
||||
cell: ({ value, isActive }): ReactElement => (
|
||||
<TanStackTable.Text
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getSanitizedLogBody(value as string, {
|
||||
shouldEscapeHtml: true,
|
||||
}),
|
||||
}}
|
||||
data-active={isActive}
|
||||
/>
|
||||
),
|
||||
const compositeKey = buildCompositeKey(f.name, f.type);
|
||||
if (compositeKey === timestampCol.id) {
|
||||
return timestampCol;
|
||||
}
|
||||
: null;
|
||||
if (compositeKey === bodyCol.id) {
|
||||
return bodyCol;
|
||||
}
|
||||
return makeUserFieldCol(f);
|
||||
})
|
||||
.filter((c): c is TableColumnDef<ILog> => c !== null);
|
||||
|
||||
return [
|
||||
stateIndicatorCol,
|
||||
...(timestampCol ? [timestampCol] : []),
|
||||
...(appendTo === 'center' ? fieldColumns : []),
|
||||
...(bodyCol ? [bodyCol] : []),
|
||||
...(appendTo === 'end' ? fieldColumns : []),
|
||||
];
|
||||
}, [fields, appendTo, fontSize, formatTimezoneAdjustedTimestamp]);
|
||||
return [stateIndicatorCol, ...fieldCols];
|
||||
}, [fields, fontSize, formatTimezoneAdjustedTimestamp]);
|
||||
}
|
||||
|
||||
@@ -256,6 +256,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
.edit-columns-container {
|
||||
padding: 12px;
|
||||
|
||||
.edit-columns-btn {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
padding: 4px 0px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: none !important;
|
||||
|
||||
.edit-columns-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-columns-btn:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.add-new-column-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, InputNumber, Popover, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import cx from 'classnames';
|
||||
import { LogViewMode } from 'container/LogsTable';
|
||||
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
@@ -14,7 +11,6 @@ import {
|
||||
Minus,
|
||||
Plus,
|
||||
SlidersVertical,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import './LogsFormatOptionsMenu.styles.scss';
|
||||
@@ -23,14 +19,21 @@ interface LogsFormatOptionsMenuProps {
|
||||
items: any;
|
||||
selectedOptionFormat: any;
|
||||
config: OptionsMenuConfig;
|
||||
onOpenColumns?: () => void;
|
||||
}
|
||||
|
||||
interface OptionsMenuContentProps extends LogsFormatOptionsMenuProps {
|
||||
closePopover: () => void;
|
||||
}
|
||||
|
||||
function OptionsMenu({
|
||||
items,
|
||||
selectedOptionFormat,
|
||||
config,
|
||||
}: LogsFormatOptionsMenuProps): JSX.Element {
|
||||
const { maxLines, format, addColumn, fontSize } = config;
|
||||
onOpenColumns,
|
||||
closePopover,
|
||||
}: OptionsMenuContentProps): JSX.Element {
|
||||
const { maxLines, format, fontSize } = config;
|
||||
const [selectedItem, setSelectedItem] = useState(selectedOptionFormat);
|
||||
const maxLinesNumber = (maxLines?.value as number) || 1;
|
||||
const [maxLinesPerRow, setMaxLinesPerRow] = useState<number>(maxLinesNumber);
|
||||
@@ -40,13 +43,6 @@ function OptionsMenu({
|
||||
const [isFontSizeOptionsOpen, setIsFontSizeOptionsOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [showAddNewColumnContainer, setShowAddNewColumnContainer] =
|
||||
useState(false);
|
||||
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const initialMouseEnterRef = useRef<boolean>(false);
|
||||
|
||||
const onChange = useCallback(
|
||||
(key: LogViewMode) => {
|
||||
if (!format) {
|
||||
@@ -61,7 +57,6 @@ function OptionsMenu({
|
||||
const handleMenuItemClick = (key: LogViewMode): void => {
|
||||
setSelectedItem(key);
|
||||
onChange(key);
|
||||
setShowAddNewColumnContainer(false);
|
||||
};
|
||||
|
||||
const incrementMaxLinesPerRow = (): void => {
|
||||
@@ -76,20 +71,6 @@ function OptionsMenu({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchValueChange = useDebouncedFn((event): void => {
|
||||
// @ts-expect-error
|
||||
const value = event?.target?.value || '';
|
||||
|
||||
if (addColumn && addColumn?.onSearch) {
|
||||
addColumn?.onSearch(value);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const handleToggleAddNewColumn = (): void => {
|
||||
addColumn?.onSearch?.('');
|
||||
setShowAddNewColumnContainer(!showAddNewColumnContainer);
|
||||
};
|
||||
|
||||
const handleLinesPerRowChange = (maxLinesPerRow: number | null): void => {
|
||||
if (
|
||||
maxLinesPerRow &&
|
||||
@@ -100,6 +81,11 @@ function OptionsMenu({
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditColumns = (): void => {
|
||||
onOpenColumns?.();
|
||||
closePopover();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (maxLinesPerRow && config && config.maxLines?.onChange) {
|
||||
config.maxLines.onChange(maxLinesPerRow);
|
||||
@@ -112,110 +98,10 @@ function OptionsMenu({
|
||||
}
|
||||
}, [fontSizeValue]);
|
||||
|
||||
function handleColumnSelection(
|
||||
currentIndex: number,
|
||||
optionsData: DefaultOptionType[],
|
||||
): void {
|
||||
const currentItem = optionsData[currentIndex];
|
||||
const itemLength = optionsData.length;
|
||||
if (addColumn && addColumn?.onSelect) {
|
||||
addColumn?.onSelect(selectedValue, {
|
||||
label: currentItem.label,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
// if the last element is selected then select the previous one
|
||||
if (currentIndex === itemLength - 1) {
|
||||
// there should be more than 1 element in the list
|
||||
if (currentIndex - 1 >= 0) {
|
||||
const prevValue = optionsData[currentIndex - 1]?.value || null;
|
||||
setSelectedValue(prevValue as string | null);
|
||||
} else {
|
||||
// if there is only one element then just select and do nothing
|
||||
setSelectedValue(null);
|
||||
}
|
||||
} else {
|
||||
// selecting any random element from the list except the last one
|
||||
const nextIndex = currentIndex + 1;
|
||||
|
||||
const nextValue = optionsData[nextIndex]?.value || null;
|
||||
|
||||
setSelectedValue(nextValue as string | null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (!selectedValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionsData = addColumn?.options || [];
|
||||
|
||||
const currentIndex = optionsData.findIndex(
|
||||
(item) => item?.value === selectedValue,
|
||||
);
|
||||
|
||||
const itemLength = optionsData.length;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp': {
|
||||
const newValue = optionsData[Math.max(0, currentIndex - 1)]?.value;
|
||||
|
||||
setSelectedValue(newValue as string | null);
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
case 'ArrowDown': {
|
||||
const newValue =
|
||||
optionsData[Math.min(itemLength - 1, currentIndex + 1)]?.value;
|
||||
|
||||
setSelectedValue(newValue as string | null);
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
handleColumnSelection(currentIndex, optionsData);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll the selected item into view
|
||||
const listNode = listRef.current;
|
||||
if (listNode && selectedValue) {
|
||||
const optionsData = addColumn?.options || [];
|
||||
const currentIndex = optionsData.findIndex(
|
||||
(item) => item?.value === selectedValue,
|
||||
);
|
||||
const itemNode = listNode.children[currentIndex] as HTMLElement;
|
||||
if (itemNode) {
|
||||
itemNode.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedValue]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return (): void => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [selectedValue]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'nested-menu-container',
|
||||
showAddNewColumnContainer ? 'active' : '',
|
||||
)}
|
||||
className="nested-menu-container"
|
||||
onClick={(event): void => {
|
||||
// this is to restrict click events to propogate to parent
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
@@ -269,71 +155,7 @@ function OptionsMenu({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showAddNewColumnContainer && (
|
||||
<div className="add-new-column-container">
|
||||
<div className="add-new-column-header">
|
||||
<div className="title">
|
||||
<div className="periscope-btn ghost" onClick={handleToggleAddNewColumn}>
|
||||
<ChevronLeft
|
||||
size={14}
|
||||
className="back-icon"
|
||||
onClick={handleToggleAddNewColumn}
|
||||
/>
|
||||
</div>
|
||||
Add New Column
|
||||
</div>
|
||||
|
||||
<Input
|
||||
tabIndex={0}
|
||||
type="text"
|
||||
autoFocus
|
||||
onFocus={addColumn?.onFocus}
|
||||
onChange={handleSearchValueChange}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="add-new-column-content">
|
||||
{addColumn?.isFetching && (
|
||||
<div className="loading-container"> Loading ... </div>
|
||||
)}
|
||||
|
||||
<div className="column-format-new-options" ref={listRef}>
|
||||
{addColumn?.options?.map(({ label, value }, index) => (
|
||||
<div
|
||||
className={cx('column-name', value === selectedValue && 'selected')}
|
||||
key={value}
|
||||
onMouseEnter={(): void => {
|
||||
if (!initialMouseEnterRef.current) {
|
||||
setSelectedValue(value as string | null);
|
||||
}
|
||||
|
||||
initialMouseEnterRef.current = true;
|
||||
}}
|
||||
onMouseMove={(): void => {
|
||||
// this is added to handle the mouse move explicit event and not the re-rendered on mouse enter event
|
||||
setSelectedValue(value as string | null);
|
||||
}}
|
||||
onClick={(eve): void => {
|
||||
eve.stopPropagation();
|
||||
handleColumnSelection(index, addColumn?.options || []);
|
||||
}}
|
||||
>
|
||||
<div className="name">
|
||||
<Tooltip placement="left" title={label}>
|
||||
{label}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isFontSizeOptionsOpen && !showAddNewColumnContainer && (
|
||||
) : (
|
||||
<div>
|
||||
<div className="font-size-container">
|
||||
<div className="title">Font Size</div>
|
||||
@@ -373,72 +195,52 @@ function OptionsMenu({
|
||||
|
||||
{selectedItem && (
|
||||
<>
|
||||
<>
|
||||
<div className="horizontal-line" />
|
||||
<div className="max-lines-per-row">
|
||||
<div className="title"> max lines per row </div>
|
||||
<div className="raw-format max-lines-per-row-input">
|
||||
<button
|
||||
type="button"
|
||||
className="periscope-btn"
|
||||
onClick={decrementMaxLinesPerRow}
|
||||
>
|
||||
{' '}
|
||||
<Minus size={12} />{' '}
|
||||
</button>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxLinesPerRow}
|
||||
onChange={handleLinesPerRowChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="periscope-btn"
|
||||
onClick={incrementMaxLinesPerRow}
|
||||
>
|
||||
{' '}
|
||||
<Plus size={12} />{' '}
|
||||
</button>
|
||||
</div>
|
||||
<div className="horizontal-line" />
|
||||
<div className="max-lines-per-row">
|
||||
<div className="title"> max lines per row </div>
|
||||
<div className="raw-format max-lines-per-row-input">
|
||||
<button
|
||||
type="button"
|
||||
className="periscope-btn"
|
||||
onClick={decrementMaxLinesPerRow}
|
||||
>
|
||||
{' '}
|
||||
<Minus size={12} />{' '}
|
||||
</button>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxLinesPerRow}
|
||||
onChange={handleLinesPerRowChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="periscope-btn"
|
||||
onClick={incrementMaxLinesPerRow}
|
||||
>
|
||||
{' '}
|
||||
<Plus size={12} />{' '}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="selected-item-content-container active">
|
||||
{!showAddNewColumnContainer && <div className="horizontal-line" />}
|
||||
|
||||
<div className="item-content">
|
||||
{!showAddNewColumnContainer && (
|
||||
<div className="title">
|
||||
columns
|
||||
<Plus size={14} onClick={handleToggleAddNewColumn} />{' '}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="column-format">
|
||||
{addColumn?.value?.map(({ name }) => (
|
||||
<div className="column-name" key={name}>
|
||||
<div className="name">
|
||||
<Tooltip placement="left" title={name}>
|
||||
{name}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{addColumn?.value?.length > 1 && (
|
||||
<X
|
||||
className="delete-btn"
|
||||
size={14}
|
||||
onClick={(): void => addColumn.onRemove(name)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{addColumn && addColumn?.value?.length === 0 && (
|
||||
<div className="column-name no-columns-selected">
|
||||
No columns selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedItem === 'table' && onOpenColumns && (
|
||||
<>
|
||||
<div className="horizontal-line" />
|
||||
<div className="edit-columns-container">
|
||||
<Button
|
||||
className="edit-columns-btn"
|
||||
type="text"
|
||||
onClick={handleEditColumns}
|
||||
data-testid="periscope-btn-edit-columns"
|
||||
>
|
||||
<Typography.Text className="edit-columns-text">
|
||||
Edit columns
|
||||
</Typography.Text>
|
||||
<ChevronRight size={14} className="icon" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -452,6 +254,7 @@ function LogsFormatOptionsMenu({
|
||||
items,
|
||||
selectedOptionFormat,
|
||||
config,
|
||||
onOpenColumns,
|
||||
}: LogsFormatOptionsMenuProps): JSX.Element {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
return (
|
||||
@@ -461,6 +264,8 @@ function LogsFormatOptionsMenu({
|
||||
items={items}
|
||||
selectedOptionFormat={selectedOptionFormat}
|
||||
config={config}
|
||||
onOpenColumns={onOpenColumns}
|
||||
closePopover={(): void => setIsPopoverOpen(false)}
|
||||
/>
|
||||
}
|
||||
trigger="click"
|
||||
|
||||
@@ -155,4 +155,66 @@ describe('LogsFormatOptionsMenu (unit)', () => {
|
||||
expect(fontSizeOnChange).toHaveBeenCalledWith(FontSize.MEDIUM);
|
||||
});
|
||||
});
|
||||
|
||||
function renderWithOnOpen(
|
||||
onOpenColumns?: jest.Mock,
|
||||
selectedOptionFormat: 'table' | 'raw' | 'list' = 'table',
|
||||
): { getByTestId: ReturnType<typeof render>['getByTestId'] } {
|
||||
const items = [
|
||||
{ key: 'raw', label: 'Raw', data: { title: 'max lines per row' } },
|
||||
{ key: 'list', label: 'Default' },
|
||||
{ key: 'table', label: 'Column', data: { title: 'columns' } },
|
||||
];
|
||||
|
||||
const { getByTestId } = render(
|
||||
<LogsFormatOptionsMenu
|
||||
items={items}
|
||||
selectedOptionFormat={selectedOptionFormat}
|
||||
config={{
|
||||
format: { value: selectedOptionFormat, onChange: jest.fn() },
|
||||
maxLines: { value: 1, onChange: jest.fn() },
|
||||
fontSize: { value: FontSize.SMALL, onChange: jest.fn() },
|
||||
}}
|
||||
onOpenColumns={onOpenColumns}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(getByTestId('periscope-btn-format-options'));
|
||||
return { getByTestId };
|
||||
}
|
||||
|
||||
it('renders "Edit columns" row when format=table and onOpenColumns provided', () => {
|
||||
const onOpenColumns = jest.fn();
|
||||
const { getByTestId } = renderWithOnOpen(onOpenColumns, 'table');
|
||||
|
||||
expect(getByTestId('periscope-btn-edit-columns')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render "Edit columns" row when onOpenColumns is not provided', () => {
|
||||
renderWithOnOpen(undefined, 'table');
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-testid="periscope-btn-edit-columns"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not render "Edit columns" row when format is not table', () => {
|
||||
renderWithOnOpen(jest.fn(), 'raw');
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-testid="periscope-btn-edit-columns"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('fires onOpenColumns and closes the popover when "Edit columns" is clicked', async () => {
|
||||
const onOpenColumns = jest.fn();
|
||||
const { getByTestId } = renderWithOnOpen(onOpenColumns, 'table');
|
||||
|
||||
fireEvent.click(getByTestId('periscope-btn-edit-columns'));
|
||||
|
||||
expect(onOpenColumns).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
// Popover content unmounts on close.
|
||||
expect(document.querySelector('.menu-container')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,6 +109,16 @@ $custom-border-color: #2c3044;
|
||||
color: color-mix(in srgb, var(--l2-foreground) 45%, transparent);
|
||||
}
|
||||
|
||||
.ant-select-clear {
|
||||
background-color: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
// Customize tags in multiselect (dark mode by default)
|
||||
.ant-select-selection-item {
|
||||
background-color: var(--l1-border);
|
||||
@@ -392,7 +402,9 @@ $custom-border-color: #2c3044;
|
||||
// Custom dropdown styles for multi-select
|
||||
.custom-multiselect-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
max-height: 350px;
|
||||
// Tall enough to hold the react-virtuoso list (<=300px) + header/footer
|
||||
// so only the list scrolls (avoids a second scrollbar on this container).
|
||||
max-height: 410px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
@@ -483,8 +495,12 @@ $custom-border-color: #2c3044;
|
||||
.option-checkbox {
|
||||
width: 100%;
|
||||
|
||||
> span:not(.ant-checkbox) {
|
||||
width: 100%;
|
||||
// @signozhq/ui Checkbox renders children inside a <label> that is
|
||||
// content-sized by default. Make it fill the row (min-width: 0 lets it
|
||||
// shrink) so the option text below can truncate instead of overflowing.
|
||||
> label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.all-option-text {
|
||||
@@ -501,7 +517,12 @@ $custom-border-color: #2c3044;
|
||||
width: 100%;
|
||||
|
||||
.option-label-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
@@ -514,26 +535,30 @@ $custom-border-color: #2c3044;
|
||||
}
|
||||
}
|
||||
|
||||
.only-btn {
|
||||
display: none;
|
||||
}
|
||||
// Size the buttons to the row's resting content height (20px) and fully
|
||||
// override antd's default 32px Button box, so revealing them on hover
|
||||
// never changes the row height.
|
||||
.only-btn,
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 18px;
|
||||
min-height: 0;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
.only-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
.toggle-btn:hover {
|
||||
background-color: unset;
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.option-content:hover {
|
||||
.only-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
}
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
@@ -548,9 +573,6 @@ $custom-border-color: #2c3044;
|
||||
.option-checkbox:hover {
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
}
|
||||
.option-badge {
|
||||
display: none;
|
||||
|
||||
@@ -721,6 +721,53 @@ export const removeKeysFromExpression = (
|
||||
return result?.text ?? '';
|
||||
};
|
||||
|
||||
const escapeRegExp = (value: string): string =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
export const createVariablePlaceholderRegExp = (
|
||||
variableName: string,
|
||||
): RegExp => {
|
||||
const escapedName = escapeRegExp(variableName);
|
||||
// (?![\w.]) prevents $env from matching inside $environment or $env.attr
|
||||
return new RegExp(
|
||||
`(\\$${escapedName}(?![\\w.])|\\{\\{\\s*\\.?${escapedName}\\s*\\}\\}|\\[\\[\\s*${escapedName}\\s*\\]\\])`,
|
||||
'g',
|
||||
);
|
||||
};
|
||||
|
||||
const matchesVariablePlaceholder = (
|
||||
text: string,
|
||||
variableName: string,
|
||||
): boolean => createVariablePlaceholderRegExp(variableName).test(text);
|
||||
|
||||
export const removeVariableFromExpression = (
|
||||
expression: string | undefined,
|
||||
variableName: string,
|
||||
): string => {
|
||||
if (!expression) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const queryPairs = extractQueryPairs(expression);
|
||||
|
||||
const keysToRemove = queryPairs
|
||||
.filter((pair) => {
|
||||
const singleValue = pair.value?.toString() ?? '';
|
||||
const listValues = (pair.valueList ?? []).join(' ');
|
||||
return (
|
||||
matchesVariablePlaceholder(singleValue, variableName) ||
|
||||
matchesVariablePlaceholder(listValues, variableName)
|
||||
);
|
||||
})
|
||||
.map((pair) => pair.key);
|
||||
|
||||
if (keysToRemove.length === 0) {
|
||||
return expression;
|
||||
}
|
||||
|
||||
return removeKeysFromExpression(expression, keysToRemove, `$${variableName}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert old having format to new having format
|
||||
* @param having - Array of old having objects with columnName, op, and value
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import refreshPaymentStatus from 'api/v3/licenses/put';
|
||||
import cx from 'classnames';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { RefreshCcw } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
function RefreshPaymentStatus({
|
||||
btnShape,
|
||||
type,
|
||||
className,
|
||||
}: {
|
||||
btnShape?: 'default' | 'round' | 'circle';
|
||||
type?: 'button' | 'text' | 'tooltip';
|
||||
className?: string;
|
||||
}): JSX.Element {
|
||||
const { t } = useTranslation(['failedPayment']);
|
||||
const { activeLicenseRefetch } = useAppContext();
|
||||
@@ -31,26 +31,33 @@ function RefreshPaymentStatus({
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="link"
|
||||
color={type === 'text' ? 'none' : 'secondary'}
|
||||
size="md"
|
||||
className={className}
|
||||
onClick={handleRefreshPaymentStatus}
|
||||
prefix={<RefreshCcw size={14} />}
|
||||
loading={isLoading}
|
||||
>
|
||||
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<span className="refresh-payment-status-btn-wrapper">
|
||||
<Tooltip title={type === 'tooltip' ? t('refreshPaymentStatus') : ''}>
|
||||
<Button
|
||||
type={type === 'text' ? 'text' : 'default'}
|
||||
shape={btnShape}
|
||||
className={cx('periscope-btn', { text: type === 'text' })}
|
||||
onClick={handleRefreshPaymentStatus}
|
||||
icon={<RefreshCcw size={14} />}
|
||||
loading={isLoading}
|
||||
>
|
||||
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{type === 'tooltip' ? (
|
||||
<TooltipSimple title={t('refreshPaymentStatus')}>{button}</TooltipSimple>
|
||||
) : (
|
||||
button
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
RefreshPaymentStatus.defaultProps = {
|
||||
btnShape: 'default',
|
||||
type: 'button',
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default RefreshPaymentStatus;
|
||||
|
||||
@@ -72,6 +72,7 @@ function TanStackTableInner<TData>(
|
||||
data,
|
||||
columns,
|
||||
columnStorageKey,
|
||||
respectColumnOrder = true,
|
||||
columnSizing: columnSizingProp,
|
||||
onColumnSizingChange,
|
||||
onColumnOrderChange,
|
||||
@@ -175,6 +176,7 @@ function TanStackTableInner<TData>(
|
||||
storageKey: columnStorageKey,
|
||||
columns,
|
||||
isGrouped,
|
||||
respectColumnOrder,
|
||||
});
|
||||
|
||||
// Use store values when columnStorageKey is provided, otherwise fall back to props/defaults
|
||||
@@ -206,6 +208,7 @@ function TanStackTableInner<TData>(
|
||||
handleRemoveColumn,
|
||||
} = useColumnHandlers({
|
||||
columnStorageKey,
|
||||
respectColumnOrder,
|
||||
effectiveSizing,
|
||||
storeSetSizing,
|
||||
storeSetOrder,
|
||||
|
||||
@@ -24,6 +24,8 @@ jest.mock('../TanStackTable.module.scss', () => ({
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom doesn't include ResizeObserver — must direct-assign rather than
|
||||
// spyOn (spyOn requires the property to already exist).
|
||||
window.ResizeObserver = jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
@@ -867,4 +869,110 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasSingleColumn — gates the Remove popover per-column', () => {
|
||||
const hasSingleColumnFlagPresent = (): boolean =>
|
||||
Boolean(document.querySelector('th[data-single-column="true"]'));
|
||||
|
||||
it('is true when only one non-pinned column exists', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
data: [{ id: '1', name: 'Item 1', value: 100 }],
|
||||
columns: [
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(hasSingleColumnFlagPresent()).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when multiple non-pinned columns exist (all removable)', async () => {
|
||||
renderTanStackTable({});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 3 default columns (id/name/value), none pinned, none non-removable
|
||||
// → table is not single-column.
|
||||
expect(hasSingleColumnFlagPresent()).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when removable + non-removable mix exists (the body/timestamp case)', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
data: [{ id: '1', name: 'Item 1', value: 100 }],
|
||||
columns: [
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Timestamp',
|
||||
accessorKey: 'name',
|
||||
enableRemove: false,
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
{
|
||||
id: 'value',
|
||||
header: 'Body',
|
||||
accessorKey: 'value',
|
||||
enableRemove: false,
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
{
|
||||
id: 'id',
|
||||
header: 'User',
|
||||
accessorKey: 'id',
|
||||
enableRemove: true,
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(hasSingleColumnFlagPresent()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not count pinned columns toward the total', async () => {
|
||||
renderTanStackTable({
|
||||
props: {
|
||||
data: [{ id: '1', name: 'Item 1', value: 100 }],
|
||||
columns: [
|
||||
{
|
||||
id: 'stateIndicator',
|
||||
header: '',
|
||||
pin: 'left',
|
||||
accessorKey: 'id',
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 1 pinned + 1 non-pinned → only the non-pinned counts → single-column.
|
||||
expect(hasSingleColumnFlagPresent()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,6 +168,53 @@ describe('useColumnState', () => {
|
||||
'a',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores stored columnOrder when respectColumnOrder=false', () => {
|
||||
const columns = [col('a'), col('b'), col('c')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({
|
||||
storageKey: TEST_KEY,
|
||||
columns,
|
||||
respectColumnOrder: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// Falls through to natural columns-array order; stored order is ignored.
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toStrictEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('honors stored columnOrder when respectColumnOrder=true (default)', () => {
|
||||
const columns = [col('a'), col('b'), col('c')];
|
||||
|
||||
act(() => {
|
||||
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
|
||||
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useColumnState({
|
||||
storageKey: TEST_KEY,
|
||||
columns,
|
||||
respectColumnOrder: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.sortedColumns.map((c) => c.id)).toStrictEqual([
|
||||
'c',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actions', () => {
|
||||
|
||||
@@ -152,6 +152,8 @@ export type TanStackTableProps<TData> = {
|
||||
columns: TableColumnDef<TData>[];
|
||||
/** Storage key for column state persistence (visibility, sizing, ordering). When set, enables unified column management. */
|
||||
columnStorageKey?: string;
|
||||
/** When false, the table renders columns in their natural array order and the persisted columnOrder slot is ignored on read and skipped on write. Use when an external source (e.g. preferences.selectColumns) is the canonical order. Default true. */
|
||||
respectColumnOrder?: boolean;
|
||||
columnSizing?: ColumnSizingState;
|
||||
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
|
||||
onColumnOrderChange?: (cols: TableColumnDef<TData>[]) => void;
|
||||
|
||||
@@ -7,6 +7,8 @@ import { TableColumnDef } from './types';
|
||||
export interface UseColumnHandlersOptions<TData> {
|
||||
/** Storage key for persisting column state (enables store mode) */
|
||||
columnStorageKey?: string;
|
||||
/** When false, drag-reorder skips the persisted columnOrder write — order flows back via onColumnOrderChange only. */
|
||||
respectColumnOrder?: boolean;
|
||||
effectiveSizing: ColumnSizingState;
|
||||
storeSetSizing: (sizing: ColumnSizingState) => void;
|
||||
storeSetOrder: (columns: TableColumnDef<TData>[]) => void;
|
||||
@@ -28,6 +30,7 @@ export interface UseColumnHandlersResult<TData> {
|
||||
*/
|
||||
export function useColumnHandlers<TData>({
|
||||
columnStorageKey,
|
||||
respectColumnOrder = true,
|
||||
effectiveSizing,
|
||||
storeSetSizing,
|
||||
storeSetOrder,
|
||||
@@ -50,12 +53,12 @@ export function useColumnHandlers<TData>({
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(cols: TableColumnDef<TData>[]) => {
|
||||
if (columnStorageKey) {
|
||||
if (columnStorageKey && respectColumnOrder) {
|
||||
storeSetOrder(cols);
|
||||
}
|
||||
onColumnOrderChange?.(cols);
|
||||
},
|
||||
[columnStorageKey, storeSetOrder, onColumnOrderChange],
|
||||
[columnStorageKey, respectColumnOrder, storeSetOrder, onColumnOrderChange],
|
||||
);
|
||||
|
||||
const handleRemoveColumn = useCallback(
|
||||
|
||||
@@ -20,6 +20,7 @@ type UseColumnStateOptions<TData> = {
|
||||
storageKey?: string;
|
||||
columns: TableColumnDef<TData>[];
|
||||
isGrouped?: boolean;
|
||||
respectColumnOrder?: boolean;
|
||||
};
|
||||
|
||||
type UseColumnStateResult<TData> = {
|
||||
@@ -40,6 +41,7 @@ export function useColumnState<TData>({
|
||||
storageKey,
|
||||
columns,
|
||||
isGrouped = false,
|
||||
respectColumnOrder = true,
|
||||
}: UseColumnStateOptions<TData>): UseColumnStateResult<TData> {
|
||||
useEffect(() => {
|
||||
if (storageKey) {
|
||||
@@ -130,7 +132,7 @@ export function useColumnState<TData>({
|
||||
}, [hiddenColumnIds, columns, isGrouped]);
|
||||
|
||||
const sortedColumns = useMemo((): TableColumnDef<TData>[] => {
|
||||
if (columnOrder.length === 0) {
|
||||
if (!respectColumnOrder || columnOrder.length === 0) {
|
||||
return columns;
|
||||
}
|
||||
|
||||
@@ -144,7 +146,7 @@ export function useColumnState<TData>({
|
||||
});
|
||||
|
||||
return [...pinned, ...sortedRest];
|
||||
}, [columns, columnOrder]);
|
||||
}, [columns, columnOrder, respectColumnOrder]);
|
||||
|
||||
const hideColumn = useCallback(
|
||||
(columnId: string) => {
|
||||
|
||||
@@ -139,7 +139,6 @@ jest.mock('react-query', (): unknown => {
|
||||
});
|
||||
|
||||
// mock other side-effecty modules
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
jest.mock('api/browser/localstorage/set', () => jest.fn());
|
||||
jest.mock('utils/error', () => ({ showErrorNotification: jest.fn() }));
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ export const REACT_QUERY_KEY = {
|
||||
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
|
||||
GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3',
|
||||
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
|
||||
GET_TRACE_V3_WATERFALL: 'GET_TRACE_V3_WATERFALL',
|
||||
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
|
||||
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
|
||||
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
|
||||
GET_POD_LIST: 'GET_POD_LIST',
|
||||
GET_NODE_LIST: 'GET_NODE_LIST',
|
||||
|
||||
@@ -67,8 +67,8 @@
|
||||
gap: 4px;
|
||||
|
||||
&--success {
|
||||
background: color-mix(in srgb, var(--success-background) 10%, transparent);
|
||||
color: var(--success-foreground);
|
||||
background: color-mix(in srgb, var(--text-forest-500) 10%, transparent);
|
||||
color: var(--text-forest-400);
|
||||
}
|
||||
|
||||
&--error {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import {
|
||||
@@ -24,8 +24,6 @@ jest.mock('providers/cmdKProvider', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
|
||||
// Mock the AppContext
|
||||
const mockUpdateUserPreferenceInContext = jest.fn();
|
||||
|
||||
@@ -139,7 +137,7 @@ describe('Sidebar Toggle Shortcut', () => {
|
||||
it('should log the toggle event with correct parameters', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockHandleShortcut = jest.fn(() => {
|
||||
logEvent('Global Shortcut: Sidebar Toggle', {
|
||||
logEventMock('Global Shortcut: Sidebar Toggle', {
|
||||
previousState: false,
|
||||
newState: true,
|
||||
});
|
||||
@@ -155,10 +153,13 @@ describe('Sidebar Toggle Shortcut', () => {
|
||||
|
||||
await user.keyboard(SHIFT_B_KEYBOARD_SHORTCUT);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith('Global Shortcut: Sidebar Toggle', {
|
||||
previousState: false,
|
||||
newState: true,
|
||||
});
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'Global Shortcut: Sidebar Toggle',
|
||||
{
|
||||
previousState: false,
|
||||
newState: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should update user preference in context', async () => {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
.billingContainer {
|
||||
margin-bottom: var(--spacing-20);
|
||||
padding-top: 36px;
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
|
||||
.pageHeader {
|
||||
margin-bottom: var(--spacing-8);
|
||||
|
||||
.pageHeaderTitle {
|
||||
font-weight: var(--label-medium-500-font-weight);
|
||||
font-size: var(--label-medium-500-font-size);
|
||||
line-height: 32px;
|
||||
letter-spacing: -0.08px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.pageHeaderSubtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.pageInfoTitle {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.pageInfoSubtitle {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
:global(.ant-card) {
|
||||
padding: var(--padding-3);
|
||||
}
|
||||
|
||||
.billingManageBtn {
|
||||
background: var(--l3-background);
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.billingSummary {
|
||||
margin: var(--spacing-12) var(--spacing-4);
|
||||
}
|
||||
|
||||
.billingDetails {
|
||||
margin: var(--spacing-12) 0;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
:global {
|
||||
.ant-table {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
height: 52px;
|
||||
padding: 0 var(--padding-4);
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
height: 52px;
|
||||
padding: 0 var(--padding-4);
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
&:first-child {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
font-feature-settings:
|
||||
'zero' 1,
|
||||
'lnum' 1,
|
||||
'tnum' 1;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:last-child > td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: var(--l2-background) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.billingDetailsHeaderCell {
|
||||
position: relative;
|
||||
background: var(--l2-background) !important;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid var(--l1-border) !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-end: 0;
|
||||
width: 2px;
|
||||
background: var(--l2-background);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upgradePlanBenefits {
|
||||
margin: 0 var(--spacing-4);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 5px;
|
||||
padding: 0 var(--padding-12);
|
||||
|
||||
.planBenefits {
|
||||
.planBenefit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.billingGraphSection {
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--spacing-4);
|
||||
|
||||
.billingGraphFooter {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
|
||||
.billingFooterBtn {
|
||||
background: var(--l3-background);
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emptyGraphCard {
|
||||
:global(.ant-card-body) {
|
||||
height: 40vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.billingUpdateNote {
|
||||
margin-top: var(--spacing-8);
|
||||
font-family: var(--font-family-inter);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 22px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
:global {
|
||||
.ant-skeleton.ant-skeleton-element.ant-skeleton-active {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.ant-skeleton.ant-skeleton-element .ant-skeleton-input {
|
||||
min-width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
.billing-container {
|
||||
margin-bottom: 40px;
|
||||
padding-top: 36px;
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
|
||||
.billing-summary {
|
||||
margin: 24px 8px;
|
||||
}
|
||||
|
||||
.billing-details {
|
||||
margin: 24px 0px;
|
||||
|
||||
.ant-table-title {
|
||||
color: var(--l2-foreground);
|
||||
background-color: var(--l3-background);
|
||||
}
|
||||
|
||||
.ant-table-cell {
|
||||
background-color: var(--l1-background);
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
|
||||
.ant-table-tbody {
|
||||
td {
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upgrade-plan-benefits {
|
||||
margin: 0px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 5px;
|
||||
padding: 0 48px;
|
||||
.plan-benefits {
|
||||
.plan-benefit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-graph-card {
|
||||
.ant-card-body {
|
||||
height: 40vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.billing-update-note {
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-skeleton.ant-skeleton-element.ant-skeleton-active {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.ant-skeleton.ant-skeleton-element .ant-skeleton-input {
|
||||
min-width: 100% !important;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ describe('BillingContainer', () => {
|
||||
});
|
||||
expect(pricePerUnit).toBeInTheDocument();
|
||||
const cost = await screen.findByRole('columnheader', {
|
||||
name: /cost \(billing period to date\)/i,
|
||||
name: /cost/i,
|
||||
});
|
||||
expect(cost).toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { CircleCheck, CloudDownload } from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { CircleCheck, Landmark, MonitorDown } from '@signozhq/icons';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Flex,
|
||||
@@ -16,7 +15,10 @@ import {
|
||||
TableColumnsType as ColumnsType,
|
||||
} from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import getUsage, { UsageResponsePayloadProps } from 'api/billing/getUsage';
|
||||
import getUsage, {
|
||||
BreakdownEntry,
|
||||
UsageResponsePayloadProps,
|
||||
} from 'api/billing/getUsage';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateCreditCardApi from 'api/v1/checkout/create';
|
||||
import manageCreditCardApi from 'api/v1/portal/create';
|
||||
@@ -29,7 +31,7 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { isEmpty, pick } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { getFormattedDate, getRemainingDays } from 'utils/timeUtils';
|
||||
@@ -38,7 +40,7 @@ import CancelSubscriptionBanner from './CancelSubscriptionBanner';
|
||||
import { BillingUsageGraph } from './BillingUsageGraph/BillingUsageGraph';
|
||||
import { prepareCsvData } from './BillingUsageGraph/utils';
|
||||
|
||||
import './BillingContainer.styles.scss';
|
||||
import styles from './BillingContainer.module.scss';
|
||||
import { LicenseState } from 'types/api/licensesV3/getActive';
|
||||
|
||||
interface DataType {
|
||||
@@ -115,7 +117,7 @@ const dummyColumns: ColumnsType<DataType> = [
|
||||
render: renderSkeletonInput,
|
||||
},
|
||||
{
|
||||
title: 'Cost (Billing period to date)',
|
||||
title: 'Cost',
|
||||
dataIndex: 'cost',
|
||||
key: 'cost',
|
||||
render: renderSkeletonInput,
|
||||
@@ -130,7 +132,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
const [billAmount, setBillAmount] = useState(0);
|
||||
const [daysRemaining, setDaysRemaining] = useState(0);
|
||||
const [isFreeTrial, setIsFreeTrial] = useState(false);
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [data, setData] = useState<DataType[]>([]);
|
||||
const [apiResponse, setApiResponse] = useState<
|
||||
Partial<UsageResponsePayloadProps>
|
||||
>({});
|
||||
@@ -150,7 +152,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
const processUsageData = useCallback(
|
||||
(data: any): void => {
|
||||
(data: SuccessResponse<UsageResponsePayloadProps> | ErrorResponse): void => {
|
||||
if (isEmpty(data?.payload)) {
|
||||
return;
|
||||
}
|
||||
@@ -158,27 +160,23 @@ export default function BillingContainer(): JSX.Element {
|
||||
details: { breakdown = [], billTotal },
|
||||
billingPeriodStart,
|
||||
billingPeriodEnd,
|
||||
} = data?.payload || {};
|
||||
const formattedUsageData: any[] = [];
|
||||
} = (data as SuccessResponse<UsageResponsePayloadProps>).payload;
|
||||
const formattedUsageData: DataType[] = [];
|
||||
|
||||
if (breakdown && Array.isArray(breakdown)) {
|
||||
for (let index = 0; index < breakdown.length; index += 1) {
|
||||
const element = breakdown[index];
|
||||
const element: BreakdownEntry = breakdown[index];
|
||||
|
||||
element?.tiers.forEach(
|
||||
(
|
||||
tier: { quantity: number; unitPrice: number; tierCost: number },
|
||||
i: number,
|
||||
) => {
|
||||
formattedUsageData.push({
|
||||
key: `${index}${i}`,
|
||||
name: i === 0 ? element?.type : '',
|
||||
dataIngested: `${tier.quantity} ${element?.unit}`,
|
||||
pricePerUnit: tier.unitPrice,
|
||||
cost: `$ ${tier.tierCost}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
element?.tiers?.forEach((tier, i: number) => {
|
||||
formattedUsageData.push({
|
||||
key: `${index}${i}`,
|
||||
name: i === 0 ? element?.type : '',
|
||||
unit: element?.unit ?? '',
|
||||
dataIngested: `${tier.quantity} ${element?.unit}`,
|
||||
pricePerUnit: String(tier.unitPrice),
|
||||
cost: `$ ${tier.tierCost}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,16 +249,19 @@ export default function BillingContainer(): JSX.Element {
|
||||
title: 'Data Ingested',
|
||||
dataIndex: 'dataIngested',
|
||||
key: 'dataIngested',
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
title: 'Price per Unit',
|
||||
dataIndex: 'pricePerUnit',
|
||||
key: 'pricePerUnit',
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
title: 'Cost (Billing period to date)',
|
||||
title: 'Cost',
|
||||
dataIndex: 'cost',
|
||||
key: 'cost',
|
||||
align: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -345,23 +346,6 @@ export default function BillingContainer(): JSX.Element {
|
||||
updateCreditCard,
|
||||
]);
|
||||
|
||||
const BillingUsageGraphCallback = useCallback(
|
||||
() =>
|
||||
!isLoading && !isFetchingBillingData ? (
|
||||
<>
|
||||
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
|
||||
<div className="billing-update-note">
|
||||
Note: Billing metrics are updated once every 24 hours.
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Card className="empty-graph-card" bordered={false}>
|
||||
<Spinner size="large" tip="Loading..." height="35vh" />
|
||||
</Card>
|
||||
),
|
||||
[apiResponse, billAmount, isLoading, isFetchingBillingData],
|
||||
);
|
||||
|
||||
const subscriptionPastDueMessage = (): JSX.Element => (
|
||||
<Typography>
|
||||
{`We were not able to process payments for your account. Please update your card details `}
|
||||
@@ -415,12 +399,12 @@ export default function BillingContainer(): JSX.Element {
|
||||
trialInfo?.gracePeriodEnd;
|
||||
|
||||
return (
|
||||
<div className="billing-container">
|
||||
<Flex vertical style={{ marginBottom: 16 }}>
|
||||
<Typography.Text style={{ fontWeight: 500, fontSize: 18 }}>
|
||||
<div className={styles.billingContainer}>
|
||||
<Flex vertical gap={4} className={styles.pageHeader}>
|
||||
<Typography.Text className={styles.pageHeaderTitle}>
|
||||
{t('billing')}
|
||||
</Typography.Text>
|
||||
<Typography.Text color="muted">
|
||||
<Typography.Text className={styles.pageHeaderSubtitle}>
|
||||
{t('manage_billing_and_costs')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
@@ -428,50 +412,36 @@ export default function BillingContainer(): JSX.Element {
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{ minHeight: 150, marginBottom: 16 }}
|
||||
className="page-info"
|
||||
className={styles.pageInfo}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Flex vertical>
|
||||
<Typography.Title level={5} style={{ marginTop: 2, fontWeight: 500 }}>
|
||||
<Flex vertical gap={8}>
|
||||
<p className={styles.pageInfoTitle}>
|
||||
{isCloudUserVal ? t('teams_cloud') : t('teams')}{' '}
|
||||
{isFreeTrial ? <Badge color="success"> Free Trial </Badge> : ''}
|
||||
</Typography.Title>
|
||||
</p>
|
||||
|
||||
{!isLoading && !isFetchingBillingData && !showGracePeriodMessage ? (
|
||||
<Typography.Text style={{ fontSize: 12, color: Color.BG_VANILLA_400 }}>
|
||||
<p className={styles.pageInfoSubtitle}>
|
||||
{daysRemaining} {daysRemainingStr}
|
||||
</Typography.Text>
|
||||
</p>
|
||||
) : null}
|
||||
</Flex>
|
||||
<Flex gap={8}>
|
||||
<Button
|
||||
type="default"
|
||||
size="middle"
|
||||
loading={isLoadingBilling || isLoadingManageBilling}
|
||||
disabled={isLoading || isFetchingBillingData}
|
||||
onClick={handleCsvDownload}
|
||||
className="periscope-btn"
|
||||
>
|
||||
<Flex align="center" justify="center" gap={4}>
|
||||
<CloudDownload size="md" />
|
||||
Download CSV
|
||||
</Flex>
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="header-billing-button"
|
||||
type="primary"
|
||||
size="middle"
|
||||
loading={isLoadingBilling || isLoadingManageBilling}
|
||||
disabled={isLoading}
|
||||
onClick={handleBilling}
|
||||
>
|
||||
{trialInfo?.trialConvertedToSubscription
|
||||
? t('manage_billing')
|
||||
: t('upgrade_plan')}
|
||||
</Button>
|
||||
|
||||
<RefreshPaymentStatus type="tooltip" />
|
||||
</Flex>
|
||||
<Button
|
||||
testId="header-billing-button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
loading={isLoadingBilling || isLoadingManageBilling}
|
||||
disabled={isLoading}
|
||||
onClick={handleBilling}
|
||||
prefix={<Landmark size={14} />}
|
||||
className={styles.billingManageBtn}
|
||||
>
|
||||
{trialInfo?.trialConvertedToSubscription
|
||||
? t('manage_billing')
|
||||
: t('upgrade_plan')}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{trialInfo?.onTrial && trialInfo?.trialConvertedToSubscription && (
|
||||
@@ -485,8 +455,8 @@ export default function BillingContainer(): JSX.Element {
|
||||
|
||||
{!isLoading && !isFetchingBillingData && !showGracePeriodMessage
|
||||
? headerText && (
|
||||
<Alert
|
||||
message={headerText}
|
||||
<Callout
|
||||
title={headerText}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
@@ -503,8 +473,8 @@ export default function BillingContainer(): JSX.Element {
|
||||
billingData &&
|
||||
trialInfo?.gracePeriodEnd &&
|
||||
showGracePeriodMessage ? (
|
||||
<Alert
|
||||
message={`Your data is safe with us until ${getFormattedDate(
|
||||
<Callout
|
||||
title={`Your data is safe with us until ${getFormattedDate(
|
||||
trialInfo?.gracePeriodEnd || Date.now(),
|
||||
)}. Please upgrade plan now to retain your data.`}
|
||||
type="info"
|
||||
@@ -515,26 +485,69 @@ export default function BillingContainer(): JSX.Element {
|
||||
|
||||
{isSubscriptionPastDue &&
|
||||
(!isLoading && !isFetchingBillingData ? (
|
||||
<Alert
|
||||
message={subscriptionPastDueMessage()}
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
<Callout type="error" showIcon style={{ marginTop: 12 }}>
|
||||
{subscriptionPastDueMessage()}
|
||||
</Callout>
|
||||
) : (
|
||||
<Skeleton.Input active style={{ height: 20, marginTop: 20 }} />
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<BillingUsageGraphCallback />
|
||||
<div className={styles.billingGraphSection}>
|
||||
{!isLoading && !isFetchingBillingData ? (
|
||||
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
|
||||
) : (
|
||||
<Card className={styles.emptyGraphCard} bordered={false}>
|
||||
<Spinner size="large" tip="Loading..." height="35vh" />
|
||||
</Card>
|
||||
)}
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<div className={styles.billingGraphFooter}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={handleCsvDownload}
|
||||
prefix={<MonitorDown size={14} />}
|
||||
testId="download-csv-button"
|
||||
className={styles.billingFooterBtn}
|
||||
>
|
||||
Download CSV
|
||||
</Button>
|
||||
<RefreshPaymentStatus type="button" className={styles.billingFooterBtn} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<Callout type="info" size="small" className={styles.billingUpdateNote}>
|
||||
Billing metrics are updated once every 24 hours.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div className="billing-details">
|
||||
<div className={styles.billingDetails}>
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
components={{
|
||||
header: {
|
||||
cell: ({
|
||||
style,
|
||||
...props
|
||||
}: React.ThHTMLAttributes<HTMLTableCellElement>): JSX.Element => {
|
||||
const { background: _, boxShadow: __, ...safeStyle } = style ?? {};
|
||||
return (
|
||||
<th
|
||||
{...props}
|
||||
style={safeStyle}
|
||||
className={`${props.className ?? ''} ${styles.billingDetailsHeaderCell}`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -546,7 +559,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
)}
|
||||
|
||||
{!trialInfo?.trialConvertedToSubscription && (
|
||||
<div className="upgrade-plan-benefits">
|
||||
<div className={styles.upgradePlanBenefits}>
|
||||
<Row
|
||||
justify="space-between"
|
||||
align="middle"
|
||||
@@ -555,16 +568,16 @@ export default function BillingContainer(): JSX.Element {
|
||||
}}
|
||||
gutter={[16, 16]}
|
||||
>
|
||||
<Col span={20} className="plan-benefits">
|
||||
<Typography.Text className="plan-benefit">
|
||||
<Col span={20} className={styles.planBenefits}>
|
||||
<Typography.Text className={styles.planBenefit}>
|
||||
<CircleCheck size="md" />
|
||||
{t('upgrade_now_text')}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="plan-benefit">
|
||||
<Typography.Text className={styles.planBenefit}>
|
||||
<CircleCheck size="md" />
|
||||
{t('Your billing will start only after the trial period')}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="plan-benefit">
|
||||
<Typography.Text className={styles.planBenefit}>
|
||||
<CircleCheck size="md" />
|
||||
<span>
|
||||
{t('checkout_plans')}
|
||||
@@ -583,9 +596,10 @@ export default function BillingContainer(): JSX.Element {
|
||||
</Col>
|
||||
<Col span={4} style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
data-testid="upgrade-plan-button"
|
||||
type="primary"
|
||||
size="middle"
|
||||
testId="upgrade-plan-button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
loading={isLoadingBilling || isLoadingManageBilling}
|
||||
onClick={handleBilling}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.headerRow {
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
.itemList {
|
||||
overflow-y: auto;
|
||||
max-height: 300px;
|
||||
padding: var(--padding-3);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import TooltipHeader from 'lib/uPlotV2/components/Tooltip/components/TooltipHeader/TooltipHeader';
|
||||
import TooltipItem from 'lib/uPlotV2/components/Tooltip/components/TooltipItem/TooltipItem';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { buildTooltipContent } from 'lib/uPlotV2/components/Tooltip/utils';
|
||||
import {
|
||||
TooltipContentItem,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import TooltipStyles from 'lib/uPlotV2/components/Tooltip/Tooltip.module.scss';
|
||||
import Styles from './BillingBarChartTooltip.module.scss';
|
||||
|
||||
interface BillingBarChartTooltipProps extends TooltipRenderArgs {
|
||||
billingApiResponse: MetricRangePayloadProps;
|
||||
}
|
||||
|
||||
const CURRENCY_SYMBOL = '$';
|
||||
|
||||
export function BillingBarChartTooltip({
|
||||
billingApiResponse,
|
||||
uPlotInstance,
|
||||
dataIndexes,
|
||||
seriesIndex,
|
||||
isPinned,
|
||||
}: BillingBarChartTooltipProps): JSX.Element {
|
||||
const content = useMemo((): TooltipContentItem[] => {
|
||||
const baseItems = buildTooltipContent({
|
||||
data: uPlotInstance.data,
|
||||
series: uPlotInstance.series,
|
||||
dataIndexes,
|
||||
activeSeriesIndex: seriesIndex,
|
||||
uPlotInstance,
|
||||
yAxisUnit: '',
|
||||
isStackedBarChart: true,
|
||||
});
|
||||
|
||||
return baseItems.map((item) => {
|
||||
const match = billingApiResponse.data.result.find(
|
||||
(r) => (r.legend || r.queryName) === item.label,
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const seriesIdx = uPlotInstance.series.findIndex(
|
||||
(s) => s.label === item.label,
|
||||
);
|
||||
if (seriesIdx === -1) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const dataIndex = dataIndexes[seriesIdx];
|
||||
const quantity = dataIndex != null ? match.quantity?.[dataIndex] : null;
|
||||
const unit = match.unit ?? '';
|
||||
const quantityStr =
|
||||
quantity != null ? ` - ${getToolTipValue(quantity)} ${unit}` : '';
|
||||
|
||||
return {
|
||||
...item,
|
||||
tooltipValue: `${CURRENCY_SYMBOL}${getToolTipValue(item.value, '')}${quantityStr}`,
|
||||
};
|
||||
});
|
||||
}, [uPlotInstance, seriesIndex, dataIndexes, billingApiResponse]);
|
||||
|
||||
const activeItem = content.find((item) => item.isActive) ?? null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(TooltipStyles.container, {
|
||||
[TooltipStyles.pinned]: isPinned,
|
||||
})}
|
||||
data-testid="uplot-tooltip-container"
|
||||
>
|
||||
<TooltipHeader
|
||||
uPlotInstance={uPlotInstance}
|
||||
showTooltipHeader
|
||||
isPinned={isPinned}
|
||||
activeItem={null}
|
||||
headerRowClassName={Styles.headerRow}
|
||||
dateFormat={DATE_TIME_FORMATS.MONTH_DATE}
|
||||
/>
|
||||
{activeItem != null && <span className={TooltipStyles.divider} />}
|
||||
<div className={Styles.itemList} data-testid="uplot-tooltip-list">
|
||||
{content.map((item) => (
|
||||
<TooltipItem key={item.label} item={item} isItemActive={item.isActive} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
.graphContainer {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.billingGraphCard {
|
||||
:global {
|
||||
.uplot-no-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
height: 40vh;
|
||||
|
||||
.uplot-graph-container {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.totalSpent {
|
||||
font-family: 'SF Mono', monospace;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.totalSpentTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
letter-spacing: 0.48px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
.billing-graph-card {
|
||||
.ant-card-body {
|
||||
height: 40vh;
|
||||
.uplot-graph-container {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
.total-spent {
|
||||
font-family: 'SF Mono' monospace;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.total-spent-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
letter-spacing: 0.48px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
@@ -1,221 +1,146 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { Card, Flex } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import Uplot from 'components/Uplot';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import tooltipPlugin from 'lib/uPlotLib/plugins/tooltipPlugin';
|
||||
import getAxes from 'lib/uPlotLib/utils/getAxes';
|
||||
import getRenderer from 'lib/uPlotLib/utils/getRenderer';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { getXAxisScale } from 'lib/uPlotLib/utils/getXAxisScale';
|
||||
import { getYAxisScale } from 'lib/uPlotLib/utils/getYAxisScale';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import {
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import type uPlot from 'uplot';
|
||||
import type { UsageResponsePayloadProps } from 'api/billing/getUsage';
|
||||
|
||||
import { BillingBarChartTooltip } from './BillingBarChartTooltip';
|
||||
import { prepareBillingBarConfig } from './prepareBillingBarConfig';
|
||||
import {
|
||||
calculateStartEndTime,
|
||||
convertDataToMetricRangePayload,
|
||||
fillMissingValuesForQuantities,
|
||||
} from './utils';
|
||||
|
||||
import './BillingUsageGraph.styles.scss';
|
||||
import '../../../lib/uPlotLib/uPlotLib.styles.scss';
|
||||
import styles from './BillingUsageGraph.module.scss';
|
||||
|
||||
interface BillingUsageGraphProps {
|
||||
data: any;
|
||||
data: Partial<UsageResponsePayloadProps>;
|
||||
billAmount: number;
|
||||
}
|
||||
const paths = (
|
||||
u: any,
|
||||
seriesIdx: number,
|
||||
idx0: number,
|
||||
idx1: number,
|
||||
extendGap: boolean,
|
||||
buildClip: boolean,
|
||||
): uPlot.Series.PathBuilder => {
|
||||
const s = u.series[seriesIdx];
|
||||
const style = s.drawStyle;
|
||||
const interp = s.lineInterpolation;
|
||||
|
||||
const renderer = getRenderer(style, interp);
|
||||
|
||||
return renderer(u, seriesIdx, idx0, idx1, extendGap, buildClip);
|
||||
};
|
||||
|
||||
const calculateStartEndTime = (
|
||||
data: any,
|
||||
): { startTime: number; endTime: number } => {
|
||||
const timestamps: number[] = [];
|
||||
data?.details?.breakdown?.forEach((breakdown: any) => {
|
||||
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry: any) => {
|
||||
timestamps.push(entry?.timestamp);
|
||||
});
|
||||
});
|
||||
const billingTime = [data?.billingPeriodStart, data?.billingPeriodEnd];
|
||||
const startTime: number = Math.min(...timestamps, ...billingTime);
|
||||
const endTime: number = Math.max(...timestamps, ...billingTime);
|
||||
return { startTime, endTime };
|
||||
};
|
||||
const numberFormatter = new Intl.NumberFormat('en-US');
|
||||
|
||||
export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
|
||||
const { data, billAmount } = props;
|
||||
// Added this to fix the issue where breakdown with one day data are causing the bars to spread across multiple days
|
||||
data?.details?.breakdown?.forEach((breakdown: any) => {
|
||||
if (breakdown?.dayWiseBreakdown?.breakdown?.length === 1) {
|
||||
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
|
||||
const nextDay = {
|
||||
...currentDay,
|
||||
timestamp: currentDay.timestamp + 86400,
|
||||
count: 0,
|
||||
size: 0,
|
||||
quantity: 0,
|
||||
total: 0,
|
||||
};
|
||||
breakdown.dayWiseBreakdown.breakdown.push(nextDay);
|
||||
}
|
||||
});
|
||||
const graphCompatibleData = useMemo(
|
||||
() => convertDataToMetricRangePayload(data),
|
||||
[data],
|
||||
);
|
||||
const chartData = getUPlotChartData(graphCompatibleData);
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const { startTime, endTime } = useMemo(
|
||||
() => calculateStartEndTime(data),
|
||||
[data],
|
||||
);
|
||||
|
||||
const getGraphSeries = (color: string, label: string): any => ({
|
||||
drawStyle: 'bars',
|
||||
paths,
|
||||
lineInterpolation: 'spline',
|
||||
show: true,
|
||||
label,
|
||||
fill: color,
|
||||
stroke: color,
|
||||
width: 2,
|
||||
spanGaps: true,
|
||||
points: {
|
||||
size: 5,
|
||||
show: false,
|
||||
stroke: color,
|
||||
},
|
||||
});
|
||||
|
||||
const uPlotSeries: any = useMemo(
|
||||
() => [
|
||||
{ label: 'Timestamp', stroke: 'purple' },
|
||||
getGraphSeries(
|
||||
'#7CEDBE',
|
||||
graphCompatibleData.data.result[0]?.legend as string,
|
||||
),
|
||||
getGraphSeries(
|
||||
'#4E74F8',
|
||||
graphCompatibleData.data.result[1]?.legend as string,
|
||||
),
|
||||
getGraphSeries(
|
||||
'#F24769',
|
||||
graphCompatibleData.data.result[2]?.legend as string,
|
||||
),
|
||||
],
|
||||
[graphCompatibleData.data.result],
|
||||
);
|
||||
|
||||
const axesOptions = getAxes({ isDarkMode, yAxisUnit: '' });
|
||||
|
||||
const optionsForChart: uPlot.Options = useMemo(
|
||||
() => ({
|
||||
id: 'billing-usage-breakdown',
|
||||
series: uPlotSeries,
|
||||
width: containerDimensions.width,
|
||||
height: containerDimensions.height - 30,
|
||||
axes: [
|
||||
{
|
||||
...axesOptions[0],
|
||||
grid: {
|
||||
...axesOptions.grid,
|
||||
show: false,
|
||||
stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400,
|
||||
},
|
||||
},
|
||||
{
|
||||
...axesOptions[1],
|
||||
stroke: isDarkMode ? Color.BG_SLATE_200 : Color.BG_INK_400,
|
||||
},
|
||||
],
|
||||
scales: {
|
||||
x: {
|
||||
...getXAxisScale(startTime - 86400, endTime), // Minus 86400 from startTime to decrease a day to have a buffer start
|
||||
},
|
||||
y: {
|
||||
...getYAxisScale({
|
||||
series: graphCompatibleData?.data?.newResult?.data?.result,
|
||||
yAxisUnit: '',
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
live: false,
|
||||
isolate: true,
|
||||
},
|
||||
cursor: {
|
||||
lock: false,
|
||||
focus: {
|
||||
prox: 1e6,
|
||||
bias: 1,
|
||||
},
|
||||
},
|
||||
focus: {
|
||||
alpha: 0.3,
|
||||
},
|
||||
padding: [32, 32, 16, 16],
|
||||
plugins: [
|
||||
tooltipPlugin({
|
||||
apiResponse: fillMissingValuesForQuantities(
|
||||
graphCompatibleData,
|
||||
chartData[0],
|
||||
),
|
||||
yAxisUnit: '',
|
||||
isBillingUsageGraphs: true,
|
||||
isDarkMode,
|
||||
// Single-day data causes bars to span multiple days — add a synthetic
|
||||
// zero-value next-day entry so uPlot renders a correctly-sized single-day bar.
|
||||
const normalizedData = useMemo(() => {
|
||||
if (!data?.details?.breakdown) {
|
||||
return data;
|
||||
}
|
||||
return {
|
||||
...data,
|
||||
details: {
|
||||
...data.details,
|
||||
breakdown: data.details.breakdown.map((breakdown) => {
|
||||
if (breakdown?.dayWiseBreakdown?.breakdown?.length !== 1) {
|
||||
return breakdown;
|
||||
}
|
||||
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
|
||||
const nextDay = {
|
||||
...currentDay,
|
||||
timestamp: currentDay.timestamp + 86400,
|
||||
count: 0,
|
||||
size: 0,
|
||||
quantity: 0,
|
||||
total: 0,
|
||||
};
|
||||
return {
|
||||
...breakdown,
|
||||
dayWiseBreakdown: {
|
||||
...breakdown.dayWiseBreakdown,
|
||||
breakdown: [...breakdown.dayWiseBreakdown.breakdown, nextDay],
|
||||
},
|
||||
};
|
||||
}),
|
||||
],
|
||||
}),
|
||||
[
|
||||
axesOptions,
|
||||
chartData,
|
||||
containerDimensions.height,
|
||||
containerDimensions.width,
|
||||
endTime,
|
||||
graphCompatibleData,
|
||||
isDarkMode,
|
||||
startTime,
|
||||
uPlotSeries,
|
||||
],
|
||||
},
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const graphCompatibleData = useMemo(
|
||||
() => convertDataToMetricRangePayload(normalizedData),
|
||||
[normalizedData],
|
||||
);
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US');
|
||||
const chartData = useMemo(
|
||||
() => prepareChartData(graphCompatibleData) as uPlot.AlignedData,
|
||||
[graphCompatibleData],
|
||||
);
|
||||
|
||||
const filledApiResponse = useMemo(
|
||||
(): MetricRangePayloadProps =>
|
||||
fillMissingValuesForQuantities(
|
||||
graphCompatibleData,
|
||||
chartData[0] as number[],
|
||||
),
|
||||
[graphCompatibleData, chartData],
|
||||
);
|
||||
|
||||
const { startTime, endTime } = useMemo(
|
||||
() =>
|
||||
calculateStartEndTime(normalizedData as Partial<UsageResponsePayloadProps>),
|
||||
[normalizedData],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
prepareBillingBarConfig({
|
||||
isDarkMode,
|
||||
// Subtract 86400s (one day) from startTime to add a buffer before first bar
|
||||
minTimeScale: startTime !== undefined ? startTime - 86400 : undefined,
|
||||
maxTimeScale: endTime,
|
||||
apiResponse: graphCompatibleData,
|
||||
}),
|
||||
[isDarkMode, startTime, endTime, graphCompatibleData],
|
||||
);
|
||||
|
||||
const renderBillingTooltip = useCallback(
|
||||
(args: TooltipRenderArgs) => (
|
||||
<BillingBarChartTooltip billingApiResponse={filledApiResponse} {...args} />
|
||||
),
|
||||
[filledApiResponse],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card bordered={false} className="billing-graph-card">
|
||||
<Card bordered={false} className={styles.billingGraphCard}>
|
||||
<Flex justify="space-between">
|
||||
<Flex vertical gap={6}>
|
||||
<Typography.Text className="total-spent-title">
|
||||
<Typography.Text className={styles.totalSpentTitle}>
|
||||
TOTAL SPENT
|
||||
</Typography.Text>
|
||||
<Typography.Text className="total-spent">
|
||||
<Typography.Text className={styles.totalSpent}>
|
||||
${numberFormatter.format(billAmount)}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<div ref={graphRef} style={{ height: '100%', paddingBottom: 48 }}>
|
||||
<Uplot data={chartData} options={optionsForChart} />
|
||||
<div ref={graphRef} className={styles.graphContainer}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
config={config}
|
||||
data={chartData}
|
||||
isStackedBarChart
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
customTooltip={renderBillingTooltip}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height - 30}
|
||||
canPinTooltip
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { BillingBarChartTooltip } from '../BillingBarChartTooltip';
|
||||
|
||||
// Mock buildTooltipContent so tests don't depend on uPlot stacking math
|
||||
jest.mock('lib/uPlotV2/components/Tooltip/utils', () => ({
|
||||
buildTooltipContent: jest.fn().mockReturnValue([
|
||||
{
|
||||
label: 'Logs',
|
||||
value: 100,
|
||||
tooltipValue: '$100.00',
|
||||
color: '#7CEDBE',
|
||||
isActive: true,
|
||||
isHighlighted: false,
|
||||
},
|
||||
{
|
||||
label: 'Traces',
|
||||
value: 50,
|
||||
tooltipValue: '$50.00',
|
||||
color: '#4E74F8',
|
||||
isActive: false,
|
||||
isHighlighted: false,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
function makeUPlotInstance(seriesLabels: string[]): uPlot {
|
||||
return {
|
||||
data: [
|
||||
[1000, 2000],
|
||||
[100, 200],
|
||||
[50, 80],
|
||||
],
|
||||
cursor: { idx: 0 },
|
||||
series: [
|
||||
{ label: 'Timestamp', show: true, stroke: '#000' },
|
||||
...seriesLabels.map((label) => ({
|
||||
label,
|
||||
show: true,
|
||||
stroke: '#aabbcc',
|
||||
})),
|
||||
],
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
function makeBillingApiResponse(
|
||||
entries: { legend: string; quantity: (number | null)[]; unit: string }[],
|
||||
): MetricRangePayloadProps {
|
||||
return {
|
||||
data: {
|
||||
result: entries.map((e) => ({
|
||||
legend: e.legend,
|
||||
queryName: e.legend,
|
||||
metric: {},
|
||||
values: [[1000, '10']] as [number, string][],
|
||||
quantity: e.quantity as number[],
|
||||
unit: e.unit,
|
||||
})),
|
||||
resultType: '',
|
||||
newResult: { data: { result: [], resultType: '' } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseTooltipArgs = {
|
||||
isPinned: false,
|
||||
dismiss: jest.fn(),
|
||||
viaSync: false,
|
||||
seriesIndex: 1,
|
||||
dataIndexes: [null, 0, 0],
|
||||
};
|
||||
|
||||
describe('BillingBarChartTooltip', () => {
|
||||
it('augments tooltipValue with quantity and unit for each series', () => {
|
||||
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
|
||||
const billingApiResponse = makeBillingApiResponse([
|
||||
{ legend: 'Logs', quantity: [1.5, 2.0], unit: 'GB' },
|
||||
{ legend: 'Traces', quantity: [500, 800], unit: 'spans' },
|
||||
]);
|
||||
|
||||
render(
|
||||
<BillingBarChartTooltip
|
||||
{...baseTooltipArgs}
|
||||
uPlotInstance={uPlotInstance}
|
||||
billingApiResponse={billingApiResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText(/1\.5 GB/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/500 spans/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('omits quantity line when quantity at dataIndex is null', () => {
|
||||
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
|
||||
const billingApiResponse = makeBillingApiResponse([
|
||||
{ legend: 'Logs', quantity: [null, null], unit: 'GB' },
|
||||
{ legend: 'Traces', quantity: [null, null], unit: 'spans' },
|
||||
]);
|
||||
|
||||
render(
|
||||
<BillingBarChartTooltip
|
||||
{...baseTooltipArgs}
|
||||
uPlotInstance={uPlotInstance}
|
||||
billingApiResponse={billingApiResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/null GB/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/null spans/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('uplot-tooltip-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats dollar value via getToolTipValue — strips trailing zeros (0.3076 → $0.3)', () => {
|
||||
const uPlotInstance = makeUPlotInstance(['Logs']);
|
||||
const { buildTooltipContent } = jest.requireMock(
|
||||
'lib/uPlotV2/components/Tooltip/utils',
|
||||
) as { buildTooltipContent: jest.Mock };
|
||||
buildTooltipContent.mockReturnValueOnce([
|
||||
{
|
||||
label: 'Logs',
|
||||
value: 0.3076171875,
|
||||
tooltipValue: '$0.31',
|
||||
color: '#7CEDBE',
|
||||
isActive: true,
|
||||
isHighlighted: false,
|
||||
},
|
||||
]);
|
||||
const billingApiResponse = makeBillingApiResponse([
|
||||
{ legend: 'Logs', quantity: [1.23], unit: 'GB' },
|
||||
]);
|
||||
|
||||
render(
|
||||
<BillingBarChartTooltip
|
||||
{...baseTooltipArgs}
|
||||
uPlotInstance={uPlotInstance}
|
||||
billingApiResponse={billingApiResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText(/\$0\.3 -/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('passes through base tooltipValue when series is not in billingApiResponse', () => {
|
||||
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
|
||||
const billingApiResponse = makeBillingApiResponse([]);
|
||||
|
||||
render(
|
||||
<BillingBarChartTooltip
|
||||
{...baseTooltipArgs}
|
||||
uPlotInstance={uPlotInstance}
|
||||
billingApiResponse={billingApiResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText('$100.00').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('$50.00').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import { prepareBillingBarConfig } from '../prepareBillingBarConfig';
|
||||
|
||||
const makeApiResponse = (legends: string[]): MetricRangePayloadProps => ({
|
||||
data: {
|
||||
result: legends.map((legend) => ({
|
||||
legend,
|
||||
queryName: legend,
|
||||
metric: {},
|
||||
values: [[1000, '10']],
|
||||
})),
|
||||
resultType: '',
|
||||
newResult: { data: { result: [], resultType: '' } },
|
||||
},
|
||||
});
|
||||
|
||||
describe('prepareBillingBarConfig', () => {
|
||||
const baseProps = { isDarkMode: false };
|
||||
|
||||
it('returns a builder with no series when apiResponse is undefined', () => {
|
||||
const builder = prepareBillingBarConfig(baseProps);
|
||||
const config = builder.getConfig();
|
||||
expect(config.series).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns a builder with no series when result is empty', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse([]),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.series).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('adds one series per result entry with correct labels and colors', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.series).toHaveLength(4);
|
||||
expect(config.series?.[1]?.label).toBe('Logs');
|
||||
expect(config.series?.[1]?.stroke).toBe(Color.BG_FOREST_300);
|
||||
expect(config.series?.[2]?.label).toBe('Traces');
|
||||
expect(config.series?.[2]?.stroke).toBe(Color.BG_ROBIN_500);
|
||||
expect(config.series?.[3]?.label).toBe('Metrics');
|
||||
expect(config.series?.[3]?.stroke).toBe(Color.BG_SAKURA_500);
|
||||
});
|
||||
|
||||
it('assigns fallback color (Amber500) for signals beyond the 3-color palette', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse(['A', 'B', 'C', 'D']),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.series?.[4]?.stroke).toBe(Color.BG_AMBER_500);
|
||||
});
|
||||
|
||||
it('sets stacking bands, padding, and focus alpha for behavioral parity', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
|
||||
expect(config.padding).toStrictEqual([32, 32, 16, 16]);
|
||||
expect(config.focus).toStrictEqual({ alpha: 0.3 });
|
||||
});
|
||||
|
||||
it('sets no bands when result is empty', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse([]),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.bands).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses queryName as label when legend is undefined', () => {
|
||||
const apiResponse: MetricRangePayloadProps = {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
legend: undefined as any,
|
||||
queryName: 'Logs',
|
||||
metric: {},
|
||||
values: [[1000, '10']],
|
||||
},
|
||||
],
|
||||
resultType: '',
|
||||
newResult: { data: { result: [], resultType: '' } },
|
||||
},
|
||||
};
|
||||
const builder = prepareBillingBarConfig({ isDarkMode: false, apiResponse });
|
||||
const config = builder.getConfig();
|
||||
expect(config.series?.[1]?.label).toBe('Logs');
|
||||
expect(config.series?.[1]?.stroke).toBe(Color.BG_FOREST_300);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
calculateStartEndTime,
|
||||
convertDataToMetricRangePayload,
|
||||
} from '../utils';
|
||||
|
||||
const makeData = (
|
||||
timestamps: number[],
|
||||
billingPeriodStart?: number,
|
||||
billingPeriodEnd?: number,
|
||||
) => ({
|
||||
billingPeriodStart,
|
||||
billingPeriodEnd,
|
||||
details: {
|
||||
total: 0,
|
||||
baseFee: 0,
|
||||
billTotal: 0,
|
||||
breakdown: [
|
||||
{
|
||||
type: 'Logs',
|
||||
unit: 'GB',
|
||||
dayWiseBreakdown: {
|
||||
breakdown: timestamps.map((timestamp) => ({
|
||||
timestamp,
|
||||
total: 0,
|
||||
quantity: 0,
|
||||
count: 0,
|
||||
size: 0,
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe('convertDataToMetricRangePayload', () => {
|
||||
it('returns empty result when all dayWiseBreakdown.breakdown are null', () => {
|
||||
const data = {
|
||||
billingPeriodStart: 1778763678,
|
||||
billingPeriodEnd: 1781442078,
|
||||
details: {
|
||||
total: 0,
|
||||
baseFee: 49,
|
||||
billTotal: 49,
|
||||
breakdown: [
|
||||
{
|
||||
type: 'Metrics',
|
||||
unit: 'Million',
|
||||
tiers: [],
|
||||
dayWiseBreakdown: { type: '', breakdown: null },
|
||||
},
|
||||
{
|
||||
type: 'Traces',
|
||||
unit: 'GB',
|
||||
tiers: [],
|
||||
dayWiseBreakdown: { type: '', breakdown: null },
|
||||
},
|
||||
{
|
||||
type: 'Logs',
|
||||
unit: 'GB',
|
||||
tiers: [],
|
||||
dayWiseBreakdown: { type: '', breakdown: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = convertDataToMetricRangePayload(data);
|
||||
expect(result.data.result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('includes only series that have day-wise data', () => {
|
||||
const data = {
|
||||
details: {
|
||||
breakdown: [
|
||||
{
|
||||
type: 'Metrics',
|
||||
unit: 'Million',
|
||||
dayWiseBreakdown: { breakdown: null },
|
||||
},
|
||||
{
|
||||
type: 'Logs',
|
||||
unit: 'GB',
|
||||
dayWiseBreakdown: {
|
||||
breakdown: [
|
||||
{ timestamp: 1000, total: 5, quantity: 10, count: 0, size: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = convertDataToMetricRangePayload(data);
|
||||
expect(result.data.result).toHaveLength(1);
|
||||
expect(result.data.result[0].legend).toBe('Logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateStartEndTime', () => {
|
||||
it('returns min/max of all breakdown timestamps', () => {
|
||||
const data = makeData([1000, 3000, 2000]);
|
||||
expect(calculateStartEndTime(data)).toStrictEqual({
|
||||
startTime: 1000,
|
||||
endTime: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes billingPeriodStart and billingPeriodEnd in the range', () => {
|
||||
const data = makeData([2000, 3000], 500, 4000);
|
||||
expect(calculateStartEndTime(data)).toStrictEqual({
|
||||
startTime: 500,
|
||||
endTime: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when there are no timestamps and no billing period', () => {
|
||||
expect(calculateStartEndTime({})).toStrictEqual({
|
||||
startTime: undefined,
|
||||
endTime: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when breakdown is empty', () => {
|
||||
const data = makeData([]);
|
||||
expect(calculateStartEndTime(data)).toStrictEqual({
|
||||
startTime: undefined,
|
||||
endTime: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters out non-finite billingPeriod values', () => {
|
||||
const data = makeData([1000], NaN, Infinity);
|
||||
expect(calculateStartEndTime(data)).toStrictEqual({
|
||||
startTime: 1000,
|
||||
endTime: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('works when details is missing', () => {
|
||||
expect(
|
||||
calculateStartEndTime({ billingPeriodStart: 100, billingPeriodEnd: 200 }),
|
||||
).toStrictEqual({
|
||||
startTime: 100,
|
||||
endTime: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
const BILLING_SERIES_COLORS = [
|
||||
Color.BG_FOREST_300,
|
||||
Color.BG_ROBIN_500,
|
||||
Color.BG_SAKURA_500,
|
||||
];
|
||||
|
||||
export interface PrepareBillingBarConfigProps {
|
||||
isDarkMode: boolean;
|
||||
timezone?: Timezone;
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
apiResponse?: MetricRangePayloadProps;
|
||||
}
|
||||
|
||||
export function prepareBillingBarConfig({
|
||||
isDarkMode,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
apiResponse,
|
||||
}: PrepareBillingBarConfigProps): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
id: 'billing-usage-breakdown',
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
});
|
||||
|
||||
const results = apiResponse?.data?.result;
|
||||
if (!results?.length) {
|
||||
return builder;
|
||||
}
|
||||
|
||||
const labels = results.map((s) => s.legend || s.queryName || '');
|
||||
|
||||
const colorMapping = labels.reduce<Record<string, string>>(
|
||||
(acc, label, index) => {
|
||||
acc[label] = BILLING_SERIES_COLORS[index] ?? Color.BG_AMBER_500;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
labels.forEach((label) => {
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
label,
|
||||
colorMapping,
|
||||
isDarkMode,
|
||||
metric: {},
|
||||
});
|
||||
});
|
||||
|
||||
builder.setBands(getInitialStackedBands(results.length));
|
||||
builder.setPadding([32, 32, 16, 16]);
|
||||
builder.setFocus({ alpha: 0.3 });
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UsageResponsePayloadProps } from 'api/billing/getUsage';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { isEmpty, isNull } from 'lodash-es';
|
||||
import { unparse } from 'papaparse';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
@@ -29,23 +29,25 @@ export const convertDataToMetricRangePayload = (
|
||||
return emptyStateData;
|
||||
}
|
||||
|
||||
const payload = breakdown.map((info: any) => {
|
||||
const metric = info.type;
|
||||
const sortedBreakdownData = (info?.dayWiseBreakdown?.breakdown || []).sort(
|
||||
(a: any, b: any) => a.timestamp - b.timestamp,
|
||||
);
|
||||
const values = (sortedBreakdownData || []).map((categoryInfo: any) => [
|
||||
categoryInfo.timestamp,
|
||||
categoryInfo.total,
|
||||
]);
|
||||
const queryName = info.type;
|
||||
const legend = info.type;
|
||||
const { unit } = info;
|
||||
const quantity = sortedBreakdownData.map(
|
||||
(categoryInfo: any) => categoryInfo.quantity,
|
||||
);
|
||||
return { metric, values, queryName, legend, quantity, unit };
|
||||
});
|
||||
const payload = breakdown
|
||||
.map((info: any) => {
|
||||
const metric = info.type;
|
||||
const sortedBreakdownData = (info?.dayWiseBreakdown?.breakdown || []).sort(
|
||||
(a: any, b: any) => a.timestamp - b.timestamp,
|
||||
);
|
||||
const values = (sortedBreakdownData || []).map((categoryInfo: any) => [
|
||||
categoryInfo.timestamp,
|
||||
categoryInfo.total,
|
||||
]);
|
||||
const queryName = info.type;
|
||||
const legend = info.type;
|
||||
const { unit } = info;
|
||||
const quantity = sortedBreakdownData.map(
|
||||
(categoryInfo: any) => categoryInfo.quantity,
|
||||
);
|
||||
return { metric, values, queryName, legend, quantity, unit };
|
||||
})
|
||||
.filter((series: any) => series.values.length > 0);
|
||||
|
||||
const sortedData = payload.sort((a: any, b: any) => {
|
||||
const sumA = a.values.reduce((acc: any, val: any) => acc + val[1], 0);
|
||||
@@ -120,11 +122,40 @@ export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
|
||||
fileName: string;
|
||||
} {
|
||||
const graphCompatibleData = convertDataToMetricRangePayload(data);
|
||||
const chartData = getUPlotChartData(graphCompatibleData);
|
||||
const quantityMapArr = quantityDataArr(graphCompatibleData, chartData[0]);
|
||||
const chartData = prepareChartData(graphCompatibleData);
|
||||
const quantityMapArr = quantityDataArr(
|
||||
graphCompatibleData,
|
||||
chartData[0] as number[],
|
||||
);
|
||||
|
||||
return {
|
||||
csvData: unparse(generateCsvData(quantityMapArr)),
|
||||
fileName: csvFileName(quantityMapArr),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateStartEndTime(
|
||||
data: Partial<UsageResponsePayloadProps>,
|
||||
): { startTime: number | undefined; endTime: number | undefined } {
|
||||
const timestamps: number[] = [];
|
||||
data?.details?.breakdown?.forEach((breakdown) => {
|
||||
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry) => {
|
||||
timestamps.push(entry.timestamp);
|
||||
});
|
||||
});
|
||||
|
||||
const billingTime: number[] = [
|
||||
data?.billingPeriodStart,
|
||||
data?.billingPeriodEnd,
|
||||
].filter((t): t is number => typeof t === 'number' && Number.isFinite(t));
|
||||
|
||||
const allTimes = [...timestamps, ...billingTime];
|
||||
if (allTimes.length === 0) {
|
||||
return { startTime: undefined, endTime: undefined };
|
||||
}
|
||||
|
||||
return {
|
||||
startTime: Math.min(...allTimes),
|
||||
endTime: Math.max(...allTimes),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,7 +196,11 @@ function Footer(): JSX.Element {
|
||||
</Button>
|
||||
);
|
||||
if (alertValidationMessage) {
|
||||
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
||||
button = (
|
||||
<Tooltip title={alertValidationMessage}>
|
||||
<span>{button}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return button;
|
||||
}, [
|
||||
@@ -224,7 +228,11 @@ function Footer(): JSX.Element {
|
||||
</Button>
|
||||
);
|
||||
if (alertValidationMessage) {
|
||||
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
||||
button = (
|
||||
<Tooltip title={alertValidationMessage}>
|
||||
<span>{button}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return button;
|
||||
}, [
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
font-size: 10px;
|
||||
color: var(--l2-foreground);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,18 +47,10 @@
|
||||
}
|
||||
|
||||
.ant-tabs-tab-active {
|
||||
.overview-btn {
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.variables-btn {
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.overview-btn,
|
||||
.variables-btn,
|
||||
.public-dashboard-btn {
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
color: var(--primary-background);
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,15 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
|
||||
.sidenav-beta-tag {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.variable-type-btn + .variable-type-btn {
|
||||
@@ -177,6 +186,7 @@
|
||||
|
||||
.multiple-values-section {
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0;
|
||||
|
||||
.typography-variables {
|
||||
@@ -193,6 +203,7 @@
|
||||
|
||||
.all-option-section {
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0;
|
||||
|
||||
.typography-variables {
|
||||
|
||||
@@ -518,7 +518,6 @@ function VariableItem({
|
||||
size={14}
|
||||
style={{
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
marginTop: 1,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -614,7 +613,6 @@ function VariableItem({
|
||||
size={14}
|
||||
style={{
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
marginTop: 1,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixture helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMPTY_BUILDER = {
|
||||
queryData: [] as any,
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
};
|
||||
|
||||
const BASE_WIDGET = {
|
||||
opacity: '1',
|
||||
nullZeroValues: 'null',
|
||||
timePreferance: 'GLOBAL_TIME' as const,
|
||||
softMin: null,
|
||||
softMax: null,
|
||||
selectedLogFields: null,
|
||||
selectedTracesFields: null,
|
||||
};
|
||||
|
||||
const DEFAULT_QUERY_DATA = {
|
||||
queryName: 'q1',
|
||||
// In QB v5, expression holds the query label (A/B/C), not a filter expression
|
||||
expression: 'A',
|
||||
dataSource: DataSource.METRICS,
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
filters: { items: [] as any[], op: 'AND' as const },
|
||||
legend: '',
|
||||
disabled: false,
|
||||
having: [],
|
||||
limit: null,
|
||||
stepInterval: null,
|
||||
orderBy: [],
|
||||
selectColumns: [],
|
||||
source: '' as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a dashboard with a single builder widget.
|
||||
* Only supply the fields your test actually cares about.
|
||||
*/
|
||||
const buildBuilderDashboard = (
|
||||
filterExpression: string,
|
||||
queryDataOverrides: Record<string, any> = {},
|
||||
): Dashboard => ({
|
||||
id: 'dash1',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: {
|
||||
title: 'Test Dashboard',
|
||||
widgets: [
|
||||
{
|
||||
...BASE_WIDGET,
|
||||
id: 'widget-1',
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: 'Widget 1',
|
||||
description: '',
|
||||
query: {
|
||||
id: 'query1',
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
...DEFAULT_QUERY_DATA,
|
||||
...queryDataOverrides,
|
||||
filter: { expression: filterExpression },
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
unit: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
variables: {},
|
||||
},
|
||||
});
|
||||
|
||||
const buildClickhouseDashboard = (query: string): Dashboard => ({
|
||||
id: 'dash-ch',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: {
|
||||
title: 'CH',
|
||||
widgets: [
|
||||
{
|
||||
...BASE_WIDGET,
|
||||
id: 'w1',
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: '',
|
||||
description: '',
|
||||
query: {
|
||||
id: 'q1',
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
promql: [],
|
||||
clickhouse_sql: [{ name: 'A', query, legend: '', disabled: false }],
|
||||
builder: EMPTY_BUILDER,
|
||||
unit: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
variables: {},
|
||||
},
|
||||
});
|
||||
|
||||
const buildPromqlDashboard = (query: string): Dashboard => ({
|
||||
id: 'dash-prom',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: {
|
||||
title: 'PromQL Dashboard',
|
||||
widgets: [
|
||||
{
|
||||
...BASE_WIDGET,
|
||||
id: 'widget-prom',
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: 'PromQL Widget',
|
||||
description: '',
|
||||
query: {
|
||||
id: 'query-prom',
|
||||
queryType: EQueryType.PROM,
|
||||
promql: [{ name: 'A', query, legend: '', disabled: false }],
|
||||
clickhouse_sql: [],
|
||||
builder: EMPTY_BUILDER,
|
||||
unit: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
variables: {},
|
||||
},
|
||||
});
|
||||
|
||||
/** Run removeVariableReferencesFromDashboard on a single-widget clickhouse dashboard and return the cleaned SQL. */
|
||||
const chQuery = (sql: string, varName: string): string => {
|
||||
const result = removeVariableReferencesFromDashboard(
|
||||
buildClickhouseDashboard(sql),
|
||||
varName,
|
||||
);
|
||||
return (result!.data.widgets![0] as any).query.clickhouse_sql[0].query;
|
||||
};
|
||||
|
||||
/** Extract the first builder queryData from a cleaned dashboard. */
|
||||
const firstBuilderQueryData = (dashboard: Dashboard | undefined): any =>
|
||||
(dashboard!.data.widgets![0] as any).query.builder.queryData[0];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('removeVariableReferencesFromDashboard', () => {
|
||||
describe('builder filter expression cleanup', () => {
|
||||
it('removes a variable clause from filter.expression', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name IN $service AND env = 'prod'",
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe("env = 'prod'");
|
||||
});
|
||||
|
||||
it('leaves no dangling AND/OR after removing a variable clause', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name IN $service AND env = 'prod'",
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
const { expression } = firstBuilderQueryData(result).filter;
|
||||
|
||||
expect(expression).toBe("env = 'prod'");
|
||||
expect(expression).not.toMatch(/^\s*(AND|OR)/i);
|
||||
expect(expression).not.toMatch(/(AND|OR)\s*$/i);
|
||||
});
|
||||
|
||||
it('does not remove $environment clause when deleting $env', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
'env = $env AND deployment.environment = $environment',
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'env');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe(
|
||||
'deployment.environment = $environment',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves literal filter expressions untouched when removing a variable', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name = 'api-gateway' AND env = 'prod'",
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe(
|
||||
"service.name = 'api-gateway' AND env = 'prod'",
|
||||
);
|
||||
});
|
||||
|
||||
it('removes only the variable clause, preserving a literal clause on the same key', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name IN $service AND service.name = 'api-gateway'",
|
||||
);
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe(
|
||||
"service.name = 'api-gateway'",
|
||||
);
|
||||
});
|
||||
|
||||
it('returns filter.expression unchanged when the variable has no clauses in it', () => {
|
||||
const dashboard = buildBuilderDashboard("env = 'prod'");
|
||||
|
||||
const result = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
|
||||
expect(firstBuilderQueryData(result).filter.expression).toBe("env = 'prod'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('PromQL query cleanup', () => {
|
||||
it('removes variable placeholder from a promql query', () => {
|
||||
const result = removeVariableReferencesFromDashboard(
|
||||
buildPromqlDashboard('sum(rate(http_requests_total{$service}[5m]))'),
|
||||
'service',
|
||||
);
|
||||
|
||||
const widget = result!.data.widgets![0] as any;
|
||||
expect(widget.query.promql[0].query).toBe(
|
||||
'sum(rate(http_requests_total{}[5m]))',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips only the variable token inside a PromQL label matcher (token-only path)', () => {
|
||||
const result = removeVariableReferencesFromDashboard(
|
||||
buildPromqlDashboard('up{env="$env", job="api"}'),
|
||||
'env',
|
||||
);
|
||||
|
||||
const widget = result!.data.widgets![0] as any;
|
||||
expect(widget.query.promql[0].query).toBe('up{env="", job="api"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClickHouse SQL query cleanup', () => {
|
||||
it('removes a quoted variable clause and its WHERE keyword', () => {
|
||||
expect(
|
||||
chQuery(
|
||||
"SELECT count() FROM signoz_logs WHERE service_name = '$service'",
|
||||
'service',
|
||||
),
|
||||
).toBe('SELECT count() FROM signoz_logs');
|
||||
});
|
||||
|
||||
it('removes a middle clause: AND env={{.env}} AND', () => {
|
||||
expect(
|
||||
chQuery('SELECT count() FROM t WHERE a=1 AND env={{.env}} AND b=2', 'env'),
|
||||
).toBe('SELECT count() FROM t WHERE a=1 AND b=2');
|
||||
});
|
||||
|
||||
it('removes the first clause: env={{.env}} AND rest', () => {
|
||||
expect(
|
||||
chQuery('SELECT count() FROM t WHERE env={{.env}} AND b=2', 'env'),
|
||||
).toBe('SELECT count() FROM t WHERE b=2');
|
||||
});
|
||||
|
||||
it('removes the last clause: rest AND env=$env', () => {
|
||||
expect(chQuery('SELECT count() FROM t WHERE a=1 AND env=$env', 'env')).toBe(
|
||||
'SELECT count() FROM t WHERE a=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes a clause with double-bracket syntax: service=[[svc]]', () => {
|
||||
expect(chQuery('SELECT count() FROM t WHERE service=[[svc]]', 'svc')).toBe(
|
||||
'SELECT count() FROM t',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to token-only strip for a bare variable in SELECT', () => {
|
||||
expect(chQuery('SELECT $metric FROM table', 'metric')).toBe(
|
||||
'SELECT FROM table',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('is idempotent — calling twice produces the same result', () => {
|
||||
const dashboard = buildBuilderDashboard(
|
||||
"service.name IN $service AND env = 'prod'",
|
||||
);
|
||||
|
||||
const once = removeVariableReferencesFromDashboard(dashboard, 'service');
|
||||
const twice = removeVariableReferencesFromDashboard(once, 'service');
|
||||
|
||||
expect(twice).toStrictEqual(once);
|
||||
});
|
||||
|
||||
it('handles a dashboard with no widgets without throwing', () => {
|
||||
const dashboard: Dashboard = {
|
||||
id: 'dash-empty',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
createdBy: '',
|
||||
updatedBy: '',
|
||||
data: { title: 'Empty Dashboard', widgets: undefined, variables: {} },
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
removeVariableReferencesFromDashboard(dashboard, 'service'),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
createVariablePlaceholderRegExp,
|
||||
removeKeysFromExpression,
|
||||
removeVariableFromExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { cloneDeep, isArray, isEmpty } from 'lodash-es';
|
||||
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
|
||||
@@ -157,6 +159,139 @@ const updateAfterRemoval = (
|
||||
};
|
||||
};
|
||||
|
||||
const removeVariablePlaceholders = (
|
||||
text: string | undefined,
|
||||
variableName: string,
|
||||
): string => {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const tokenPattern = createVariablePlaceholderRegExp(variableName);
|
||||
|
||||
// Step 1: attempt clause-aware removal for SQL WHERE patterns.
|
||||
// Strips the entire `key op $var` unit plus its adjacent AND/OR so we
|
||||
// never leave a dangling `key = ` in unquoted ClickHouse SQL clauses.
|
||||
// Handles three shapes:
|
||||
// (a) preceding conjunction: AND key = $var
|
||||
// (b) following conjunction: key = $var AND
|
||||
// (c) standalone clause: key = $var (end of expression)
|
||||
const escapedToken = tokenPattern.source;
|
||||
const clausePattern = new RegExp(
|
||||
// (a) conjunction before the clause
|
||||
`\\s*\\b(?:AND|OR)\\b\\s+[\\w."'\\[\\]]+\\s*(?:=|!=|<>|LIKE|ILIKE|IN|NOT\\s+IN)\\s*'?${escapedToken}'?` +
|
||||
// (b)+(c) clause first, optional conjunction after
|
||||
`|[\\w."'\\[\\]]+\\s*(?:=|!=|<>|LIKE|ILIKE|IN|NOT\\s+IN)\\s*'?${escapedToken}'?(?:\\s*\\b(?:AND|OR)\\b)?`,
|
||||
'gi',
|
||||
);
|
||||
|
||||
const withClauseRemoval = text.replace(clausePattern, '');
|
||||
if (withClauseRemoval !== text) {
|
||||
return withClauseRemoval
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.replace(/\bWHERE\s*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Step 2: fallback — bare variable usage outside a key-op-value pattern
|
||||
// (e.g. SELECT $metric, LIMIT $n). Token-only removal is correct here.
|
||||
return text
|
||||
.replace(tokenPattern, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const removeVariableReferencesFromQueryData = (
|
||||
queryData: IBuilderQuery,
|
||||
variableName: string,
|
||||
): IBuilderQuery => {
|
||||
const updatedFilter = queryData.filter?.expression
|
||||
? {
|
||||
...queryData.filter,
|
||||
expression: removeVariableFromExpression(
|
||||
queryData.filter.expression,
|
||||
variableName,
|
||||
),
|
||||
}
|
||||
: queryData.filter;
|
||||
|
||||
return { ...queryData, filter: updatedFilter };
|
||||
};
|
||||
|
||||
const removeVariableReferencesFromWidget = (
|
||||
widget: Widgets,
|
||||
variableName: string,
|
||||
): Widgets => {
|
||||
let updatedWidget = { ...widget };
|
||||
|
||||
if (updatedWidget.query?.builder?.queryData) {
|
||||
updatedWidget = {
|
||||
...updatedWidget,
|
||||
query: {
|
||||
...updatedWidget.query,
|
||||
builder: {
|
||||
...updatedWidget.query.builder,
|
||||
queryData: updatedWidget.query.builder.queryData.map((queryData) =>
|
||||
removeVariableReferencesFromQueryData(queryData, variableName),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (updatedWidget.query?.promql) {
|
||||
updatedWidget = {
|
||||
...updatedWidget,
|
||||
query: {
|
||||
...updatedWidget.query,
|
||||
promql: updatedWidget.query.promql.map((promqlQuery) => ({
|
||||
...promqlQuery,
|
||||
query: removeVariablePlaceholders(promqlQuery.query, variableName),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (updatedWidget.query?.clickhouse_sql) {
|
||||
updatedWidget = {
|
||||
...updatedWidget,
|
||||
query: {
|
||||
...updatedWidget.query,
|
||||
clickhouse_sql: updatedWidget.query.clickhouse_sql.map((sqlQuery) => ({
|
||||
...sqlQuery,
|
||||
query: removeVariablePlaceholders(sqlQuery.query, variableName),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return updatedWidget;
|
||||
};
|
||||
|
||||
export const removeVariableReferencesFromDashboard = (
|
||||
dashboard: Dashboard | undefined,
|
||||
variableName: string,
|
||||
): Dashboard | undefined => {
|
||||
if (!dashboard || !variableName) {
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
const updatedDashboard = cloneDeep(dashboard);
|
||||
|
||||
if (updatedDashboard.data.widgets) {
|
||||
updatedDashboard.data.widgets = updatedDashboard.data.widgets.map(
|
||||
(widget) => {
|
||||
if ('query' in widget) {
|
||||
return removeVariableReferencesFromWidget(widget as Widgets, variableName);
|
||||
}
|
||||
return widget;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return updatedDashboard;
|
||||
};
|
||||
|
||||
/**
|
||||
* A function that takes a dashboard configuration and a list of tag filters
|
||||
* and returns an updated dashboard with the filters appended to widget queries.
|
||||
|
||||
@@ -18,10 +18,11 @@ import { convertVariablesToDbFormat } from 'container/DashboardContainer/Dashboa
|
||||
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
import { removeVariableReferencesFromDashboard } from './addTagFiltersToDashboard';
|
||||
|
||||
import { TVariableMode } from './types';
|
||||
import VariableItem from './VariableItem/VariableItem';
|
||||
@@ -92,8 +93,6 @@ function VariablesSettings({
|
||||
const { dashboardData, setDashboardData } = useDashboardStore();
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const [variablesTableData, setVariablesTableData] = useState<any>([]);
|
||||
const [variblesOrderArr, setVariablesOrderArr] = useState<number[]>([]);
|
||||
const [existingVariableNamesMap, setExistingVariableNamesMap] = useState<
|
||||
@@ -201,9 +200,7 @@ function VariablesSettings({
|
||||
onSuccess: (updatedDashboard) => {
|
||||
if (updatedDashboard.data) {
|
||||
setDashboardData(updatedDashboard.data);
|
||||
notifications.success({
|
||||
message: t('variable_updated_successfully'),
|
||||
});
|
||||
toast.success(t('variable_updated_successfully'));
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -256,6 +253,11 @@ function VariablesSettings({
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = (): void => {
|
||||
if (!dashboardData || !variableToDelete.current) {
|
||||
setDeleteVariableModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newVariablesArr = variablesTableData.filter(
|
||||
(variable: IDashboardVariable) =>
|
||||
variable.id !== variableToDelete?.current?.id,
|
||||
@@ -263,7 +265,31 @@ function VariablesSettings({
|
||||
|
||||
const updatedVariables = convertVariablesToDbFormat(newVariablesArr);
|
||||
|
||||
updateVariables(updatedVariables);
|
||||
const cleanedDashboard =
|
||||
removeVariableReferencesFromDashboard(
|
||||
dashboardData,
|
||||
variableToDelete.current.name || '',
|
||||
) || dashboardData;
|
||||
|
||||
updateMutation.mutateAsync(
|
||||
{
|
||||
id: dashboardData.id,
|
||||
|
||||
data: {
|
||||
...cleanedDashboard.data,
|
||||
variables: updatedVariables,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (updatedDashboard) => {
|
||||
if (updatedDashboard.data) {
|
||||
setDashboardData(updatedDashboard.data);
|
||||
toast.success(t('variable_updated_successfully'));
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
variableToDelete.current = null;
|
||||
setDeleteVariableModal(false);
|
||||
};
|
||||
@@ -476,6 +502,7 @@ function VariablesSettings({
|
||||
open={deleteVariableModal}
|
||||
onOk={handleDeleteConfirm}
|
||||
onCancel={handleDeleteCancel}
|
||||
okButtonProps={{ loading: updateMutation.isLoading }}
|
||||
>
|
||||
<Typography.Text>
|
||||
Are you sure you want to delete variable{' '}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import { usePanelContextMenu } from '../usePanelContextMenu';
|
||||
|
||||
@@ -47,10 +46,7 @@ const mockWidget = { id: 'w-1', query: {} } as unknown as Widgets;
|
||||
const mockQueryResponse = {
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
} as unknown as UseQueryResult<
|
||||
SuccessResponse<MetricRangePayloadProps, unknown>,
|
||||
Error
|
||||
>;
|
||||
} as unknown as UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
|
||||
|
||||
describe('usePanelContextMenu', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,17 +10,13 @@ import {
|
||||
PopoverPosition,
|
||||
useCoordinates,
|
||||
} from 'periscope/components/ContextMenu';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
interface UseTimeSeriesContextMenuParams {
|
||||
widget: Widgets;
|
||||
queryResponse: UseQueryResult<
|
||||
SuccessResponse<MetricRangePayloadProps, unknown>,
|
||||
Error
|
||||
>;
|
||||
queryResponse: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
import { logEventMock } from '__tests__/logEventMock';
|
||||
import { Events } from 'constants/events';
|
||||
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import TooltipFooter from '../TooltipFooter';
|
||||
|
||||
const mockLogEvent = jest.fn();
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]): unknown => mockLogEvent(...args),
|
||||
}));
|
||||
|
||||
describe('TooltipFooter', () => {
|
||||
const defaultProps = {
|
||||
id: 'panel-123',
|
||||
@@ -84,7 +78,7 @@ describe('TooltipFooter', () => {
|
||||
|
||||
await user.click(screen.getByTestId('uplot-tooltip-unpin'));
|
||||
|
||||
expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
|
||||
expect(logEventMock).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
|
||||
id: 'panel-123',
|
||||
});
|
||||
expect(dismiss).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
}
|
||||
.ant-btn-default {
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
.ant-tabs-tab-active {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user