mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-13 12:52:55 +00:00
Compare commits
21 Commits
feat/azure
...
emails
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b251aa60bf | ||
|
|
b166b20069 | ||
|
|
3c30114642 | ||
|
|
d042fad1e3 | ||
|
|
235c606b44 | ||
|
|
a49d7e1662 | ||
|
|
b3e41b5520 | ||
|
|
83bb97cc58 | ||
|
|
68ea28cf6b | ||
|
|
3726c0aac1 | ||
|
|
dae2d3239b | ||
|
|
0c660f8618 | ||
|
|
97cffbc20a | ||
|
|
8317eb1735 | ||
|
|
b1789ea3f7 | ||
|
|
3b41d0a731 | ||
|
|
a171f7122f | ||
|
|
4a20e93b20 | ||
|
|
d4dc709aa5 | ||
|
|
cd014652a1 | ||
|
|
538351131f |
@@ -42,7 +42,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
schema-migrator-sync:
|
||||
image: signoz/signoz-schema-migrator:v0.129.13
|
||||
image: signoz/signoz-schema-migrator:v0.142.0
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
condition: service_healthy
|
||||
restart: on-failure
|
||||
schema-migrator-async:
|
||||
image: signoz/signoz-schema-migrator:v0.129.13
|
||||
image: signoz/signoz-schema-migrator:v0.142.0
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -4,7 +4,6 @@ services:
|
||||
container_name: signoz-otel-collector-dev
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --feature-gates=-pkg.translator.prometheus.NormalizeName
|
||||
volumes:
|
||||
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
|
||||
environment:
|
||||
|
||||
10
.github/workflows/goci.yaml
vendored
10
.github/workflows/goci.yaml
vendored
@@ -93,3 +93,13 @@ jobs:
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate openapi
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in openapi spec. Run go run cmd/enterprise/*.go generate openapi locally and commit."; exit 1)
|
||||
- name: node-install
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: install-frontend
|
||||
run: cd frontend && yarn install
|
||||
- name: generate-api-clients
|
||||
run: |
|
||||
cd frontend && yarn generate:api
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run yarn generate:api in frontend/ locally and commit."; exit 1)
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -14,5 +14,8 @@
|
||||
},
|
||||
"[sql]": {
|
||||
"editor.defaultFormatter": "adpyke.vscode-sql-formatter"
|
||||
},
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "vscode.html-language-features"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,15 @@ emailing:
|
||||
templates:
|
||||
# The directory containing the email templates. This directory should contain a list of files defined at pkg/types/emailtypes/template.go.
|
||||
directory: /opt/signoz/conf/templates/email
|
||||
format:
|
||||
header:
|
||||
enabled: false
|
||||
logo_url: ""
|
||||
help:
|
||||
enabled: false
|
||||
email: ""
|
||||
footer:
|
||||
enabled: false
|
||||
smtp:
|
||||
# The SMTP server address.
|
||||
address: localhost:25
|
||||
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.110.1
|
||||
image: signoz/signoz:v0.111.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -209,12 +209,11 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.129.13
|
||||
image: signoz/signoz-otel-collector:v0.142.0
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
- --copy-path=/var/tmp/collector-config.yaml
|
||||
- --feature-gates=-pkg.translator.prometheus.NormalizeName
|
||||
volumes:
|
||||
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
|
||||
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
|
||||
@@ -233,7 +232,7 @@ services:
|
||||
- signoz
|
||||
schema-migrator:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:v0.129.13
|
||||
image: signoz/signoz-schema-migrator:v0.142.0
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.110.1
|
||||
image: signoz/signoz:v0.111.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -150,12 +150,11 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.129.13
|
||||
image: signoz/signoz-otel-collector:v0.142.0
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
- --copy-path=/var/tmp/collector-config.yaml
|
||||
- --feature-gates=-pkg.translator.prometheus.NormalizeName
|
||||
configs:
|
||||
- source: otel-collector-config
|
||||
target: /etc/otel-collector-config.yaml
|
||||
@@ -176,7 +175,7 @@ services:
|
||||
- signoz
|
||||
schema-migrator:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:v0.129.13
|
||||
image: signoz/signoz-schema-migrator:v0.142.0
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -179,7 +179,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.110.1}
|
||||
image: signoz/signoz:${VERSION:-v0.111.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -213,13 +213,12 @@ services:
|
||||
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.13}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
|
||||
container_name: signoz-otel-collector
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
- --copy-path=/var/tmp/collector-config.yaml
|
||||
- --feature-gates=-pkg.translator.prometheus.NormalizeName
|
||||
volumes:
|
||||
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
|
||||
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
|
||||
@@ -239,7 +238,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-sync:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -250,7 +249,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-async:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.110.1}
|
||||
image: signoz/signoz:${VERSION:-v0.111.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -144,13 +144,12 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.13}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
|
||||
container_name: signoz-otel-collector
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
- --copy-path=/var/tmp/collector-config.yaml
|
||||
- --feature-gates=-pkg.translator.prometheus.NormalizeName
|
||||
volumes:
|
||||
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
|
||||
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
|
||||
@@ -166,7 +165,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-sync:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -178,7 +177,7 @@ services:
|
||||
restart: on-failure
|
||||
schema-migrator-async:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -4355,6 +4355,8 @@ components:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
Querybuildertypesv5QueryData:
|
||||
properties:
|
||||
@@ -4427,6 +4429,9 @@ components:
|
||||
type: array
|
||||
nullable: true
|
||||
type: object
|
||||
required:
|
||||
- keys
|
||||
- complete
|
||||
type: object
|
||||
TelemetrytypesGettableFieldValues:
|
||||
properties:
|
||||
@@ -4434,6 +4439,9 @@ components:
|
||||
type: boolean
|
||||
values:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldValues'
|
||||
required:
|
||||
- values
|
||||
- complete
|
||||
type: object
|
||||
TelemetrytypesTelemetryFieldKey:
|
||||
properties:
|
||||
@@ -4449,6 +4457,8 @@ components:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
TelemetrytypesTelemetryFieldValues:
|
||||
properties:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"time"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
querierAPI "github.com/SigNoz/signoz/pkg/querier"
|
||||
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -31,14 +30,13 @@ type APIHandlerOptions struct {
|
||||
RulesManager *rules.Manager
|
||||
UsageManager *usage.Manager
|
||||
IntegrationsController *integrations.Controller
|
||||
CloudIntegrationsRegistry map[integrationstypes.CloudProviderType]integrationstypes.CloudProvider
|
||||
CloudIntegrationsController *cloudintegrations.Controller
|
||||
LogsParsingPipelineController *logparsingpipeline.LogParsingPipelineController
|
||||
Gateway *httputil.ReverseProxy
|
||||
GatewayUrl string
|
||||
// Querier Influx Interval
|
||||
FluxInterval time.Duration
|
||||
GlobalConfig global.Config
|
||||
Logger *slog.Logger // this is present in Signoz.Instrumentation but adding for quick access
|
||||
}
|
||||
|
||||
type APIHandler struct {
|
||||
@@ -52,7 +50,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
|
||||
Reader: opts.DataConnector,
|
||||
RuleManager: opts.RulesManager,
|
||||
IntegrationsController: opts.IntegrationsController,
|
||||
CloudIntegrationsRegistry: opts.CloudIntegrationsRegistry,
|
||||
CloudIntegrationsController: opts.CloudIntegrationsController,
|
||||
LogsParsingPipelineController: opts.LogsParsingPipelineController,
|
||||
FluxInterval: opts.FluxInterval,
|
||||
AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager),
|
||||
@@ -120,12 +118,14 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
}
|
||||
|
||||
func (ah *APIHandler) RegisterCloudIntegrationsRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
|
||||
ah.APIHandler.RegisterCloudIntegrationsRoutes(router, am)
|
||||
|
||||
router.HandleFunc(
|
||||
"/api/v1/cloud-integrations/{cloudProvider}/accounts/generate-connection-params",
|
||||
am.EditAccess(ah.CloudIntegrationsGenerateConnectionParams),
|
||||
).Methods(http.MethodGet)
|
||||
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getVersion(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -14,14 +13,20 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TODO: move this file with other cloud integration related code
|
||||
type CloudIntegrationConnectionParamsResponse struct {
|
||||
IngestionUrl string `json:"ingestion_url,omitempty"`
|
||||
IngestionKey string `json:"ingestion_key,omitempty"`
|
||||
SigNozAPIUrl string `json:"signoz_api_url,omitempty"`
|
||||
SigNozAPIKey string `json:"signoz_api_key,omitempty"`
|
||||
}
|
||||
|
||||
func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
@@ -36,21 +41,23 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
|
||||
return
|
||||
}
|
||||
|
||||
cloudProviderString := mux.Vars(r)["cloudProvider"]
|
||||
|
||||
cloudProvider, err := integrationstypes.NewCloudProvider(cloudProviderString)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
cloudProvider := mux.Vars(r)["cloudProvider"]
|
||||
if cloudProvider != "aws" {
|
||||
RespondError(w, basemodel.BadRequest(fmt.Errorf(
|
||||
"cloud provider not supported: %s", cloudProvider,
|
||||
)), nil)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, err := ah.getOrCreateCloudIntegrationPAT(r.Context(), claims.OrgID, cloudProvider)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
apiKey, apiErr := ah.getOrCreateCloudIntegrationPAT(r.Context(), claims.OrgID, cloudProvider)
|
||||
if apiErr != nil {
|
||||
RespondError(w, basemodel.WrapApiError(
|
||||
apiErr, "couldn't provision PAT for cloud integration:",
|
||||
), nil)
|
||||
return
|
||||
}
|
||||
|
||||
result := integrationstypes.GettableCloudIntegrationConnectionParams{
|
||||
result := CloudIntegrationConnectionParamsResponse{
|
||||
SigNozAPIKey: apiKey,
|
||||
}
|
||||
|
||||
@@ -64,17 +71,16 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
|
||||
// Return the API Key (PAT) even if the rest of the params can not be deduced.
|
||||
// Params not returned from here will be requested from the user via form inputs.
|
||||
// This enables gracefully degraded but working experience even for non-cloud deployments.
|
||||
ah.opts.Logger.InfoContext(
|
||||
r.Context(),
|
||||
"ingestion params and signoz api url can not be deduced since no license was found",
|
||||
)
|
||||
render.Success(w, http.StatusOK, result)
|
||||
zap.L().Info("ingestion params and signoz api url can not be deduced since no license was found")
|
||||
ah.Respond(w, result)
|
||||
return
|
||||
}
|
||||
|
||||
signozApiUrl, err := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
signozApiUrl, apiErr := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
|
||||
if apiErr != nil {
|
||||
RespondError(w, basemodel.WrapApiError(
|
||||
apiErr, "couldn't deduce ingestion url and signoz api url",
|
||||
), nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -83,41 +89,48 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
|
||||
|
||||
gatewayUrl := ah.opts.GatewayUrl
|
||||
if len(gatewayUrl) > 0 {
|
||||
ingestionKeyString, err := ah.getOrCreateCloudProviderIngestionKey(
|
||||
|
||||
ingestionKey, apiErr := getOrCreateCloudProviderIngestionKey(
|
||||
r.Context(), gatewayUrl, license.Key, cloudProvider,
|
||||
)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
if apiErr != nil {
|
||||
RespondError(w, basemodel.WrapApiError(
|
||||
apiErr, "couldn't get or create ingestion key",
|
||||
), nil)
|
||||
return
|
||||
}
|
||||
|
||||
result.IngestionKey = ingestionKeyString
|
||||
result.IngestionKey = ingestionKey
|
||||
|
||||
} else {
|
||||
ah.opts.Logger.InfoContext(
|
||||
r.Context(),
|
||||
"ingestion key can't be deduced since no gateway url has been configured",
|
||||
)
|
||||
zap.L().Info("ingestion key can't be deduced since no gateway url has been configured")
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, result)
|
||||
ah.Respond(w, result)
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId string, cloudProvider valuer.String) (string, error) {
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId string, cloudProvider string) (
|
||||
string, *basemodel.ApiError,
|
||||
) {
|
||||
integrationPATName := fmt.Sprintf("%s integration", cloudProvider)
|
||||
|
||||
integrationUser, err := ah.getOrCreateCloudIntegrationUser(ctx, orgId, cloudProvider)
|
||||
if err != nil {
|
||||
return "", err
|
||||
integrationUser, apiErr := ah.getOrCreateCloudIntegrationUser(ctx, orgId, cloudProvider)
|
||||
if apiErr != nil {
|
||||
return "", apiErr
|
||||
}
|
||||
|
||||
orgIdUUID, err := valuer.NewUUID(orgId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't parse orgId: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
allPats, err := ah.Signoz.Modules.User.ListAPIKeys(ctx, orgIdUUID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't list PATs: %w", err,
|
||||
))
|
||||
}
|
||||
for _, p := range allPats {
|
||||
if p.UserID == integrationUser.ID && p.Name == integrationPATName {
|
||||
@@ -125,10 +138,9 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId
|
||||
}
|
||||
}
|
||||
|
||||
ah.opts.Logger.InfoContext(
|
||||
ctx,
|
||||
zap.L().Info(
|
||||
"no PAT found for cloud integration, creating a new one",
|
||||
slog.String("cloudProvider", cloudProvider.String()),
|
||||
zap.String("cloudProvider", cloudProvider),
|
||||
)
|
||||
|
||||
newPAT, err := types.NewStorableAPIKey(
|
||||
@@ -138,48 +150,68 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't create cloud integration PAT: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
err = ah.Signoz.Modules.User.CreateAPIKey(ctx, newPAT)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't create cloud integration PAT: %w", err,
|
||||
))
|
||||
}
|
||||
return newPAT.Token, nil
|
||||
}
|
||||
|
||||
// TODO: move this function out of handler and use proper module structure
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationUser(ctx context.Context, orgId string, cloudProvider valuer.String) (*types.User, error) {
|
||||
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider.String())
|
||||
func (ah *APIHandler) getOrCreateCloudIntegrationUser(
|
||||
ctx context.Context, orgId string, cloudProvider string,
|
||||
) (*types.User, *basemodel.ApiError) {
|
||||
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
|
||||
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
|
||||
|
||||
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
|
||||
}
|
||||
|
||||
password := types.MustGenerateFactorPassword(cloudIntegrationUser.ID.StringValue())
|
||||
|
||||
cloudIntegrationUser, err = ah.Signoz.Modules.User.GetOrCreateUser(ctx, cloudIntegrationUser, user.WithFactorPassword(password))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't look for integration user: %w", err))
|
||||
}
|
||||
|
||||
return cloudIntegrationUser, nil
|
||||
}
|
||||
|
||||
// TODO: move this function out of handler and use proper module structure
|
||||
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (string, error) {
|
||||
respBytes, err := ah.Signoz.Zeus.GetDeployment(ctx, licenseKey)
|
||||
if err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "couldn't query for deployment info: error")
|
||||
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (
|
||||
string, *basemodel.ApiError,
|
||||
) {
|
||||
// TODO: remove this struct from here
|
||||
type deploymentResponse struct {
|
||||
Name string `json:"name"`
|
||||
ClusterInfo struct {
|
||||
Region struct {
|
||||
DNS string `json:"dns"`
|
||||
} `json:"region"`
|
||||
} `json:"cluster"`
|
||||
}
|
||||
|
||||
resp := new(integrationstypes.GettableDeployment)
|
||||
respBytes, err := ah.Signoz.Zeus.GetDeployment(ctx, licenseKey)
|
||||
if err != nil {
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't query for deployment info: error: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
resp := new(deploymentResponse)
|
||||
|
||||
err = json.Unmarshal(respBytes, resp)
|
||||
if err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "couldn't unmarshal deployment info response")
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't unmarshal deployment info response: error: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
regionDns := resp.ClusterInfo.Region.DNS
|
||||
@@ -187,11 +219,9 @@ func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licens
|
||||
|
||||
if len(regionDns) < 1 || len(deploymentName) < 1 {
|
||||
// Fail early if actual response structure and expectation here ever diverge
|
||||
return "", errors.WrapInternalf(
|
||||
err,
|
||||
errors.CodeInternal,
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"deployment info response not in expected shape. couldn't determine region dns and deployment name",
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
signozApiUrl := fmt.Sprintf("https://%s.%s", deploymentName, regionDns)
|
||||
@@ -199,85 +229,102 @@ func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licens
|
||||
return signozApiUrl, nil
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getOrCreateCloudProviderIngestionKey(
|
||||
ctx context.Context, gatewayUrl string, licenseKey string, cloudProvider valuer.String,
|
||||
) (string, error) {
|
||||
type ingestionKey struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
// other attributes from gateway response not included here since they are not being used.
|
||||
}
|
||||
|
||||
type ingestionKeysSearchResponse struct {
|
||||
Status string `json:"status"`
|
||||
Data []ingestionKey `json:"data"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type createIngestionKeyResponse struct {
|
||||
Status string `json:"status"`
|
||||
Data ingestionKey `json:"data"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func getOrCreateCloudProviderIngestionKey(
|
||||
ctx context.Context, gatewayUrl string, licenseKey string, cloudProvider string,
|
||||
) (string, *basemodel.ApiError) {
|
||||
cloudProviderKeyName := fmt.Sprintf("%s-integration", cloudProvider)
|
||||
|
||||
// see if the key already exists
|
||||
searchResult, err := requestGateway[integrationstypes.GettableIngestionKeysSearch](
|
||||
searchResult, apiErr := requestGateway[ingestionKeysSearchResponse](
|
||||
ctx,
|
||||
gatewayUrl,
|
||||
licenseKey,
|
||||
fmt.Sprintf("/v1/workspaces/me/keys/search?name=%s", cloudProviderKeyName),
|
||||
nil,
|
||||
ah.opts.Logger,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
if apiErr != nil {
|
||||
return "", basemodel.WrapApiError(
|
||||
apiErr, "couldn't search for cloudprovider ingestion key",
|
||||
)
|
||||
}
|
||||
|
||||
if searchResult.Status != "success" {
|
||||
return "", errors.NewInternalf(
|
||||
errors.CodeInternal,
|
||||
"couldn't search for cloud provider ingestion key: status: %s, error: %s",
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't search for cloudprovider ingestion key: status: %s, error: %s",
|
||||
searchResult.Status, searchResult.Error,
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
for _, k := range searchResult.Data {
|
||||
if k.Name != cloudProviderKeyName {
|
||||
continue
|
||||
}
|
||||
if k.Name == cloudProviderKeyName {
|
||||
if len(k.Value) < 1 {
|
||||
// Fail early if actual response structure and expectation here ever diverge
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"ingestion keys search response not as expected",
|
||||
))
|
||||
}
|
||||
|
||||
if len(k.Value) < 1 {
|
||||
// Fail early if actual response structure and expectation here ever diverge
|
||||
return "", errors.NewInternalf(errors.CodeInternal, "ingestion keys search response not as expected")
|
||||
return k.Value, nil
|
||||
}
|
||||
|
||||
return k.Value, nil
|
||||
}
|
||||
|
||||
ah.opts.Logger.InfoContext(
|
||||
ctx,
|
||||
zap.L().Info(
|
||||
"no existing ingestion key found for cloud integration, creating a new one",
|
||||
slog.String("cloudProvider", cloudProvider.String()),
|
||||
zap.String("cloudProvider", cloudProvider),
|
||||
)
|
||||
|
||||
createKeyResult, err := requestGateway[integrationstypes.GettableCreateIngestionKey](
|
||||
createKeyResult, apiErr := requestGateway[createIngestionKeyResponse](
|
||||
ctx, gatewayUrl, licenseKey, "/v1/workspaces/me/keys",
|
||||
map[string]any{
|
||||
"name": cloudProviderKeyName,
|
||||
"tags": []string{"integration", cloudProvider.String()},
|
||||
"tags": []string{"integration", cloudProvider},
|
||||
},
|
||||
ah.opts.Logger,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
if apiErr != nil {
|
||||
return "", basemodel.WrapApiError(
|
||||
apiErr, "couldn't create cloudprovider ingestion key",
|
||||
)
|
||||
}
|
||||
|
||||
if createKeyResult.Status != "success" {
|
||||
return "", errors.NewInternalf(
|
||||
errors.CodeInternal,
|
||||
"couldn't create cloud provider ingestion key: status: %s, error: %s",
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't create cloudprovider ingestion key: status: %s, error: %s",
|
||||
createKeyResult.Status, createKeyResult.Error,
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
ingestionKeyString := createKeyResult.Data.Value
|
||||
if len(ingestionKeyString) < 1 {
|
||||
ingestionKey := createKeyResult.Data.Value
|
||||
if len(ingestionKey) < 1 {
|
||||
// Fail early if actual response structure and expectation here ever diverge
|
||||
return "", errors.NewInternalf(errors.CodeInternal,
|
||||
return "", basemodel.InternalError(fmt.Errorf(
|
||||
"ingestion key creation response not as expected",
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
return ingestionKeyString, nil
|
||||
return ingestionKey, nil
|
||||
}
|
||||
|
||||
func requestGateway[ResponseType any](
|
||||
ctx context.Context, gatewayUrl, licenseKey, path string, payload any, logger *slog.Logger,
|
||||
) (*ResponseType, error) {
|
||||
ctx context.Context, gatewayUrl string, licenseKey string, path string, payload any,
|
||||
) (*ResponseType, *basemodel.ApiError) {
|
||||
|
||||
baseUrl := strings.TrimSuffix(gatewayUrl, "/")
|
||||
reqUrl := fmt.Sprintf("%s%s", baseUrl, path)
|
||||
@@ -288,12 +335,13 @@ func requestGateway[ResponseType any](
|
||||
"X-Consumer-Groups": "ns:default",
|
||||
}
|
||||
|
||||
return requestAndParseResponse[ResponseType](ctx, reqUrl, headers, payload, logger)
|
||||
return requestAndParseResponse[ResponseType](ctx, reqUrl, headers, payload)
|
||||
}
|
||||
|
||||
func requestAndParseResponse[ResponseType any](
|
||||
ctx context.Context, url string, headers map[string]string, payload any, logger *slog.Logger,
|
||||
) (*ResponseType, error) {
|
||||
ctx context.Context, url string, headers map[string]string, payload any,
|
||||
) (*ResponseType, *basemodel.ApiError) {
|
||||
|
||||
reqMethod := http.MethodGet
|
||||
var reqBody io.Reader
|
||||
if payload != nil {
|
||||
@@ -301,14 +349,18 @@ func requestAndParseResponse[ResponseType any](
|
||||
|
||||
bodyJson, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't marshal payload")
|
||||
return nil, basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't serialize request payload to JSON: %w", err,
|
||||
))
|
||||
}
|
||||
reqBody = bytes.NewBuffer(bodyJson)
|
||||
reqBody = bytes.NewBuffer([]byte(bodyJson))
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, reqMethod, url, reqBody)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't create req")
|
||||
return nil, basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't prepare request: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
@@ -321,26 +373,23 @@ func requestAndParseResponse[ResponseType any](
|
||||
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't make req")
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't make request: %w", err))
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = response.Body.Close()
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "couldn't close response body", "error", err)
|
||||
}
|
||||
}()
|
||||
defer response.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read response body")
|
||||
return nil, basemodel.InternalError(fmt.Errorf("couldn't read response: %w", err))
|
||||
}
|
||||
|
||||
var resp ResponseType
|
||||
|
||||
err = json.Unmarshal(respBody, &resp)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't unmarshal response body")
|
||||
return nil, basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't unmarshal gateway response into %T", resp,
|
||||
))
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
|
||||
@@ -127,7 +127,12 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
)
|
||||
}
|
||||
|
||||
cloudIntegrationsRegistry := cloudintegrations.NewCloudProviderRegistry(signoz.Instrumentation.Logger(), signoz.SQLStore, signoz.Querier)
|
||||
cloudIntegrationsController, err := cloudintegrations.NewController(signoz.SQLStore)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"couldn't create cloud provider integrations controller: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// ingestion pipelines manager
|
||||
logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController(
|
||||
@@ -162,13 +167,12 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
RulesManager: rm,
|
||||
UsageManager: usageManager,
|
||||
IntegrationsController: integrationsController,
|
||||
CloudIntegrationsRegistry: cloudIntegrationsRegistry,
|
||||
CloudIntegrationsController: cloudIntegrationsController,
|
||||
LogsParsingPipelineController: logParsingPipelineController,
|
||||
FluxInterval: config.Querier.FluxInterval,
|
||||
Gateway: gatewayProxy,
|
||||
GatewayUrl: config.Gateway.URL.String(),
|
||||
GlobalConfig: config.Global,
|
||||
Logger: signoz.Instrumentation.Logger(),
|
||||
}
|
||||
|
||||
apiHandler, err := api.NewAPIHandler(apiOpts, signoz)
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface MockUPlotInstance {
|
||||
export interface MockUPlotPaths {
|
||||
spline: jest.Mock;
|
||||
bars: jest.Mock;
|
||||
linear: jest.Mock;
|
||||
stepped: jest.Mock;
|
||||
}
|
||||
|
||||
// Create mock instance methods
|
||||
@@ -23,10 +25,23 @@ const createMockUPlotInstance = (): MockUPlotInstance => ({
|
||||
setSeries: jest.fn(),
|
||||
});
|
||||
|
||||
// Create mock paths
|
||||
const mockPaths: MockUPlotPaths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
|
||||
const createMockPathBuilder = (name: string): jest.Mock =>
|
||||
jest.fn(() => ({
|
||||
name, // To test if the correct pathBuilder is used
|
||||
stroke: jest.fn(),
|
||||
fill: jest.fn(),
|
||||
clip: jest.fn(),
|
||||
}));
|
||||
|
||||
// Create mock paths - linear, spline, stepped needed by UPlotSeriesBuilder.getPathBuilder
|
||||
const mockPaths = {
|
||||
spline: jest.fn(() => createMockPathBuilder('spline')),
|
||||
bars: jest.fn(() => createMockPathBuilder('bars')),
|
||||
linear: jest.fn(() => createMockPathBuilder('linear')),
|
||||
stepped: jest.fn((opts?: { align?: number }) =>
|
||||
createMockPathBuilder(`stepped-(${opts?.align ?? 0})`),
|
||||
),
|
||||
};
|
||||
|
||||
// Mock static methods
|
||||
|
||||
@@ -17,6 +17,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,
|
||||
'^@signozhq/icons$':
|
||||
'<rootDir>/node_modules/@signozhq/icons/dist/index.esm.js',
|
||||
},
|
||||
globals: {
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"commitlint": "commitlint --edit $1",
|
||||
"test": "jest",
|
||||
"test:changedsince": "jest --changedSince=main --coverage --silent",
|
||||
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh && prettier --write src/api/generated && (eslint --fix src/api/generated || true)"
|
||||
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.15.0"
|
||||
@@ -52,6 +52,7 @@
|
||||
"@signozhq/combobox": "0.0.2",
|
||||
"@signozhq/command": "0.0.0",
|
||||
"@signozhq/design-tokens": "2.1.1",
|
||||
"@signozhq/icons": "0.1.0",
|
||||
"@signozhq/input": "0.0.2",
|
||||
"@signozhq/popover": "0.0.0",
|
||||
"@signozhq/resizable": "0.0.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"SIGN_UP": "SigNoz | Sign Up",
|
||||
"LOGIN": "SigNoz | Login",
|
||||
"FORGOT_PASSWORD": "SigNoz | Forgot Password",
|
||||
"HOME": "SigNoz | Home",
|
||||
"SERVICE_METRICS": "SigNoz | Service Metrics",
|
||||
"SERVICE_MAP": "SigNoz | Service Map",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "\n\n---\nRenamed tag files to index.ts...\n"
|
||||
# Rename tag files to index.ts in services directories
|
||||
# tags-split creates: services/tagName/tagName.ts -> rename to services/tagName/index.ts
|
||||
find src/api/generated/services -mindepth 1 -maxdepth 1 -type d | while read -r dir; do
|
||||
@@ -11,4 +12,33 @@ find src/api/generated/services -mindepth 1 -maxdepth 1 -type d | while read -r
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Tag files renamed to index.ts"
|
||||
echo "\n✅ Tag files renamed to index.ts"
|
||||
|
||||
# Format generated files
|
||||
echo "\n\n---\nRunning prettier...\n"
|
||||
if ! prettier --write src/api/generated; then
|
||||
echo "Prettier formatting failed!"
|
||||
exit 1
|
||||
fi
|
||||
echo "\n✅ Prettier formatting successful"
|
||||
|
||||
|
||||
# Fix linting issues
|
||||
echo "\n\n---\nRunning eslint...\n"
|
||||
if ! yarn lint --fix --quiet src/api/generated; then
|
||||
echo "ESLint check failed! Please fix linting errors before proceeding."
|
||||
exit 1
|
||||
fi
|
||||
echo "\n✅ ESLint check successful"
|
||||
|
||||
|
||||
# Check for type errors
|
||||
echo "\n\n---\nChecking for type errors...\n"
|
||||
if ! tsc --noEmit; then
|
||||
echo "Type check failed! Please fix type errors before proceeding."
|
||||
exit 1
|
||||
fi
|
||||
echo "\n✅ Type check successful"
|
||||
|
||||
|
||||
echo "\n\n---\n ✅✅✅ API generation complete!"
|
||||
|
||||
@@ -194,6 +194,10 @@ export const Login = Loadable(
|
||||
() => import(/* webpackChunkName: "Login" */ 'pages/Login'),
|
||||
);
|
||||
|
||||
export const ForgotPassword = Loadable(
|
||||
() => import(/* webpackChunkName: "ForgotPassword" */ 'pages/ForgotPassword'),
|
||||
);
|
||||
|
||||
export const UnAuthorized = Loadable(
|
||||
() => import(/* webpackChunkName: "UnAuthorized" */ 'pages/UnAuthorized'),
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DashboardWidget,
|
||||
EditRulesPage,
|
||||
ErrorDetails,
|
||||
ForgotPassword,
|
||||
Home,
|
||||
InfrastructureMonitoring,
|
||||
InstalledIntegrations,
|
||||
@@ -339,6 +340,13 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: false,
|
||||
key: 'LOGIN',
|
||||
},
|
||||
{
|
||||
path: ROUTES.FORGOT_PASSWORD,
|
||||
exact: true,
|
||||
component: ForgotPassword,
|
||||
isPrivate: false,
|
||||
key: 'FORGOT_PASSWORD',
|
||||
},
|
||||
{
|
||||
path: ROUTES.UN_AUTHORIZED,
|
||||
exact: true,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
|
||||
const dashboardVariablesQuery = async (
|
||||
props: Props,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SuccessResponse<VariableResponseProps> | ErrorResponse> => {
|
||||
try {
|
||||
const { globalTime } = store.getState();
|
||||
@@ -32,7 +33,7 @@ const dashboardVariablesQuery = async (
|
||||
|
||||
payload.variables = { ...payload.variables, ...timeVariables };
|
||||
|
||||
const response = await axios.post(`/variables/query`, payload);
|
||||
const response = await axios.post(`/variables/query`, payload, { signal });
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
|
||||
@@ -19,6 +19,7 @@ export const getFieldValues = async (
|
||||
startUnixMilli?: number,
|
||||
endUnixMilli?: number,
|
||||
existingQuery?: string,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<SuccessResponseV2<FieldValueResponse>> => {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
@@ -47,7 +48,10 @@ export const getFieldValues = async (
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get('/fields/values', { params });
|
||||
const response = await axios.get('/fields/values', {
|
||||
params,
|
||||
signal: abortSignal,
|
||||
});
|
||||
|
||||
// Normalize values from different types (stringValues, boolValues, etc.)
|
||||
if (response.data?.data?.values) {
|
||||
|
||||
222
frontend/src/api/generated/services/fields/index.ts
Normal file
222
frontend/src/api/generated/services/fields/index.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'yarn generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../index';
|
||||
import type {
|
||||
GetFieldsKeys200,
|
||||
GetFieldsKeysParams,
|
||||
GetFieldsValues200,
|
||||
GetFieldsValuesParams,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
type AwaitedInput<T> = PromiseLike<T> | T;
|
||||
|
||||
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
|
||||
|
||||
/**
|
||||
* This endpoint returns field keys
|
||||
* @summary Get field keys
|
||||
*/
|
||||
export const getFieldsKeys = (
|
||||
params?: GetFieldsKeysParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetFieldsKeys200>({
|
||||
url: `/api/v1/fields/keys`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetFieldsKeysQueryKey = (params?: GetFieldsKeysParams) => {
|
||||
return ['getFieldsKeys', ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetFieldsKeysQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getFieldsKeys>>,
|
||||
TError = RenderErrorResponseDTO
|
||||
>(
|
||||
params?: GetFieldsKeysParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getFieldsKeys>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetFieldsKeysQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getFieldsKeys>>> = ({
|
||||
signal,
|
||||
}) => getFieldsKeys(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getFieldsKeys>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetFieldsKeysQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getFieldsKeys>>
|
||||
>;
|
||||
export type GetFieldsKeysQueryError = RenderErrorResponseDTO;
|
||||
|
||||
/**
|
||||
* @summary Get field keys
|
||||
*/
|
||||
|
||||
export function useGetFieldsKeys<
|
||||
TData = Awaited<ReturnType<typeof getFieldsKeys>>,
|
||||
TError = RenderErrorResponseDTO
|
||||
>(
|
||||
params?: GetFieldsKeysParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getFieldsKeys>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetFieldsKeysQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get field keys
|
||||
*/
|
||||
export const invalidateGetFieldsKeys = async (
|
||||
queryClient: QueryClient,
|
||||
params?: GetFieldsKeysParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetFieldsKeysQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns field values
|
||||
* @summary Get field values
|
||||
*/
|
||||
export const getFieldsValues = (
|
||||
params?: GetFieldsValuesParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetFieldsValues200>({
|
||||
url: `/api/v1/fields/values`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetFieldsValuesQueryKey = (params?: GetFieldsValuesParams) => {
|
||||
return ['getFieldsValues', ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getGetFieldsValuesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getFieldsValues>>,
|
||||
TError = RenderErrorResponseDTO
|
||||
>(
|
||||
params?: GetFieldsValuesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getFieldsValues>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetFieldsValuesQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getFieldsValues>>> = ({
|
||||
signal,
|
||||
}) => getFieldsValues(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getFieldsValues>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetFieldsValuesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getFieldsValues>>
|
||||
>;
|
||||
export type GetFieldsValuesQueryError = RenderErrorResponseDTO;
|
||||
|
||||
/**
|
||||
* @summary Get field values
|
||||
*/
|
||||
|
||||
export function useGetFieldsValues<
|
||||
TData = Awaited<ReturnType<typeof getFieldsValues>>,
|
||||
TError = RenderErrorResponseDTO
|
||||
>(
|
||||
params?: GetFieldsValuesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getFieldsValues>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetFieldsValuesQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
query.queryKey = queryOptions.queryKey;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get field values
|
||||
*/
|
||||
export const invalidateGetFieldsValues = async (
|
||||
queryClient: QueryClient,
|
||||
params?: GetFieldsValuesParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetFieldsValuesQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
@@ -1049,7 +1049,7 @@ export interface Querybuildertypesv5OrderByKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -1141,6 +1141,79 @@ export interface RoletypesRoleDTO {
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type TelemetrytypesGettableFieldKeysDTOKeys = {
|
||||
[key: string]: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
} | null;
|
||||
|
||||
export interface TelemetrytypesGettableFieldKeysDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
complete: boolean;
|
||||
/**
|
||||
* @type object
|
||||
* @nullable true
|
||||
*/
|
||||
keys: TelemetrytypesGettableFieldKeysDTOKeys;
|
||||
}
|
||||
|
||||
export interface TelemetrytypesGettableFieldValuesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
complete: boolean;
|
||||
values: TelemetrytypesTelemetryFieldValuesDTO;
|
||||
}
|
||||
|
||||
export interface TelemetrytypesTelemetryFieldKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
fieldContext?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
fieldDataType?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
signal?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface TelemetrytypesTelemetryFieldValuesDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
boolValues?: boolean[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
numberValues?: number[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
relatedValues?: string[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
stringValues?: string[];
|
||||
}
|
||||
|
||||
export interface TypesChangePasswordRequestDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -1588,6 +1661,132 @@ export type DeleteAuthDomainPathParameters = {
|
||||
export type UpdateAuthDomainPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetFieldsKeysParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
signal?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
source?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
* @description undefined
|
||||
*/
|
||||
startUnixMilli?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
* @description undefined
|
||||
*/
|
||||
endUnixMilli?: number;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
fieldContext?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
fieldDataType?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
metricName?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
searchText?: string;
|
||||
};
|
||||
|
||||
export type GetFieldsKeys200 = {
|
||||
data?: TelemetrytypesGettableFieldKeysDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type GetFieldsValuesParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
signal?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
source?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
* @description undefined
|
||||
*/
|
||||
startUnixMilli?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
* @description undefined
|
||||
*/
|
||||
endUnixMilli?: number;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
fieldContext?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
fieldDataType?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
metricName?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
searchText?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
existingQuery?: string;
|
||||
};
|
||||
|
||||
export type GetFieldsValues200 = {
|
||||
data?: TelemetrytypesGettableFieldValuesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type GetResetPasswordTokenPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
1
frontend/src/auto-import-registry.d.ts
vendored
1
frontend/src/auto-import-registry.d.ts
vendored
@@ -18,6 +18,7 @@ import '@signozhq/checkbox';
|
||||
import '@signozhq/combobox';
|
||||
import '@signozhq/command';
|
||||
import '@signozhq/design-tokens';
|
||||
import '@signozhq/icons';
|
||||
import '@signozhq/input';
|
||||
import '@signozhq/popover';
|
||||
import '@signozhq/resizable';
|
||||
|
||||
@@ -73,6 +73,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
enableRegexOption = false,
|
||||
isDynamicVariable = false,
|
||||
showRetryButton = true,
|
||||
waitingMessage,
|
||||
...rest
|
||||
}) => {
|
||||
// ===== State & Refs =====
|
||||
@@ -1681,6 +1682,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
{!loading &&
|
||||
!errorMessage &&
|
||||
!noDataMessage &&
|
||||
!waitingMessage &&
|
||||
!(showIncompleteDataMessage && isScrolledToBottom) && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
@@ -1698,7 +1700,17 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
<div className="navigation-text">Refreshing values...</div>
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && !loading && (
|
||||
{!loading && waitingMessage && (
|
||||
<div className="navigation-loading">
|
||||
<div className="navigation-icons">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="navigation-text" title={waitingMessage}>
|
||||
{waitingMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && !loading && !waitingMessage && (
|
||||
<div className="navigation-error">
|
||||
<div className="navigation-text">
|
||||
{errorMessage || SOMETHING_WENT_WRONG}
|
||||
@@ -1720,6 +1732,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
{showIncompleteDataMessage &&
|
||||
isScrolledToBottom &&
|
||||
!loading &&
|
||||
!waitingMessage &&
|
||||
!errorMessage && (
|
||||
<div className="navigation-text-incomplete">
|
||||
Don't see the value? Use search
|
||||
@@ -1762,6 +1775,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
isDarkMode,
|
||||
isDynamicVariable,
|
||||
showRetryButton,
|
||||
waitingMessage,
|
||||
]);
|
||||
|
||||
// Custom handler for dropdown visibility changes
|
||||
|
||||
@@ -63,6 +63,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
showIncompleteDataMessage = false,
|
||||
showRetryButton = true,
|
||||
isDynamicVariable = false,
|
||||
waitingMessage,
|
||||
...rest
|
||||
}) => {
|
||||
// ===== State & Refs =====
|
||||
@@ -568,6 +569,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
{!loading &&
|
||||
!errorMessage &&
|
||||
!noDataMessage &&
|
||||
!waitingMessage &&
|
||||
!(showIncompleteDataMessage && isScrolledToBottom) && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
@@ -583,6 +585,16 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
<div className="navigation-text">Refreshing values...</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && waitingMessage && (
|
||||
<div className="navigation-loading">
|
||||
<div className="navigation-icons">
|
||||
<LoadingOutlined />
|
||||
</div>
|
||||
<div className="navigation-text" title={waitingMessage}>
|
||||
{waitingMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && !loading && (
|
||||
<div className="navigation-error">
|
||||
<div className="navigation-text">
|
||||
@@ -605,6 +617,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
{showIncompleteDataMessage &&
|
||||
isScrolledToBottom &&
|
||||
!loading &&
|
||||
!waitingMessage &&
|
||||
!errorMessage && (
|
||||
<div className="navigation-text-incomplete">
|
||||
Don't see the value? Use search
|
||||
@@ -641,6 +654,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
showRetryButton,
|
||||
isDarkMode,
|
||||
isDynamicVariable,
|
||||
waitingMessage,
|
||||
]);
|
||||
|
||||
// Handle dropdown visibility changes
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
|
||||
showIncompleteDataMessage?: boolean;
|
||||
showRetryButton?: boolean;
|
||||
isDynamicVariable?: boolean;
|
||||
waitingMessage?: string;
|
||||
}
|
||||
|
||||
export interface CustomTagProps {
|
||||
@@ -66,4 +67,5 @@ export interface CustomMultiSelectProps
|
||||
enableRegexOption?: boolean;
|
||||
isDynamicVariable?: boolean;
|
||||
showRetryButton?: boolean;
|
||||
waitingMessage?: string;
|
||||
}
|
||||
|
||||
@@ -648,7 +648,13 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
) : (
|
||||
<Typography.Text
|
||||
className="value-string"
|
||||
ellipsis={{ tooltip: { placement: 'top' } }}
|
||||
ellipsis={{
|
||||
tooltip: {
|
||||
placement: 'top',
|
||||
mouseEnterDelay: 0.2,
|
||||
mouseLeaveDelay: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const ROUTES = {
|
||||
SIGN_UP: '/signup',
|
||||
LOGIN: '/login',
|
||||
FORGOT_PASSWORD: '/forgot-password',
|
||||
HOME: '/home',
|
||||
SERVICE_METRICS: '/services/:servicename',
|
||||
SERVICE_TOP_LEVEL_OPERATIONS: '/services/:servicename/top-level-operations',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Spin, Table, Typography } from 'antd';
|
||||
import { Spin, Table } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
@@ -14,11 +14,13 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useListOverview } from 'hooks/thirdPartyApis/useListOverview';
|
||||
import { get } from 'lodash-es';
|
||||
import { MoveUpRight } from 'lucide-react';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import { ApiMonitoringHardcodedAttributeKeys } from '../../constants';
|
||||
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
|
||||
@@ -125,51 +127,67 @@ function DomainList(): JSX.Element {
|
||||
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
className={cx('api-monitoring-domain-list-table')}
|
||||
dataSource={isFetching || isLoading ? [] : formattedDataForTable}
|
||||
columns={columnsConfig}
|
||||
loading={{
|
||||
spinning: isFetching || isLoading,
|
||||
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
|
||||
}}
|
||||
locale={{
|
||||
emptyText:
|
||||
isFetching || isLoading ? null : (
|
||||
<div className="no-filtered-domains-message-container">
|
||||
<div className="no-filtered-domains-message-content">
|
||||
<img
|
||||
src="/Icons/emptyState.svg"
|
||||
alt="thinking-emoji"
|
||||
className="empty-state-svg"
|
||||
/>
|
||||
{!isFetching && !isLoading && formattedDataForTable.length === 0 && (
|
||||
<div className="no-filtered-domains-message-container">
|
||||
<div className="no-filtered-domains-message-content">
|
||||
<img
|
||||
src="/Icons/emptyState.svg"
|
||||
alt="thinking-emoji"
|
||||
className="empty-state-svg"
|
||||
/>
|
||||
|
||||
<Typography.Text className="no-filtered-domains-message">
|
||||
This query had no results. Edit your query and try again!
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="no-filtered-domains-message">
|
||||
<div className="no-domain-title">
|
||||
No External API calls detected with applied filters.
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
scroll={{ x: true }}
|
||||
tableLayout="fixed"
|
||||
onRow={(record, index): { onClick: () => void; className: string } => ({
|
||||
onClick: (): void => {
|
||||
if (index !== undefined) {
|
||||
const dataIndex = formattedDataForTable.findIndex(
|
||||
(item) => item.key === record.key,
|
||||
);
|
||||
setSelectedDomainIndex(dataIndex);
|
||||
setParams({ selectedDomain: record.domainName });
|
||||
logEvent('API Monitoring: Domain name row clicked', {});
|
||||
}
|
||||
},
|
||||
className: 'expanded-clickable-row',
|
||||
})}
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
|
||||
}
|
||||
/>
|
||||
<div className="no-domain-subtitle">
|
||||
Ensure all HTTP client spans are being sent with kind as{' '}
|
||||
<span className="attribute">Client</span> and url set in{' '}
|
||||
<span className="attribute">url.full</span> or{' '}
|
||||
<span className="attribute">http.url</span> attribute.
|
||||
</div>
|
||||
<a
|
||||
href={DOCLINKS.EXTERNAL_API_MONITORING}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="external-api-doc-link"
|
||||
>
|
||||
Learn how External API monitoring works in SigNoz{' '}
|
||||
<MoveUpRight size={14} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(isFetching || isLoading || formattedDataForTable.length > 0) && (
|
||||
<Table
|
||||
className="api-monitoring-domain-list-table"
|
||||
dataSource={isFetching || isLoading ? [] : formattedDataForTable}
|
||||
columns={columnsConfig}
|
||||
loading={{
|
||||
spinning: isFetching || isLoading,
|
||||
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
|
||||
}}
|
||||
scroll={{ x: true }}
|
||||
tableLayout="fixed"
|
||||
onRow={(record, index): { onClick: () => void; className: string } => ({
|
||||
onClick: (): void => {
|
||||
if (index !== undefined) {
|
||||
const dataIndex = formattedDataForTable.findIndex(
|
||||
(item) => item.key === record.key,
|
||||
);
|
||||
setSelectedDomainIndex(dataIndex);
|
||||
setParams({ selectedDomain: record.domainName });
|
||||
logEvent('API Monitoring: Domain name row clicked', {});
|
||||
}
|
||||
},
|
||||
className: 'expanded-clickable-row',
|
||||
})}
|
||||
rowClassName={(_, index): string =>
|
||||
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{selectedDomainIndex !== -1 && (
|
||||
<DomainDetails
|
||||
domainData={formattedDataForTable[selectedDomainIndex]}
|
||||
|
||||
@@ -180,10 +180,59 @@
|
||||
|
||||
.no-filtered-domains-message {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
|
||||
.no-domain-title {
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
|
||||
.no-domain-subtitle {
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
|
||||
.attribute {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
}
|
||||
|
||||
.external-api-doc-link {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.no-filtered-domains-message-container {
|
||||
.no-filtered-domains-message-content {
|
||||
.no-filtered-domains-message {
|
||||
.no-domain-title {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
|
||||
.no-domain-subtitle {
|
||||
color: var(--text-ink-400);
|
||||
|
||||
.attribute {
|
||||
font-family: 'Space Mono';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.api-monitoring-domain-list-table {
|
||||
.ant-table {
|
||||
.ant-table-thead > tr > th {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import {
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import UPlotChart from 'lib/uPlotV2/components/UPlotChart';
|
||||
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
|
||||
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
|
||||
import noop from 'lodash-es/noop';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { ChartProps } from '../types';
|
||||
|
||||
const TOOLTIP_WIDTH_PADDING = 60;
|
||||
const TOOLTIP_MIN_WIDTH = 200;
|
||||
|
||||
export default function ChartWrapper({
|
||||
legendConfig = { position: LegendPosition.BOTTOM },
|
||||
config,
|
||||
data,
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
showTooltip = true,
|
||||
canPinTooltip = false,
|
||||
syncMode,
|
||||
syncKey,
|
||||
onDestroy = noop,
|
||||
children,
|
||||
layoutChildren,
|
||||
renderTooltip,
|
||||
'data-testid': testId,
|
||||
}: ChartProps): JSX.Element {
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
|
||||
const legendComponent = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => {
|
||||
return (
|
||||
<Legend
|
||||
config={config}
|
||||
position={legendConfig.position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[config, legendConfig.position],
|
||||
);
|
||||
|
||||
const renderTooltipCallback = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => {
|
||||
if (renderTooltip) {
|
||||
return renderTooltip(args);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[renderTooltip],
|
||||
);
|
||||
|
||||
return (
|
||||
<PlotContextProvider>
|
||||
<ChartLayout
|
||||
config={config}
|
||||
containerWidth={containerWidth}
|
||||
containerHeight={containerHeight}
|
||||
legendConfig={legendConfig}
|
||||
legendComponent={legendComponent}
|
||||
layoutChildren={layoutChildren}
|
||||
>
|
||||
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
|
||||
<UPlotChart
|
||||
config={config}
|
||||
data={data}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
plotRef={(plot): void => {
|
||||
plotInstanceRef.current = plot;
|
||||
}}
|
||||
onDestroy={(plot: uPlot): void => {
|
||||
plotInstanceRef.current = null;
|
||||
onDestroy(plot);
|
||||
}}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
{showTooltip && (
|
||||
<TooltipPlugin
|
||||
config={config}
|
||||
canPinTooltip={canPinTooltip}
|
||||
syncMode={syncMode}
|
||||
maxWidth={Math.max(
|
||||
TOOLTIP_MIN_WIDTH,
|
||||
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
|
||||
)}
|
||||
syncKey={syncKey}
|
||||
render={renderTooltipCallback}
|
||||
/>
|
||||
)}
|
||||
</UPlotChart>
|
||||
)}
|
||||
</ChartLayout>
|
||||
</PlotContextProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +1,46 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import Tooltip from 'lib/uPlotV2/components/Tooltip/Tooltip';
|
||||
import { useCallback } from 'react';
|
||||
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import TimeSeriesTooltip from 'lib/uPlotV2/components/Tooltip/TimeSeriesTooltip';
|
||||
import { buildTooltipContent } from 'lib/uPlotV2/components/Tooltip/utils';
|
||||
import {
|
||||
LegendPosition,
|
||||
TimeSeriesTooltipProps,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import UPlotChart from 'lib/uPlotV2/components/UPlotChart';
|
||||
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
|
||||
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
|
||||
import _noop from 'lodash-es/noop';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { ChartProps } from '../types';
|
||||
import { TimeSeriesChartProps } from '../types';
|
||||
|
||||
const TOOLTIP_WIDTH_PADDING = 60;
|
||||
const TOOLTIP_MIN_WIDTH = 200;
|
||||
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
const { children, renderTooltip: customRenderTooltip, ...rest } = props;
|
||||
|
||||
export default function TimeSeries({
|
||||
legendConfig = { position: LegendPosition.BOTTOM },
|
||||
config,
|
||||
data,
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
disableTooltip = false,
|
||||
canPinTooltip = false,
|
||||
timezone,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
syncMode,
|
||||
syncKey,
|
||||
onDestroy = _noop,
|
||||
children,
|
||||
layoutChildren,
|
||||
'data-testid': testId,
|
||||
}: ChartProps): JSX.Element {
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
|
||||
const legendComponent = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => {
|
||||
return (
|
||||
<Legend
|
||||
config={config}
|
||||
position={legendConfig.position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
/>
|
||||
);
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
if (customRenderTooltip) {
|
||||
return customRenderTooltip(props);
|
||||
}
|
||||
const content = buildTooltipContent({
|
||||
data: props.uPlotInstance.data,
|
||||
series: props.uPlotInstance.series,
|
||||
dataIndexes: props.dataIndexes,
|
||||
activeSeriesIndex: props.seriesIndex,
|
||||
uPlotInstance: props.uPlotInstance,
|
||||
yAxisUnit: rest.yAxisUnit ?? '',
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
});
|
||||
const tooltipProps: TimeSeriesTooltipProps = {
|
||||
...props,
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
content,
|
||||
};
|
||||
return <TimeSeriesTooltip {...tooltipProps} />;
|
||||
},
|
||||
[config, legendConfig.position],
|
||||
[customRenderTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
|
||||
);
|
||||
|
||||
return (
|
||||
<PlotContextProvider>
|
||||
<ChartLayout
|
||||
config={config}
|
||||
containerWidth={containerWidth}
|
||||
containerHeight={containerHeight}
|
||||
legendConfig={legendConfig}
|
||||
legendComponent={legendComponent}
|
||||
layoutChildren={layoutChildren}
|
||||
>
|
||||
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
|
||||
<UPlotChart
|
||||
config={config}
|
||||
data={data}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
plotRef={(plot): void => {
|
||||
plotInstanceRef.current = plot;
|
||||
}}
|
||||
onDestroy={(plot: uPlot): void => {
|
||||
plotInstanceRef.current = null;
|
||||
onDestroy(plot);
|
||||
}}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
{!disableTooltip && (
|
||||
<TooltipPlugin
|
||||
config={config}
|
||||
canPinTooltip={canPinTooltip}
|
||||
syncMode={syncMode}
|
||||
maxWidth={Math.max(
|
||||
TOOLTIP_MIN_WIDTH,
|
||||
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
|
||||
)}
|
||||
syncKey={syncKey}
|
||||
render={(props: TooltipRenderArgs): React.ReactNode => (
|
||||
<Tooltip
|
||||
{...props}
|
||||
timezone={timezone}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</UPlotChart>
|
||||
)}
|
||||
</ChartLayout>
|
||||
</PlotContextProvider>
|
||||
<ChartWrapper {...rest} renderTooltip={renderTooltip}>
|
||||
{children}
|
||||
</ChartWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,39 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { LegendConfig } from 'lib/uPlotV2/components/types';
|
||||
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
interface BaseChartProps {
|
||||
width: number;
|
||||
height: number;
|
||||
disableTooltip?: boolean;
|
||||
showTooltip?: boolean;
|
||||
timezone: string;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
canPinTooltip?: boolean;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
renderTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
interface TimeSeriesChartProps extends BaseChartProps {
|
||||
interface UPlotBasedChartProps {
|
||||
config: UPlotConfigBuilder;
|
||||
legendConfig: LegendConfig;
|
||||
data: uPlot.AlignedData;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
plotRef?: (plot: uPlot | null) => void;
|
||||
onDestroy?: (plot: uPlot) => void;
|
||||
children?: React.ReactNode;
|
||||
layoutChildren?: React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
export type ChartProps = TimeSeriesChartProps;
|
||||
export interface TimeSeriesChartProps
|
||||
extends BaseChartProps,
|
||||
UPlotBasedChartProps {
|
||||
legendConfig: LegendConfig;
|
||||
}
|
||||
|
||||
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
|
||||
legendConfig: LegendConfig;
|
||||
isStackedBarChart?: boolean;
|
||||
}
|
||||
|
||||
export type ChartProps = TimeSeriesChartProps | BarChartProps;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { LineInterpolation } from 'lib/uPlotV2/config/types';
|
||||
import { ContextMenu } from 'periscope/components/ContextMenu';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
@@ -73,55 +72,28 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
}, [queryResponse?.data?.payload]);
|
||||
|
||||
const config = useMemo(() => {
|
||||
const tzDate = (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
|
||||
|
||||
return prepareUPlotConfig({
|
||||
widgetId: widget.id || '',
|
||||
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps,
|
||||
tzDate,
|
||||
minTimeScale: minTimeScale,
|
||||
maxTimeScale: maxTimeScale,
|
||||
isLogScale: widget?.isLogScale ?? false,
|
||||
thresholds: {
|
||||
scaleKey: 'y',
|
||||
thresholds: (widget.thresholds || []).map((threshold) => ({
|
||||
thresholdValue: threshold.thresholdValue ?? 0,
|
||||
thresholdColor: threshold.thresholdColor,
|
||||
thresholdUnit: threshold.thresholdUnit,
|
||||
thresholdLabel: threshold.thresholdLabel,
|
||||
})),
|
||||
yAxisUnit: widget.yAxisUnit,
|
||||
},
|
||||
yAxisUnit: widget.yAxisUnit || '',
|
||||
softMin: widget.softMin === undefined ? null : widget.softMin,
|
||||
softMax: widget.softMax === undefined ? null : widget.softMax,
|
||||
spanGaps: false,
|
||||
colorMapping: widget.customLegendColors ?? {},
|
||||
lineInterpolation: LineInterpolation.Spline,
|
||||
widget,
|
||||
isDarkMode,
|
||||
currentQuery: widget.query,
|
||||
onClick: clickHandlerWithContextMenu,
|
||||
onDragSelect,
|
||||
currentQuery: widget.query,
|
||||
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps,
|
||||
timezone,
|
||||
panelMode,
|
||||
minTimeScale: minTimeScale,
|
||||
maxTimeScale: maxTimeScale,
|
||||
});
|
||||
}, [
|
||||
widget.id,
|
||||
maxTimeScale,
|
||||
minTimeScale,
|
||||
timezone.value,
|
||||
widget.customLegendColors,
|
||||
widget.isLogScale,
|
||||
widget.softMax,
|
||||
widget.softMin,
|
||||
widget,
|
||||
isDarkMode,
|
||||
queryResponse?.data?.payload,
|
||||
widget.query,
|
||||
widget.thresholds,
|
||||
widget.yAxisUnit,
|
||||
panelMode,
|
||||
clickHandlerWithContextMenu,
|
||||
onDragSelect,
|
||||
queryResponse?.data?.payload,
|
||||
panelMode,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
timezone,
|
||||
]);
|
||||
|
||||
const layoutChildren = useMemo(() => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
fillMissingXAxisTimestamps,
|
||||
@@ -5,23 +6,20 @@ import {
|
||||
} from 'container/DashboardContainer/visualization/panels/utils';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import onClickPlugin, {
|
||||
OnClickPluginOpts,
|
||||
} from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import {
|
||||
DistributionType,
|
||||
DrawStyle,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
SelectionPreferencesSource,
|
||||
VisibilityMode,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { PanelMode } from '../types';
|
||||
import { buildBaseConfig } from '../utils/baseConfigBuilder';
|
||||
|
||||
export const prepareChartData = (
|
||||
apiResponse: MetricRangePayloadProps,
|
||||
@@ -34,112 +32,39 @@ export const prepareChartData = (
|
||||
};
|
||||
|
||||
export const prepareUPlotConfig = ({
|
||||
widgetId,
|
||||
apiResponse,
|
||||
tzDate,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
isLogScale,
|
||||
thresholds,
|
||||
softMin,
|
||||
softMax,
|
||||
spanGaps,
|
||||
colorMapping,
|
||||
lineInterpolation,
|
||||
widget,
|
||||
isDarkMode,
|
||||
currentQuery,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
yAxisUnit,
|
||||
onDragSelect,
|
||||
apiResponse,
|
||||
timezone,
|
||||
panelMode,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
}: {
|
||||
widgetId: string;
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
tzDate: uPlot.LocalDateFromUnix;
|
||||
minTimeScale: number | undefined;
|
||||
maxTimeScale: number | undefined;
|
||||
isLogScale: boolean;
|
||||
softMin: number | null;
|
||||
softMax: number | null;
|
||||
spanGaps: boolean;
|
||||
colorMapping: Record<string, string>;
|
||||
lineInterpolation: LineInterpolation;
|
||||
widget: Widgets;
|
||||
isDarkMode: boolean;
|
||||
thresholds: ThresholdsDrawHookOptions;
|
||||
currentQuery: Query;
|
||||
yAxisUnit: string;
|
||||
onClick: OnClickPluginOpts['onClick'];
|
||||
onDragSelect: (startTime: number, endTime: number) => void;
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
}): UPlotConfigBuilder => {
|
||||
const builder = new UPlotConfigBuilder({
|
||||
const builder = buildBaseConfig({
|
||||
widget,
|
||||
isDarkMode,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
widgetId,
|
||||
tzDate,
|
||||
shouldSaveSelectionPreference: panelMode === PanelMode.DASHBOARD_VIEW,
|
||||
selectionPreferencesSource: [
|
||||
PanelMode.DASHBOARD_VIEW,
|
||||
PanelMode.STANDALONE_VIEW,
|
||||
].includes(panelMode)
|
||||
? SelectionPreferencesSource.LOCAL_STORAGE
|
||||
: SelectionPreferencesSource.IN_MEMORY,
|
||||
});
|
||||
|
||||
// X scale – time axis
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min: minTimeScale,
|
||||
max: maxTimeScale,
|
||||
logBase: isLogScale ? 10 : undefined,
|
||||
distribution: isLogScale
|
||||
? DistributionType.Logarithmic
|
||||
: DistributionType.Linear,
|
||||
});
|
||||
|
||||
// Y scale – value axis, driven primarily by softMin/softMax and data
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
softMin: softMin ?? undefined,
|
||||
softMax: softMax ?? undefined,
|
||||
thresholds,
|
||||
logBase: isLogScale ? 10 : undefined,
|
||||
distribution: isLogScale
|
||||
? DistributionType.Logarithmic
|
||||
: DistributionType.Linear,
|
||||
});
|
||||
|
||||
builder.addThresholds(thresholds);
|
||||
|
||||
if (typeof onClick === 'function') {
|
||||
builder.addPlugin(
|
||||
onClickPlugin({
|
||||
onClick,
|
||||
apiResponse,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
show: true,
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
isLogScale: false,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
show: true,
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
isLogScale: false,
|
||||
yAxisUnit,
|
||||
apiResponse,
|
||||
timezone,
|
||||
panelMode,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
});
|
||||
|
||||
apiResponse.data?.result?.forEach((series) => {
|
||||
@@ -157,14 +82,16 @@ export const prepareUPlotConfig = ({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Line,
|
||||
label: label,
|
||||
colorMapping,
|
||||
spanGaps,
|
||||
colorMapping: widget.customLegendColors ?? {},
|
||||
spanGaps: true,
|
||||
lineStyle: LineStyle.Solid,
|
||||
lineInterpolation,
|
||||
lineInterpolation: LineInterpolation.Spline,
|
||||
showPoints: VisibilityMode.Never,
|
||||
pointSize: 5,
|
||||
isDarkMode,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
});
|
||||
|
||||
return builder;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,11 @@ export interface GraphVisibilityState {
|
||||
dataIndex: SeriesVisibilityItem[];
|
||||
}
|
||||
|
||||
export interface SeriesVisibilityState {
|
||||
labels: string[];
|
||||
visibility: boolean[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Context in which a panel is rendered. Used to vary behavior (e.g. persistence,
|
||||
* interactions) per context.
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import type { GraphVisibilityState } from '../../types';
|
||||
import {
|
||||
getStoredSeriesVisibility,
|
||||
updateSeriesVisibilityToLocalStorage,
|
||||
} from '../legendVisibilityUtils';
|
||||
|
||||
describe('legendVisibilityUtils', () => {
|
||||
const storageKey = LOCALSTORAGE.GRAPH_VISIBILITY_STATES;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
jest.spyOn(window.localStorage.__proto__, 'getItem');
|
||||
jest.spyOn(window.localStorage.__proto__, 'setItem');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getStoredSeriesVisibility', () => {
|
||||
it('returns null when there is no stored visibility state', () => {
|
||||
const result = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(localStorage.getItem).toHaveBeenCalledWith(storageKey);
|
||||
});
|
||||
|
||||
it('returns null when widget has no stored dataIndex', () => {
|
||||
const stored: GraphVisibilityState[] = [
|
||||
{
|
||||
name: 'widget-1',
|
||||
dataIndex: [],
|
||||
},
|
||||
];
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(stored));
|
||||
|
||||
const result = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns visibility array by index when widget state exists', () => {
|
||||
const stored: GraphVisibilityState[] = [
|
||||
{
|
||||
name: 'widget-1',
|
||||
dataIndex: [
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'widget-2',
|
||||
dataIndex: [{ label: 'Errors', show: true }],
|
||||
},
|
||||
];
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(stored));
|
||||
|
||||
const result = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toEqual({
|
||||
labels: ['CPU', 'Memory'],
|
||||
visibility: [true, false],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns visibility by index including duplicate labels', () => {
|
||||
const stored: GraphVisibilityState[] = [
|
||||
{
|
||||
name: 'widget-1',
|
||||
dataIndex: [
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
{ label: 'Memory', show: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(stored));
|
||||
|
||||
const result = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toEqual({
|
||||
labels: ['CPU', 'CPU', 'Memory'],
|
||||
visibility: [true, false, false],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null on malformed JSON in localStorage', () => {
|
||||
localStorage.setItem(storageKey, '{invalid-json');
|
||||
|
||||
const result = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when widget id is not found', () => {
|
||||
const stored: GraphVisibilityState[] = [
|
||||
{
|
||||
name: 'another-widget',
|
||||
dataIndex: [{ label: 'CPU', show: true }],
|
||||
},
|
||||
];
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(stored));
|
||||
|
||||
const result = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSeriesVisibilityToLocalStorage', () => {
|
||||
it('creates new visibility state when none exists', () => {
|
||||
const seriesVisibility = [
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
];
|
||||
|
||||
updateSeriesVisibilityToLocalStorage('widget-1', seriesVisibility);
|
||||
|
||||
const stored = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).toEqual({
|
||||
labels: ['CPU', 'Memory'],
|
||||
visibility: [true, false],
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a new widget entry when other widgets already exist', () => {
|
||||
const existing: GraphVisibilityState[] = [
|
||||
{
|
||||
name: 'widget-existing',
|
||||
dataIndex: [{ label: 'Errors', show: true }],
|
||||
},
|
||||
];
|
||||
localStorage.setItem(storageKey, JSON.stringify(existing));
|
||||
|
||||
const newVisibility = [{ label: 'CPU', show: false }];
|
||||
|
||||
updateSeriesVisibilityToLocalStorage('widget-new', newVisibility);
|
||||
|
||||
const stored = getStoredSeriesVisibility('widget-new');
|
||||
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).toEqual({ labels: ['CPU'], visibility: [false] });
|
||||
});
|
||||
|
||||
it('updates existing widget visibility when entry already exists', () => {
|
||||
const initialVisibility: GraphVisibilityState[] = [
|
||||
{
|
||||
name: 'widget-1',
|
||||
dataIndex: [
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(initialVisibility));
|
||||
|
||||
const updatedVisibility = [
|
||||
{ label: 'CPU', show: false },
|
||||
{ label: 'Memory', show: true },
|
||||
];
|
||||
|
||||
updateSeriesVisibilityToLocalStorage('widget-1', updatedVisibility);
|
||||
|
||||
const stored = getStoredSeriesVisibility('widget-1');
|
||||
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).toEqual({
|
||||
labels: ['CPU', 'Memory'],
|
||||
visibility: [false, true],
|
||||
});
|
||||
});
|
||||
|
||||
it('silently handles malformed existing JSON without throwing', () => {
|
||||
localStorage.setItem(storageKey, '{invalid-json');
|
||||
|
||||
expect(() =>
|
||||
updateSeriesVisibilityToLocalStorage('widget-1', [
|
||||
{ label: 'CPU', show: true },
|
||||
]),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('when existing JSON is malformed, overwrites with valid data for the widget', () => {
|
||||
localStorage.setItem(storageKey, '{invalid-json');
|
||||
|
||||
updateSeriesVisibilityToLocalStorage('widget-1', [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
]);
|
||||
|
||||
const stored = getStoredSeriesVisibility('widget-1');
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored).toEqual({
|
||||
labels: ['x-axis', 'CPU'],
|
||||
visibility: [true, false],
|
||||
});
|
||||
const expected = [
|
||||
{
|
||||
name: 'widget-1',
|
||||
dataIndex: [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
storageKey,
|
||||
JSON.stringify(expected),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves other widgets when updating one widget', () => {
|
||||
const existing: GraphVisibilityState[] = [
|
||||
{ name: 'widget-a', dataIndex: [{ label: 'A', show: true }] },
|
||||
{ name: 'widget-b', dataIndex: [{ label: 'B', show: false }] },
|
||||
];
|
||||
localStorage.setItem(storageKey, JSON.stringify(existing));
|
||||
|
||||
updateSeriesVisibilityToLocalStorage('widget-b', [
|
||||
{ label: 'B', show: true },
|
||||
]);
|
||||
|
||||
expect(getStoredSeriesVisibility('widget-a')).toEqual({
|
||||
labels: ['A'],
|
||||
visibility: [true],
|
||||
});
|
||||
expect(getStoredSeriesVisibility('widget-b')).toEqual({
|
||||
labels: ['B'],
|
||||
visibility: [true],
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setItem with storage key and stringified visibility states', () => {
|
||||
updateSeriesVisibilityToLocalStorage('widget-1', [
|
||||
{ label: 'CPU', show: true },
|
||||
]);
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
storageKey,
|
||||
expect.any(String),
|
||||
);
|
||||
const [_, value] = (localStorage.setItem as jest.Mock).mock.calls[0];
|
||||
expect((): void => JSON.parse(value)).not.toThrow();
|
||||
expect(JSON.parse(value)).toEqual([
|
||||
{ name: 'widget-1', dataIndex: [{ label: 'CPU', show: true }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('stores empty dataIndex when seriesVisibility is empty', () => {
|
||||
updateSeriesVisibilityToLocalStorage('widget-1', []);
|
||||
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
expect(raw).not.toBeNull();
|
||||
const parsed = JSON.parse(raw ?? '[]');
|
||||
expect(parsed).toEqual([{ name: 'widget-1', dataIndex: [] }]);
|
||||
expect(getStoredSeriesVisibility('widget-1')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import onClickPlugin, {
|
||||
OnClickPluginOpts,
|
||||
} from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import {
|
||||
DistributionType,
|
||||
SelectionPreferencesSource,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { PanelMode } from '../types';
|
||||
|
||||
export interface BaseConfigBuilderProps {
|
||||
widget: Widgets;
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
isDarkMode: boolean;
|
||||
onClick: OnClickPluginOpts['onClick'];
|
||||
onDragSelect: (startTime: number, endTime: number) => void;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
panelType: PANEL_TYPES;
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
}
|
||||
|
||||
export function buildBaseConfig({
|
||||
widget,
|
||||
isDarkMode,
|
||||
onClick,
|
||||
onDragSelect,
|
||||
apiResponse,
|
||||
timezone,
|
||||
panelMode,
|
||||
panelType,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
}: BaseConfigBuilderProps): UPlotConfigBuilder {
|
||||
const tzDate = (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
onDragSelect,
|
||||
widgetId: widget.id,
|
||||
tzDate,
|
||||
shouldSaveSelectionPreference: panelMode === PanelMode.DASHBOARD_VIEW,
|
||||
selectionPreferencesSource: [
|
||||
PanelMode.DASHBOARD_VIEW,
|
||||
PanelMode.STANDALONE_VIEW,
|
||||
].includes(panelMode)
|
||||
? SelectionPreferencesSource.LOCAL_STORAGE
|
||||
: SelectionPreferencesSource.IN_MEMORY,
|
||||
});
|
||||
|
||||
const thresholdOptions: ThresholdsDrawHookOptions = {
|
||||
scaleKey: 'y',
|
||||
thresholds: (widget.thresholds || []).map((threshold) => ({
|
||||
thresholdValue: threshold.thresholdValue ?? 0,
|
||||
thresholdColor: threshold.thresholdColor,
|
||||
thresholdUnit: threshold.thresholdUnit,
|
||||
thresholdLabel: threshold.thresholdLabel,
|
||||
})),
|
||||
yAxisUnit: widget.yAxisUnit,
|
||||
};
|
||||
|
||||
builder.addThresholds(thresholdOptions);
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min: minTimeScale,
|
||||
max: maxTimeScale,
|
||||
logBase: widget.isLogScale ? 10 : undefined,
|
||||
distribution: widget.isLogScale
|
||||
? DistributionType.Logarithmic
|
||||
: DistributionType.Linear,
|
||||
});
|
||||
|
||||
// Y scale – value axis, driven primarily by softMin/softMax and data
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
softMin: widget.softMin ?? undefined,
|
||||
softMax: widget.softMax ?? undefined,
|
||||
thresholds: thresholdOptions,
|
||||
logBase: widget.isLogScale ? 10 : undefined,
|
||||
distribution: widget.isLogScale
|
||||
? DistributionType.Logarithmic
|
||||
: DistributionType.Linear,
|
||||
});
|
||||
|
||||
if (typeof onClick === 'function') {
|
||||
builder.addPlugin(
|
||||
onClickPlugin({
|
||||
onClick,
|
||||
apiResponse,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
show: true,
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
isLogScale: widget.isLogScale,
|
||||
panelType,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
show: true,
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
isLogScale: widget.isLogScale,
|
||||
yAxisUnit: widget.yAxisUnit,
|
||||
panelType,
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { GraphVisibilityState, SeriesVisibilityItem } from '../types';
|
||||
import {
|
||||
GraphVisibilityState,
|
||||
SeriesVisibilityItem,
|
||||
SeriesVisibilityState,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Retrieves the visibility map for a specific widget from localStorage
|
||||
* Retrieves the stored series visibility for a specific widget from localStorage by index.
|
||||
* Index 0 is the x-axis (time); indices 1, 2, ... are data series (same order as uPlot plot.series).
|
||||
* @param widgetId - The unique identifier of the widget
|
||||
* @returns A Map of series labels to their visibility state, or null if not found
|
||||
* @returns visibility[i] = show state for series at index i, or null if not found
|
||||
*/
|
||||
export function getStoredSeriesVisibility(
|
||||
widgetId: string,
|
||||
): Map<string, boolean> | null {
|
||||
): SeriesVisibilityState | null {
|
||||
try {
|
||||
const storedData = localStorage.getItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
|
||||
|
||||
@@ -24,8 +29,15 @@ export function getStoredSeriesVisibility(
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Map(widgetState.dataIndex.map((item) => [item.label, item.show]));
|
||||
} catch {
|
||||
return {
|
||||
labels: widgetState.dataIndex.map((item) => item.label),
|
||||
visibility: widgetState.dataIndex.map((item) => item.show),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
// If the stored data is malformed, remove it
|
||||
localStorage.removeItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
|
||||
}
|
||||
// Silently handle parsing errors - fall back to default visibility
|
||||
return null;
|
||||
}
|
||||
@@ -35,40 +47,31 @@ export function updateSeriesVisibilityToLocalStorage(
|
||||
widgetId: string,
|
||||
seriesVisibility: SeriesVisibilityItem[],
|
||||
): void {
|
||||
let visibilityStates: GraphVisibilityState[] = [];
|
||||
try {
|
||||
const storedData = localStorage.getItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
|
||||
|
||||
let visibilityStates: GraphVisibilityState[];
|
||||
|
||||
if (!storedData) {
|
||||
visibilityStates = [
|
||||
{
|
||||
name: widgetId,
|
||||
dataIndex: seriesVisibility,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
visibilityStates = JSON.parse(storedData);
|
||||
visibilityStates = JSON.parse(storedData || '[]');
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
visibilityStates = [];
|
||||
}
|
||||
const widgetState = visibilityStates.find((state) => state.name === widgetId);
|
||||
|
||||
if (!widgetState) {
|
||||
visibilityStates = [
|
||||
...visibilityStates,
|
||||
{
|
||||
name: widgetId,
|
||||
dataIndex: seriesVisibility,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
widgetState.dataIndex = seriesVisibility;
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.GRAPH_VISIBILITY_STATES,
|
||||
JSON.stringify(visibilityStates),
|
||||
);
|
||||
} catch {
|
||||
// Silently handle parsing errors - fall back to default visibility
|
||||
}
|
||||
const widgetState = visibilityStates.find((state) => state.name === widgetId);
|
||||
|
||||
if (widgetState) {
|
||||
widgetState.dataIndex = seriesVisibility;
|
||||
} else {
|
||||
visibilityStates = [
|
||||
...visibilityStates,
|
||||
{
|
||||
name: widgetId,
|
||||
dataIndex: seriesVisibility,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.GRAPH_VISIBILITY_STATES,
|
||||
JSON.stringify(visibilityStates),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
.forgot-password-title {
|
||||
font-family: var(--label-large-600-font-family);
|
||||
font-size: var(--label-large-600-font-size);
|
||||
font-weight: var(--label-large-600-font-weight);
|
||||
letter-spacing: var(--label-large-600-letter-spacing);
|
||||
line-height: 1.45;
|
||||
color: var(--l1-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.forgot-password-description {
|
||||
font-family: var(--paragraph-base-400-font-family);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--paragraph-base-400-font-weight);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--l2-foreground);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
max-width: 317px;
|
||||
}
|
||||
|
||||
.forgot-password-form {
|
||||
width: 100%;
|
||||
|
||||
// Label styling
|
||||
.forgot-password-label {
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.065px;
|
||||
color: var(--l1-foreground);
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
|
||||
.lightMode & {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
// Parent container for fields
|
||||
.forgot-password-field {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&.ant-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 0px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.forgot-password-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
|
||||
> .forgot-password-back-button,
|
||||
> .login-submit-btn {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.forgot-password-back-button {
|
||||
height: 32px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 2px;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l3-border);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--l3-border);
|
||||
border-color: var(--l3-border);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
41
frontend/src/container/ForgotPassword/SuccessScreen.tsx
Normal file
41
frontend/src/container/ForgotPassword/SuccessScreen.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Button } from '@signozhq/button';
|
||||
import { ArrowLeft, Mail } from '@signozhq/icons';
|
||||
|
||||
interface SuccessScreenProps {
|
||||
onBackToLogin: () => void;
|
||||
}
|
||||
|
||||
function SuccessScreen({ onBackToLogin }: SuccessScreenProps): JSX.Element {
|
||||
return (
|
||||
<div className="login-form-container">
|
||||
<div className="forgot-password-form">
|
||||
<div className="login-form-header">
|
||||
<div className="login-form-emoji">
|
||||
<Mail size={32} />
|
||||
</div>
|
||||
<h4 className="forgot-password-title">Check your email</h4>
|
||||
<p className="forgot-password-description">
|
||||
We've sent a password reset link to your email. Please check your
|
||||
inbox and follow the instructions to reset your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="login-form-actions forgot-password-actions">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
type="button"
|
||||
data-testid="back-to-login"
|
||||
className="login-submit-btn"
|
||||
onClick={onBackToLogin}
|
||||
prefixIcon={<ArrowLeft size={12} />}
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SuccessScreen;
|
||||
@@ -0,0 +1,402 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import {
|
||||
createErrorResponse,
|
||||
handleInternalServerError,
|
||||
rest,
|
||||
server,
|
||||
} from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { OrgSessionContext } from 'types/api/v2/sessions/context/get';
|
||||
|
||||
import ForgotPassword, { ForgotPasswordRouteState } from '../index';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
push: jest.fn(),
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mockHistoryPush = history.push as jest.MockedFunction<
|
||||
typeof history.push
|
||||
>;
|
||||
|
||||
const FORGOT_PASSWORD_ENDPOINT = '*/api/v2/factor_password/forgot';
|
||||
|
||||
// Mock data
|
||||
const mockSingleOrg: OrgSessionContext[] = [
|
||||
{
|
||||
id: 'org-1',
|
||||
name: 'Test Organization',
|
||||
authNSupport: {
|
||||
password: [{ provider: 'email_password' }],
|
||||
callback: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockMultipleOrgs: OrgSessionContext[] = [
|
||||
{
|
||||
id: 'org-1',
|
||||
name: 'Organization One',
|
||||
authNSupport: {
|
||||
password: [{ provider: 'email_password' }],
|
||||
callback: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'org-2',
|
||||
name: 'Organization Two',
|
||||
authNSupport: {
|
||||
password: [{ provider: 'email_password' }],
|
||||
callback: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const TEST_EMAIL = 'jest.test@signoz.io';
|
||||
|
||||
const defaultProps: ForgotPasswordRouteState = {
|
||||
email: TEST_EMAIL,
|
||||
orgs: mockSingleOrg,
|
||||
};
|
||||
|
||||
const multiOrgProps: ForgotPasswordRouteState = {
|
||||
email: TEST_EMAIL,
|
||||
orgs: mockMultipleOrgs,
|
||||
};
|
||||
|
||||
describe('ForgotPassword Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('Initial Render', () => {
|
||||
it('renders forgot password form with all required elements', () => {
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/forgot your password\?/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/send a reset link to your inbox/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('email')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('forgot-password-submit')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('forgot-password-back')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills email from props', () => {
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const emailInput = screen.getByTestId('email');
|
||||
expect(emailInput).toHaveValue(TEST_EMAIL);
|
||||
});
|
||||
|
||||
it('disables email input field', () => {
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const emailInput = screen.getByTestId('email');
|
||||
expect(emailInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not show organization dropdown for single org', () => {
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByTestId('orgId')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Organization Name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables submit button when email is provided with single org', () => {
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Organizations', () => {
|
||||
it('shows organization dropdown when multiple orgs exist', () => {
|
||||
render(<ForgotPassword {...multiOrgProps} />);
|
||||
|
||||
expect(screen.getByTestId('orgId')).toBeInTheDocument();
|
||||
expect(screen.getByText('Organization Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables submit button when org is not selected', () => {
|
||||
const propsWithoutOrgId: ForgotPasswordRouteState = {
|
||||
email: TEST_EMAIL,
|
||||
orgs: mockMultipleOrgs,
|
||||
};
|
||||
|
||||
render(<ForgotPassword {...propsWithoutOrgId} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables submit button after selecting an organization', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<ForgotPassword {...multiOrgProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Click on the dropdown to reveal the options
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
await user.click(screen.getByText('Organization One'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('pre-selects organization when orgId is provided', () => {
|
||||
const propsWithOrgId: ForgotPasswordRouteState = {
|
||||
email: TEST_EMAIL,
|
||||
orgId: 'org-1',
|
||||
orgs: mockMultipleOrgs,
|
||||
};
|
||||
|
||||
render(<ForgotPassword {...propsWithOrgId} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission - Success', () => {
|
||||
it('successfully submits forgot password request and shows success screen', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(await screen.findByText(/check your email/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/we've sent a password reset link/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows back to login button on success screen', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(await screen.findByTestId('back-to-login')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to login when clicking back to login on success screen', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(await screen.findByTestId('back-to-login')).toBeInTheDocument();
|
||||
|
||||
const backToLoginButton = screen.getByTestId('back-to-login');
|
||||
await user.click(backToLoginButton);
|
||||
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission - Error Handling', () => {
|
||||
it('displays error message when forgot password API fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(
|
||||
FORGOT_PASSWORD_ENDPOINT,
|
||||
createErrorResponse(400, 'USER_NOT_FOUND', 'User not found'),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(await screen.findByText(/user not found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays error message when API returns server error', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(rest.post(FORGOT_PASSWORD_ENDPOINT, handleInternalServerError));
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/internal server error occurred/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears error message on new submission attempt', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let requestCount = 0;
|
||||
|
||||
server.use(
|
||||
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return res(
|
||||
ctx.status(400),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'USER_NOT_FOUND',
|
||||
message: 'User not found',
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(await screen.findByText(/user not found/i)).toBeInTheDocument();
|
||||
|
||||
// Click submit again
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/user not found/i)).not.toBeInTheDocument();
|
||||
});
|
||||
expect(await screen.findByText(/check your email/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('redirects to login when clicking back button on form', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const backButton = screen.getByTestId('forgot-password-back');
|
||||
await user.click(backButton);
|
||||
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('shows loading state during API call', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.delay(100), ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
await user.click(submitButton);
|
||||
|
||||
// Button should show loading state
|
||||
expect(await screen.findByText(/sending\.\.\./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables submit button during loading', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.post(FORGOT_PASSWORD_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.delay(100), ctx.status(200), ctx.json({ status: 'success' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ForgotPassword {...defaultProps} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty email gracefully', () => {
|
||||
const propsWithEmptyEmail: ForgotPasswordRouteState = {
|
||||
email: '',
|
||||
orgs: mockSingleOrg,
|
||||
};
|
||||
|
||||
render(<ForgotPassword {...propsWithEmptyEmail} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('handles whitespace-only email', () => {
|
||||
const propsWithWhitespaceEmail: ForgotPasswordRouteState = {
|
||||
email: ' ',
|
||||
orgs: mockSingleOrg,
|
||||
};
|
||||
|
||||
render(<ForgotPassword {...propsWithWhitespaceEmail} />);
|
||||
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('handles empty orgs array by disabling submission', () => {
|
||||
const propsWithNoOrgs: ForgotPasswordRouteState = {
|
||||
email: TEST_EMAIL,
|
||||
orgs: [],
|
||||
};
|
||||
|
||||
render(<ForgotPassword {...propsWithNoOrgs} />);
|
||||
|
||||
// Should not show org dropdown
|
||||
expect(screen.queryByTestId('orgId')).not.toBeInTheDocument();
|
||||
// Submit should be disabled because no orgId can be determined
|
||||
const submitButton = screen.getByTestId('forgot-password-submit');
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
217
frontend/src/container/ForgotPassword/index.tsx
Normal file
217
frontend/src/container/ForgotPassword/index.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { ArrowLeft, ArrowRight } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Form, Select } from 'antd';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { useForgotPassword } from 'api/generated/services/users';
|
||||
import { AxiosError } from 'axios';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { OrgSessionContext } from 'types/api/v2/sessions/context/get';
|
||||
|
||||
import SuccessScreen from './SuccessScreen';
|
||||
|
||||
import './ForgotPassword.styles.scss';
|
||||
import 'container/Login/Login.styles.scss';
|
||||
|
||||
type FormValues = {
|
||||
email: string;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export type ForgotPasswordRouteState = {
|
||||
email: string;
|
||||
orgId?: string;
|
||||
orgs: OrgSessionContext[];
|
||||
};
|
||||
|
||||
function ForgotPassword({
|
||||
email,
|
||||
orgId,
|
||||
orgs,
|
||||
}: ForgotPasswordRouteState): JSX.Element {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const {
|
||||
mutate: forgotPasswordMutate,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
error: mutationError,
|
||||
} = useForgotPassword();
|
||||
|
||||
const errorMessage = useMemo(() => {
|
||||
if (!mutationError) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
ErrorResponseHandlerV2(mutationError as AxiosError<ErrorV2Resp>);
|
||||
} catch (apiError) {
|
||||
return apiError as APIError;
|
||||
}
|
||||
}, [mutationError]);
|
||||
|
||||
const initialOrgId = useMemo((): string | undefined => {
|
||||
if (orgId) {
|
||||
return orgId;
|
||||
}
|
||||
|
||||
if (orgs.length === 1) {
|
||||
return orgs[0]?.id;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [orgId, orgs]);
|
||||
|
||||
const watchedEmail = Form.useWatch('email', form);
|
||||
const selectedOrgId = Form.useWatch('orgId', form);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
email,
|
||||
orgId: initialOrgId,
|
||||
});
|
||||
}, [email, form, initialOrgId]);
|
||||
|
||||
const hasMultipleOrgs = orgs.length > 1;
|
||||
|
||||
const isSubmitEnabled = useMemo((): boolean => {
|
||||
if (isLoading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!watchedEmail?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure we have an orgId (either selected from dropdown or the initial one)
|
||||
const currentOrgId = hasMultipleOrgs ? selectedOrgId : initialOrgId;
|
||||
return Boolean(currentOrgId);
|
||||
}, [watchedEmail, selectedOrgId, isLoading, initialOrgId, hasMultipleOrgs]);
|
||||
|
||||
const handleSubmit = useCallback((): void => {
|
||||
const values = form.getFieldsValue();
|
||||
const currentOrgId = hasMultipleOrgs ? values.orgId : initialOrgId;
|
||||
|
||||
if (!currentOrgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the forgot password API
|
||||
forgotPasswordMutate({
|
||||
data: {
|
||||
email: values.email,
|
||||
orgId: currentOrgId,
|
||||
frontendBaseURL: window.location.origin,
|
||||
},
|
||||
});
|
||||
}, [form, forgotPasswordMutate, initialOrgId, hasMultipleOrgs]);
|
||||
|
||||
const handleBackToLogin = useCallback((): void => {
|
||||
history.push(ROUTES.LOGIN);
|
||||
}, []);
|
||||
|
||||
// Success screen
|
||||
if (isSuccess) {
|
||||
return <SuccessScreen onBackToLogin={handleBackToLogin} />;
|
||||
}
|
||||
|
||||
// Form screen
|
||||
return (
|
||||
<div className="login-form-container">
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleSubmit}
|
||||
className="forgot-password-form"
|
||||
initialValues={{
|
||||
email,
|
||||
orgId: initialOrgId,
|
||||
}}
|
||||
>
|
||||
<div className="login-form-header">
|
||||
<div className="login-form-emoji">
|
||||
<img src="/svgs/tv.svg" alt="TV" width="32" height="32" />
|
||||
</div>
|
||||
<h4 className="forgot-password-title">Forgot your password?</h4>
|
||||
<p className="forgot-password-description">
|
||||
Send a reset link to your inbox and get back to monitoring.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="login-form-card">
|
||||
<div className="forgot-password-field">
|
||||
<label className="forgot-password-label" htmlFor="forgotPasswordEmail">
|
||||
Email address
|
||||
</label>
|
||||
<Form.Item name="email">
|
||||
<Input
|
||||
type="email"
|
||||
id="forgotPasswordEmail"
|
||||
data-testid="email"
|
||||
required
|
||||
disabled
|
||||
className="login-form-input"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{hasMultipleOrgs && (
|
||||
<div className="forgot-password-field">
|
||||
<label className="forgot-password-label" htmlFor="orgId">
|
||||
Organization Name
|
||||
</label>
|
||||
<Form.Item
|
||||
name="orgId"
|
||||
rules={[{ required: true, message: 'Please select your organization' }]}
|
||||
>
|
||||
<Select
|
||||
id="orgId"
|
||||
data-testid="orgId"
|
||||
className="login-form-input login-form-select-no-border"
|
||||
placeholder="Select your organization"
|
||||
options={orgs.map((org) => ({
|
||||
value: org.id,
|
||||
label: org.name || 'default',
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorMessage && <AuthError error={errorMessage} />}
|
||||
|
||||
<div className="login-form-actions forgot-password-actions">
|
||||
<Button
|
||||
variant="solid"
|
||||
type="button"
|
||||
data-testid="forgot-password-back"
|
||||
className="forgot-password-back-button"
|
||||
onClick={handleBackToLogin}
|
||||
prefixIcon={<ArrowLeft size={12} />}
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={!isSubmitEnabled}
|
||||
loading={isLoading}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
type="submit"
|
||||
data-testid="forgot-password-submit"
|
||||
className="login-submit-btn"
|
||||
suffixIcon={<ArrowRight size={12} />}
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send reset link'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ForgotPassword;
|
||||
@@ -2,7 +2,6 @@
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import { ChangeEvent, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
@@ -27,12 +26,20 @@ import {
|
||||
} from 'antd';
|
||||
import { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import { CollapseProps } from 'antd/lib';
|
||||
import createIngestionKeyApi from 'api/IngestionKeys/createIngestionKey';
|
||||
import deleteIngestionKey from 'api/IngestionKeys/deleteIngestionKey';
|
||||
import createLimitForIngestionKeyApi from 'api/IngestionKeys/limits/createLimitsForKey';
|
||||
import deleteLimitsForIngestionKey from 'api/IngestionKeys/limits/deleteLimitsForIngestionKey';
|
||||
import updateLimitForIngestionKeyApi from 'api/IngestionKeys/limits/updateLimitsForIngestionKey';
|
||||
import updateIngestionKey from 'api/IngestionKeys/updateIngestionKey';
|
||||
import {
|
||||
useCreateIngestionKey,
|
||||
useCreateIngestionKeyLimit,
|
||||
useDeleteIngestionKey,
|
||||
useDeleteIngestionKeyLimit,
|
||||
useGetIngestionKeys,
|
||||
useSearchIngestionKeys,
|
||||
useUpdateIngestionKey,
|
||||
useUpdateIngestionKeyLimit,
|
||||
} from 'api/generated/services/gateway';
|
||||
import {
|
||||
GatewaytypesIngestionKeyDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import Tags from 'components/Tags/Tags';
|
||||
@@ -44,7 +51,6 @@ import ROUTES from 'constants/routes';
|
||||
import { INITIAL_ALERT_THRESHOLD_STATE } from 'container/CreateAlertV2/context/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { useGetGlobalConfig } from 'hooks/globalConfig/useGetGlobalConfig';
|
||||
import { useGetAllIngestionsKeys } from 'hooks/IngestionKeys/useGetAllIngestionKeys';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { cloneDeep, isNil, isUndefined } from 'lodash-es';
|
||||
@@ -66,16 +72,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { ErrorResponse } from 'types/api';
|
||||
import {
|
||||
AddLimitProps,
|
||||
LimitProps,
|
||||
UpdateLimitProps,
|
||||
} from 'types/api/ingestionKeys/limits/types';
|
||||
import {
|
||||
IngestionKeyProps,
|
||||
PaginationProps,
|
||||
} from 'types/api/ingestionKeys/types';
|
||||
import { PaginationProps } from 'types/api/ingestionKeys/types';
|
||||
import { MeterAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getDaysUntilExpiry } from 'utils/timeUtils';
|
||||
@@ -86,6 +88,10 @@ const { Option } = Select;
|
||||
|
||||
const BYTES = 1073741824;
|
||||
|
||||
const INITIAL_PAGE_SIZE = 10;
|
||||
const SEARCH_PAGE_SIZE = 100;
|
||||
const FIRST_PAGE = 1;
|
||||
|
||||
const COUNT_MULTIPLIER = {
|
||||
thousand: 1000,
|
||||
million: 1000000,
|
||||
@@ -111,6 +117,8 @@ export const showErrorNotification = (
|
||||
): void => {
|
||||
notifications.error({
|
||||
message: err.message || SOMETHING_WENT_WRONG,
|
||||
description: (err as AxiosError<RenderErrorResponseDTO>).response?.data?.error
|
||||
?.message,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -163,15 +171,20 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
const [updatedTags, setUpdatedTags] = useState<string[]>([]);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isEditAddLimitOpen, setIsEditAddLimitOpen] = useState(false);
|
||||
const [activeAPIKey, setActiveAPIKey] = useState<IngestionKeyProps | null>();
|
||||
const [
|
||||
activeAPIKey,
|
||||
setActiveAPIKey,
|
||||
] = useState<GatewaytypesIngestionKeyDTO | null>(null);
|
||||
const [activeSignal, setActiveSignal] = useState<LimitProps | null>(null);
|
||||
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [dataSource, setDataSource] = useState<IngestionKeyProps[]>([]);
|
||||
const [dataSource, setDataSource] = useState<GatewaytypesIngestionKeyDTO[]>(
|
||||
[],
|
||||
);
|
||||
const [paginationParams, setPaginationParams] = useState<PaginationProps>({
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
page: FIRST_PAGE,
|
||||
per_page: INITIAL_PAGE_SIZE,
|
||||
});
|
||||
|
||||
const [totalIngestionKeys, setTotalIngestionKeys] = useState(0);
|
||||
@@ -186,7 +199,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
const [
|
||||
createLimitForIngestionKeyError,
|
||||
setCreateLimitForIngestionKeyError,
|
||||
] = useState<ErrorResponse | null>(null);
|
||||
] = useState<string | null>(null);
|
||||
|
||||
const [
|
||||
hasUpdateLimitForIngestionKeyError,
|
||||
@@ -196,7 +209,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
const [
|
||||
updateLimitForIngestionKeyError,
|
||||
setUpdateLimitForIngestionKeyError,
|
||||
] = useState<ErrorResponse | null>(null);
|
||||
] = useState<string | null>(null);
|
||||
|
||||
const { t } = useTranslation(['ingestionKeys']);
|
||||
|
||||
@@ -216,7 +229,11 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
handleFormReset();
|
||||
};
|
||||
|
||||
const showDeleteModal = (apiKey: IngestionKeyProps): void => {
|
||||
const showDeleteModal = (apiKey: GatewaytypesIngestionKeyDTO): void => {
|
||||
setHasCreateLimitForIngestionKeyError(false);
|
||||
setCreateLimitForIngestionKeyError(null);
|
||||
setHasUpdateLimitForIngestionKeyError(false);
|
||||
setUpdateLimitForIngestionKeyError(null);
|
||||
setActiveAPIKey(apiKey);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
@@ -233,7 +250,11 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
setIsAddModalOpen(false);
|
||||
};
|
||||
|
||||
const showEditModal = (apiKey: IngestionKeyProps): void => {
|
||||
const showEditModal = (apiKey: GatewaytypesIngestionKeyDTO): void => {
|
||||
setHasCreateLimitForIngestionKeyError(false);
|
||||
setCreateLimitForIngestionKeyError(null);
|
||||
setHasUpdateLimitForIngestionKeyError(false);
|
||||
setUpdateLimitForIngestionKeyError(null);
|
||||
setActiveAPIKey(apiKey);
|
||||
handleFormReset();
|
||||
setUpdatedTags(apiKey.tags || []);
|
||||
@@ -248,6 +269,10 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
};
|
||||
|
||||
const showAddModal = (): void => {
|
||||
setHasCreateLimitForIngestionKeyError(false);
|
||||
setCreateLimitForIngestionKeyError(null);
|
||||
setHasUpdateLimitForIngestionKeyError(false);
|
||||
setUpdateLimitForIngestionKeyError(null);
|
||||
setUpdatedTags([]);
|
||||
setActiveAPIKey(null);
|
||||
setIsAddModalOpen(true);
|
||||
@@ -258,27 +283,62 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
setActiveSignal(null);
|
||||
};
|
||||
|
||||
// Use search API when searchText is present, otherwise use normal get API
|
||||
const isSearching = searchText.length > 0;
|
||||
|
||||
const {
|
||||
data: IngestionKeys,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
refetch: refetchAPIKeys,
|
||||
error,
|
||||
isError,
|
||||
} = useGetAllIngestionsKeys({
|
||||
search: searchText,
|
||||
...paginationParams,
|
||||
});
|
||||
data: ingestionKeysData,
|
||||
isLoading: isLoadingGet,
|
||||
isRefetching: isRefetchingGet,
|
||||
refetch: refetchGetAPIKeys,
|
||||
error: getError,
|
||||
isError: isGetError,
|
||||
} = useGetIngestionKeys(
|
||||
{
|
||||
...paginationParams,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: !isSearching,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: searchIngestionKeysData,
|
||||
isLoading: isLoadingSearch,
|
||||
isRefetching: isRefetchingSearch,
|
||||
refetch: refetchSearchAPIKeys,
|
||||
error: searchError,
|
||||
isError: isSearchError,
|
||||
} = useSearchIngestionKeys(
|
||||
{
|
||||
page: FIRST_PAGE,
|
||||
per_page: SEARCH_PAGE_SIZE,
|
||||
name: searchText,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: isSearching,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Use the appropriate data based on which API is active
|
||||
const ingestionKeys = isSearching
|
||||
? searchIngestionKeysData
|
||||
: ingestionKeysData;
|
||||
const isLoading = isSearching ? isLoadingSearch : isLoadingGet;
|
||||
const isRefetching = isSearching ? isRefetchingSearch : isRefetchingGet;
|
||||
const refetchAPIKeys = isSearching ? refetchSearchAPIKeys : refetchGetAPIKeys;
|
||||
const error = isSearching ? searchError : getError;
|
||||
const isError = isSearching ? isSearchError : isGetError;
|
||||
|
||||
useEffect(() => {
|
||||
setActiveAPIKey(IngestionKeys?.data.data[0]);
|
||||
}, [IngestionKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
setDataSource(IngestionKeys?.data.data || []);
|
||||
setTotalIngestionKeys(IngestionKeys?.data?._pagination?.total || 0);
|
||||
setDataSource(ingestionKeys?.data.data?.keys || []);
|
||||
setTotalIngestionKeys(ingestionKeys?.data?.data?._pagination?.total || 0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [IngestionKeys?.data?.data]);
|
||||
}, [ingestionKeys?.data?.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
@@ -297,6 +357,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
|
||||
const clearSearch = (): void => {
|
||||
setSearchValue('');
|
||||
setSearchText('');
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -309,101 +370,54 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
const {
|
||||
mutate: createIngestionKey,
|
||||
isLoading: isLoadingCreateAPIKey,
|
||||
} = useMutation(createIngestionKeyApi, {
|
||||
onSuccess: (data) => {
|
||||
setActiveAPIKey(data.payload);
|
||||
setUpdatedTags([]);
|
||||
hideAddViewModal();
|
||||
refetchAPIKeys();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
});
|
||||
} = useCreateIngestionKey<AxiosError<RenderErrorResponseDTO>>();
|
||||
|
||||
const { mutate: updateAPIKey, isLoading: isLoadingUpdateAPIKey } = useMutation(
|
||||
updateIngestionKey,
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchAPIKeys();
|
||||
setIsEditModalOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
const {
|
||||
mutate: updateAPIKey,
|
||||
isLoading: isLoadingUpdateAPIKey,
|
||||
} = useUpdateIngestionKey<AxiosError<RenderErrorResponseDTO>>();
|
||||
|
||||
const { mutate: deleteAPIKey, isLoading: isDeleteingAPIKey } = useMutation(
|
||||
deleteIngestionKey,
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchAPIKeys();
|
||||
setIsDeleteModalOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
const {
|
||||
mutate: deleteAPIKey,
|
||||
isLoading: isDeleteingAPIKey,
|
||||
} = useDeleteIngestionKey<AxiosError<RenderErrorResponseDTO>>();
|
||||
|
||||
const {
|
||||
mutate: createLimitForIngestionKey,
|
||||
isLoading: isLoadingLimitForKey,
|
||||
} = useMutation(createLimitForIngestionKeyApi, {
|
||||
onSuccess: () => {
|
||||
setActiveSignal(null);
|
||||
setActiveAPIKey(null);
|
||||
setIsEditAddLimitOpen(false);
|
||||
setUpdatedTags([]);
|
||||
hideAddViewModal();
|
||||
refetchAPIKeys();
|
||||
setHasCreateLimitForIngestionKeyError(false);
|
||||
},
|
||||
onError: (error: ErrorResponse) => {
|
||||
setHasCreateLimitForIngestionKeyError(true);
|
||||
setCreateLimitForIngestionKeyError(error);
|
||||
},
|
||||
});
|
||||
} = useCreateIngestionKeyLimit<AxiosError<RenderErrorResponseDTO>>();
|
||||
|
||||
const {
|
||||
mutate: updateLimitForIngestionKey,
|
||||
isLoading: isLoadingUpdatedLimitForKey,
|
||||
} = useMutation(updateLimitForIngestionKeyApi, {
|
||||
onSuccess: () => {
|
||||
setActiveSignal(null);
|
||||
setActiveAPIKey(null);
|
||||
setIsEditAddLimitOpen(false);
|
||||
setUpdatedTags([]);
|
||||
hideAddViewModal();
|
||||
refetchAPIKeys();
|
||||
setHasUpdateLimitForIngestionKeyError(false);
|
||||
},
|
||||
onError: (error: ErrorResponse) => {
|
||||
setHasUpdateLimitForIngestionKeyError(true);
|
||||
setUpdateLimitForIngestionKeyError(error);
|
||||
},
|
||||
});
|
||||
} = useUpdateIngestionKeyLimit<AxiosError<RenderErrorResponseDTO>>();
|
||||
|
||||
const { mutate: deleteLimitForKey, isLoading: isDeletingLimit } = useMutation(
|
||||
deleteLimitsForIngestionKey,
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setIsDeleteLimitModalOpen(false);
|
||||
refetchAPIKeys();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
const {
|
||||
mutate: deleteLimitForKey,
|
||||
isLoading: isDeletingLimit,
|
||||
} = useDeleteIngestionKeyLimit<AxiosError<RenderErrorResponseDTO>>();
|
||||
|
||||
const onDeleteHandler = (): void => {
|
||||
clearSearch();
|
||||
|
||||
if (activeAPIKey) {
|
||||
deleteAPIKey(activeAPIKey.id);
|
||||
if (activeAPIKey && activeAPIKey.id) {
|
||||
deleteAPIKey(
|
||||
{
|
||||
pathParams: { keyId: activeAPIKey.id },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.success({
|
||||
message: 'Ingestion key deleted successfully',
|
||||
});
|
||||
refetchAPIKeys();
|
||||
setIsDeleteModalOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -411,15 +425,31 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
editForm
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
if (activeAPIKey) {
|
||||
updateAPIKey({
|
||||
id: activeAPIKey.id,
|
||||
data: {
|
||||
name: values.name,
|
||||
tags: updatedTags,
|
||||
expires_at: dayjs(values.expires_at).endOf('day').toISOString(),
|
||||
if (activeAPIKey && activeAPIKey.id) {
|
||||
updateAPIKey(
|
||||
{
|
||||
pathParams: { keyId: activeAPIKey.id },
|
||||
data: {
|
||||
name: values.name,
|
||||
tags: updatedTags,
|
||||
expires_at: new Date(
|
||||
dayjs(values.expires_at).endOf('day').toISOString(),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.success({
|
||||
message: 'Ingestion key updated successfully',
|
||||
});
|
||||
refetchAPIKeys();
|
||||
setIsEditModalOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((errorInfo) => {
|
||||
@@ -435,10 +465,30 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
const requestPayload = {
|
||||
name: values.name,
|
||||
tags: updatedTags,
|
||||
expires_at: dayjs(values.expires_at).endOf('day').toISOString(),
|
||||
expires_at: new Date(dayjs(values.expires_at).endOf('day').toISOString()),
|
||||
};
|
||||
|
||||
createIngestionKey(requestPayload);
|
||||
createIngestionKey(
|
||||
{
|
||||
data: requestPayload,
|
||||
},
|
||||
{
|
||||
onSuccess: (_data) => {
|
||||
notifications.success({
|
||||
message: 'Ingestion key created successfully',
|
||||
});
|
||||
// The new API returns GatewaytypesGettableCreatedIngestionKeyDTO with only id and value
|
||||
// We rely on refetchAPIKeys to get the full key object
|
||||
setActiveAPIKey(null);
|
||||
setUpdatedTags([]);
|
||||
hideAddViewModal();
|
||||
refetchAPIKeys();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((errorInfo) => {
|
||||
@@ -465,7 +515,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.UTC_MONTH_COMPACT);
|
||||
|
||||
const showDeleteLimitModal = (
|
||||
APIKey: IngestionKeyProps,
|
||||
APIKey: GatewaytypesIngestionKeyDTO,
|
||||
limit: LimitProps,
|
||||
): void => {
|
||||
setActiveAPIKey(APIKey);
|
||||
@@ -489,9 +539,17 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
const handleAddLimit = (
|
||||
APIKey: IngestionKeyProps,
|
||||
APIKey: GatewaytypesIngestionKeyDTO,
|
||||
signalName: string,
|
||||
): void => {
|
||||
if (!APIKey.id) {
|
||||
notifications.error({
|
||||
message: 'Invalid ingestion key',
|
||||
description: 'Cannot create limit for ingestion key without a valid ID',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
dailyLimit,
|
||||
secondsLimit,
|
||||
@@ -576,13 +634,49 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
return;
|
||||
}
|
||||
|
||||
createLimitForIngestionKey(payload);
|
||||
createLimitForIngestionKey(
|
||||
{
|
||||
pathParams: { keyId: payload.keyID },
|
||||
data: {
|
||||
signal: payload.signal,
|
||||
config: payload.config,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.success({
|
||||
message: 'Limit created successfully',
|
||||
});
|
||||
setActiveSignal(null);
|
||||
setActiveAPIKey(null);
|
||||
setIsEditAddLimitOpen(false);
|
||||
setUpdatedTags([]);
|
||||
hideAddViewModal();
|
||||
refetchAPIKeys();
|
||||
setHasCreateLimitForIngestionKeyError(false);
|
||||
},
|
||||
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
|
||||
setHasCreateLimitForIngestionKeyError(true);
|
||||
setCreateLimitForIngestionKeyError(
|
||||
error.response?.data?.error?.message || 'Failed to create limit',
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdateLimit = (
|
||||
APIKey: IngestionKeyProps,
|
||||
APIKey: GatewaytypesIngestionKeyDTO,
|
||||
signal: LimitProps,
|
||||
): void => {
|
||||
if (!signal.id) {
|
||||
notifications.error({
|
||||
message: 'Invalid limit',
|
||||
description: 'Cannot update limit without a valid ID',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
dailyLimit,
|
||||
secondsLimit,
|
||||
@@ -644,7 +738,34 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
updateLimitForIngestionKey(payload);
|
||||
updateLimitForIngestionKey(
|
||||
{
|
||||
pathParams: { limitId: payload.limitID },
|
||||
data: {
|
||||
config: payload.config,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.success({
|
||||
message: 'Limit updated successfully',
|
||||
});
|
||||
setActiveSignal(null);
|
||||
setActiveAPIKey(null);
|
||||
setIsEditAddLimitOpen(false);
|
||||
setUpdatedTags([]);
|
||||
hideAddViewModal();
|
||||
refetchAPIKeys();
|
||||
setHasUpdateLimitForIngestionKeyError(false);
|
||||
},
|
||||
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
|
||||
setHasUpdateLimitForIngestionKeyError(true);
|
||||
setUpdateLimitForIngestionKeyError(
|
||||
error.response?.data?.error?.message || 'Failed to update limit',
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
/* eslint-enable sonarjs/cognitive-complexity */
|
||||
|
||||
@@ -656,7 +777,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
};
|
||||
|
||||
const enableEditLimitMode = (
|
||||
APIKey: IngestionKeyProps,
|
||||
APIKey: GatewaytypesIngestionKeyDTO,
|
||||
signal: LimitProps,
|
||||
): void => {
|
||||
const dayCount = signal?.config?.day?.count;
|
||||
@@ -665,6 +786,11 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
const dayCountConverted = countToUnit(dayCount || 0);
|
||||
const secondCountConverted = countToUnit(secondCount || 0);
|
||||
|
||||
setHasCreateLimitForIngestionKeyError(false);
|
||||
setCreateLimitForIngestionKeyError(null);
|
||||
setHasUpdateLimitForIngestionKeyError(false);
|
||||
setUpdateLimitForIngestionKeyError(null);
|
||||
|
||||
setActiveAPIKey(APIKey);
|
||||
setActiveSignal({
|
||||
...signal,
|
||||
@@ -703,14 +829,31 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
|
||||
const onDeleteLimitHandler = (): void => {
|
||||
if (activeSignal && activeSignal.id) {
|
||||
deleteLimitForKey(activeSignal.id);
|
||||
deleteLimitForKey(
|
||||
{
|
||||
pathParams: { limitId: activeSignal.id },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.success({
|
||||
message: 'Limit deleted successfully',
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
setIsDeleteLimitModalOpen(false);
|
||||
refetchAPIKeys();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const handleCreateAlert = (
|
||||
APIKey: IngestionKeyProps,
|
||||
APIKey: GatewaytypesIngestionKeyDTO,
|
||||
signal: LimitProps,
|
||||
): void => {
|
||||
let metricName = '';
|
||||
@@ -771,31 +914,61 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
history.push(URL);
|
||||
};
|
||||
|
||||
const columns: AntDTableProps<IngestionKeyProps>['columns'] = [
|
||||
const columns: AntDTableProps<GatewaytypesIngestionKeyDTO>['columns'] = [
|
||||
{
|
||||
title: 'Ingestion Key',
|
||||
key: 'ingestion-key',
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
render: (APIKey: IngestionKeyProps): JSX.Element => {
|
||||
const createdOn = getFormattedTime(
|
||||
APIKey.created_at,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
);
|
||||
render: (APIKey: GatewaytypesIngestionKeyDTO): JSX.Element => {
|
||||
const createdOn = APIKey?.created_at
|
||||
? getFormattedTime(
|
||||
dayjs(APIKey.created_at).toISOString(),
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
)
|
||||
: '';
|
||||
|
||||
const expiresOn =
|
||||
!APIKey?.expires_at || APIKey?.expires_at === '0001-01-01T00:00:00Z'
|
||||
!APIKey?.expires_at ||
|
||||
dayjs(APIKey?.expires_at).toISOString() === '0001-01-01T00:00:00.000Z'
|
||||
? 'No Expiry'
|
||||
: getFormattedTime(APIKey?.expires_at, formatTimezoneAdjustedTimestamp);
|
||||
: getFormattedTime(
|
||||
dayjs(APIKey?.expires_at).toISOString(),
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
);
|
||||
|
||||
const updatedOn = getFormattedTime(
|
||||
APIKey?.updated_at,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
);
|
||||
const updatedOn = APIKey?.updated_at
|
||||
? getFormattedTime(
|
||||
dayjs(APIKey.updated_at).toISOString(),
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
)
|
||||
: '';
|
||||
|
||||
const onCopyKey = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (APIKey?.value) {
|
||||
handleCopyKey(APIKey.value);
|
||||
}
|
||||
};
|
||||
|
||||
const onEditKey = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showEditModal(APIKey);
|
||||
};
|
||||
|
||||
const onDeleteKey = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showDeleteModal(APIKey);
|
||||
};
|
||||
|
||||
// Convert array of limits to a dictionary for quick access
|
||||
const limitsDict: Record<string, LimitProps> = {};
|
||||
APIKey.limits?.forEach((limitItem: LimitProps) => {
|
||||
limitsDict[limitItem.signal] = limitItem;
|
||||
APIKey.limits?.forEach((limitItem) => {
|
||||
if (limitItem.signal && limitItem.id) {
|
||||
limitsDict[limitItem.signal] = limitItem as LimitProps;
|
||||
}
|
||||
});
|
||||
|
||||
const hasLimits = (signalName: string): boolean => !!limitsDict[signalName];
|
||||
@@ -812,39 +985,25 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
|
||||
<div className="ingestion-key-value">
|
||||
<Typography.Text>
|
||||
{APIKey?.value.substring(0, 2)}********
|
||||
{APIKey?.value.substring(APIKey.value.length - 2).trim()}
|
||||
{APIKey?.value?.substring(0, 2)}********
|
||||
{APIKey?.value
|
||||
?.substring(APIKey?.value?.length ? APIKey.value.length - 2 : 0)
|
||||
?.trim()}
|
||||
</Typography.Text>
|
||||
|
||||
<Copy
|
||||
className="copy-key-btn"
|
||||
size={12}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleCopyKey(APIKey.value);
|
||||
}}
|
||||
/>
|
||||
<Copy className="copy-key-btn" size={12} onClick={onCopyKey} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="action-btn">
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<PenLine size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showEditModal(APIKey);
|
||||
}}
|
||||
onClick={onEditKey}
|
||||
/>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showDeleteModal(APIKey);
|
||||
}}
|
||||
onClick={onDeleteKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -854,7 +1013,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
<Row>
|
||||
<Col span={6}> ID </Col>
|
||||
<Col span={12}>
|
||||
<Typography.Text>{APIKey.id}</Typography.Text>
|
||||
<Typography.Text>{APIKey?.id}</Typography.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -906,6 +1065,39 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
limit?.config?.second?.size !== undefined ||
|
||||
limit?.config?.second?.count !== undefined;
|
||||
|
||||
const onEditSignalLimit = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
enableEditLimitMode(APIKey, limit);
|
||||
};
|
||||
|
||||
const onDeleteSignalLimit = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showDeleteLimitModal(APIKey, limit);
|
||||
};
|
||||
|
||||
const onAddSignalLimit = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
enableEditLimitMode(APIKey, {
|
||||
id: signalName,
|
||||
signal: signalName,
|
||||
config: {},
|
||||
});
|
||||
};
|
||||
|
||||
const onSaveSignalLimit = (): void => {
|
||||
if (!hasLimits(signalName)) {
|
||||
handleAddLimit(APIKey, signalName);
|
||||
} else {
|
||||
handleUpdateLimit(APIKey, limitsDict[signalName]);
|
||||
}
|
||||
};
|
||||
|
||||
const onCreateSignalAlert = (): void =>
|
||||
handleCreateAlert(APIKey, limitsDict[signalName]);
|
||||
|
||||
return (
|
||||
<div className="signal" key={signalName}>
|
||||
<div className="header">
|
||||
@@ -916,22 +1108,18 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<PenLine size={14} />}
|
||||
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
enableEditLimitMode(APIKey, limit);
|
||||
}}
|
||||
disabled={
|
||||
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
|
||||
}
|
||||
onClick={onEditSignalLimit}
|
||||
/>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
|
||||
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showDeleteLimitModal(APIKey, limit);
|
||||
}}
|
||||
disabled={
|
||||
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
|
||||
}
|
||||
onClick={onDeleteSignalLimit}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -940,16 +1128,8 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
size="small"
|
||||
shape="round"
|
||||
icon={<PlusIcon size={14} />}
|
||||
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
enableEditLimitMode(APIKey, {
|
||||
id: signalName,
|
||||
signal: signalName,
|
||||
config: {},
|
||||
});
|
||||
}}
|
||||
disabled={!!(activeAPIKey?.id === APIKey?.id && activeSignal)}
|
||||
onClick={onAddSignalLimit}
|
||||
>
|
||||
Limits
|
||||
</Button>
|
||||
@@ -958,7 +1138,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div className="signal-limit-values">
|
||||
{activeAPIKey?.id === APIKey.id &&
|
||||
{activeAPIKey?.id === APIKey?.id &&
|
||||
activeSignal?.signal === signalName &&
|
||||
isEditAddLimitOpen ? (
|
||||
<Form
|
||||
@@ -1154,27 +1334,27 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeAPIKey?.id === APIKey.id &&
|
||||
{activeAPIKey?.id === APIKey?.id &&
|
||||
activeSignal.signal === signalName &&
|
||||
!isLoadingLimitForKey &&
|
||||
hasCreateLimitForIngestionKeyError &&
|
||||
createLimitForIngestionKeyError?.error && (
|
||||
createLimitForIngestionKeyError && (
|
||||
<div className="error">
|
||||
{createLimitForIngestionKeyError?.error}
|
||||
{createLimitForIngestionKeyError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeAPIKey?.id === APIKey.id &&
|
||||
{activeAPIKey?.id === APIKey?.id &&
|
||||
activeSignal.signal === signalName &&
|
||||
!isLoadingLimitForKey &&
|
||||
hasUpdateLimitForIngestionKeyError &&
|
||||
updateLimitForIngestionKeyError?.error && (
|
||||
updateLimitForIngestionKeyError && (
|
||||
<div className="error">
|
||||
{updateLimitForIngestionKeyError?.error}
|
||||
{updateLimitForIngestionKeyError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeAPIKey?.id === APIKey.id &&
|
||||
{activeAPIKey?.id === APIKey?.id &&
|
||||
activeSignal.signal === signalName &&
|
||||
isEditAddLimitOpen && (
|
||||
<div className="signal-limit-save-discard">
|
||||
@@ -1188,13 +1368,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
loading={
|
||||
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
|
||||
}
|
||||
onClick={(): void => {
|
||||
if (!hasLimits(signalName)) {
|
||||
handleAddLimit(APIKey, signalName);
|
||||
} else {
|
||||
handleUpdateLimit(APIKey, limitsDict[signalName]);
|
||||
}
|
||||
}}
|
||||
onClick={onSaveSignalLimit}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
@@ -1275,9 +1449,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
className="set-alert-btn periscope-btn ghost"
|
||||
type="text"
|
||||
data-testid={`set-alert-btn-${signalName}`}
|
||||
onClick={(): void =>
|
||||
handleCreateAlert(APIKey, limitsDict[signalName])
|
||||
}
|
||||
onClick={onCreateSignalAlert}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -1392,7 +1564,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
const handleTableChange = (pagination: TablePaginationConfig): void => {
|
||||
setPaginationParams({
|
||||
page: pagination?.current || 1,
|
||||
per_page: 10,
|
||||
per_page: INITIAL_PAGE_SIZE,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1490,7 +1662,7 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
showHeader={false}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
pageSize: paginationParams?.per_page,
|
||||
pageSize: isSearching ? SEARCH_PAGE_SIZE : paginationParams?.per_page,
|
||||
hideOnSinglePage: true,
|
||||
showTotal: (total: number, range: number[]): string =>
|
||||
`${range[0]}-${range[1]} of ${total} Ingestion keys`,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GatewaytypesGettableIngestionKeysDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
@@ -18,6 +19,12 @@ interface TestAllIngestionKeyProps extends Omit<AllIngestionKeyProps, 'data'> {
|
||||
data: TestIngestionKeyProps[];
|
||||
}
|
||||
|
||||
// Gateway API response type (uses actual schema types for contract safety)
|
||||
interface TestGatewayIngestionKeysResponse {
|
||||
status: string;
|
||||
data: GatewaytypesGettableIngestionKeysDTO;
|
||||
}
|
||||
|
||||
// Mock useHistory.push to capture navigation URL used by MultiIngestionSettings
|
||||
const mockPush = jest.fn() as jest.MockedFunction<(path: string) => void>;
|
||||
jest.mock('react-router-dom', () => {
|
||||
@@ -86,32 +93,34 @@ describe('MultiIngestionSettings Page', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Arrange API response with a metrics daily count limit so the alert button is visible
|
||||
const response: TestAllIngestionKeyProps = {
|
||||
const response: TestGatewayIngestionKeysResponse = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
name: 'Key One',
|
||||
expires_at: TEST_EXPIRES_AT,
|
||||
value: 'secret',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k1',
|
||||
created_at: TEST_CREATED_UPDATED,
|
||||
updated_at: TEST_CREATED_UPDATED,
|
||||
tags: [],
|
||||
limits: [
|
||||
{
|
||||
id: 'l1',
|
||||
signal: 'metrics',
|
||||
config: { day: { count: 1000 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
|
||||
data: {
|
||||
keys: [
|
||||
{
|
||||
name: 'Key One',
|
||||
expires_at: new Date(TEST_EXPIRES_AT),
|
||||
value: 'secret',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k1',
|
||||
created_at: new Date(TEST_CREATED_UPDATED),
|
||||
updated_at: new Date(TEST_CREATED_UPDATED),
|
||||
tags: [],
|
||||
limits: [
|
||||
{
|
||||
id: 'l1',
|
||||
signal: 'metrics',
|
||||
config: { day: { count: 1000 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get('*/workspaces/me/keys*', (_req, res, ctx) =>
|
||||
rest.get('*/api/v2/gateway/ingestion_keys*', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(response)),
|
||||
),
|
||||
);
|
||||
@@ -257,4 +266,95 @@ describe('MultiIngestionSettings Page', () => {
|
||||
'signoz.meter.log.size',
|
||||
);
|
||||
});
|
||||
|
||||
it('switches to search API when search text is entered', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
const getResponse: TestGatewayIngestionKeysResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
keys: [
|
||||
{
|
||||
name: 'Key Regular',
|
||||
expires_at: new Date(TEST_EXPIRES_AT),
|
||||
value: 'secret1',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k1',
|
||||
created_at: new Date(TEST_CREATED_UPDATED),
|
||||
updated_at: new Date(TEST_CREATED_UPDATED),
|
||||
tags: [],
|
||||
limits: [],
|
||||
},
|
||||
],
|
||||
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
const searchResponse: TestGatewayIngestionKeysResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
keys: [
|
||||
{
|
||||
name: 'Key Search Result',
|
||||
expires_at: new Date(TEST_EXPIRES_AT),
|
||||
value: 'secret2',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k2',
|
||||
created_at: new Date(TEST_CREATED_UPDATED),
|
||||
updated_at: new Date(TEST_CREATED_UPDATED),
|
||||
tags: [],
|
||||
limits: [],
|
||||
},
|
||||
],
|
||||
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
const getHandler = jest.fn();
|
||||
const searchHandler = jest.fn();
|
||||
|
||||
server.use(
|
||||
rest.get('*/api/v2/gateway/ingestion_keys', (req, res, ctx) => {
|
||||
if (req.url.pathname.endsWith('/search')) {
|
||||
return undefined;
|
||||
}
|
||||
getHandler();
|
||||
return res(ctx.status(200), ctx.json(getResponse));
|
||||
}),
|
||||
rest.get('*/api/v2/gateway/ingestion_keys/search', (_req, res, ctx) => {
|
||||
searchHandler();
|
||||
return res(ctx.status(200), ctx.json(searchResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MultiIngestionSettings />, undefined, {
|
||||
initialRoute: INGESTION_SETTINGS_ROUTE,
|
||||
});
|
||||
|
||||
await screen.findByText('Key Regular');
|
||||
expect(getHandler).toHaveBeenCalled();
|
||||
expect(searchHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Reset getHandler count to verify it's not called again during search
|
||||
getHandler.mockClear();
|
||||
|
||||
// Type in search box
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
'Search for ingestion key...',
|
||||
);
|
||||
await user.type(searchInput, 'test');
|
||||
|
||||
await screen.findByText('Key Search Result');
|
||||
expect(searchHandler).toHaveBeenCalled();
|
||||
expect(getHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Clear search
|
||||
searchHandler.mockClear();
|
||||
getHandler.mockClear();
|
||||
await user.clear(searchInput);
|
||||
|
||||
await screen.findByText('Key Regular');
|
||||
// Search API should be disabled when not searching
|
||||
expect(searchHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,10 +35,10 @@
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 0px;
|
||||
.group-by-clause {
|
||||
.more-filter-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.group-by-clause:hover {
|
||||
.more-filter-actions:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@
|
||||
border: 1px solid var(--bg-vanilla-400);
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
|
||||
.group-by-clause {
|
||||
.more-filter-actions {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
@@ -16,7 +17,12 @@ import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||
import { ArrowDownToDot, ArrowUpFromDot, Ellipsis } from 'lucide-react';
|
||||
import {
|
||||
ArrowDownToDot,
|
||||
ArrowUpFromDot,
|
||||
Ellipsis,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
@@ -205,6 +211,70 @@ export default function TableViewActions(
|
||||
viewName,
|
||||
]);
|
||||
|
||||
const handleReplaceFilter = useCallback((): void => {
|
||||
if (!stagedQuery) {
|
||||
return;
|
||||
}
|
||||
const normalizedDataType: DataTypes | undefined =
|
||||
dataType && Object.values(DataTypes).includes(dataType as DataTypes)
|
||||
? (dataType as DataTypes)
|
||||
: undefined;
|
||||
|
||||
const updatedQuery = updateQueriesData(
|
||||
stagedQuery,
|
||||
'queryData',
|
||||
(item, index) => {
|
||||
// Only replace filters for index 0
|
||||
if (index === 0) {
|
||||
const newFilterItem: BaseAutocompleteData = {
|
||||
key: fieldFilterKey,
|
||||
type: fieldType || '',
|
||||
dataType: normalizedDataType,
|
||||
};
|
||||
|
||||
// Create new filter items array with single IN filter
|
||||
const newFilters = {
|
||||
items: [
|
||||
{
|
||||
id: '',
|
||||
key: newFilterItem,
|
||||
op: OPERATORS.IN,
|
||||
value: [parseFieldValue(fieldData.value)],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
// Clear the expression and update filters
|
||||
return {
|
||||
...item,
|
||||
filters: newFilters,
|
||||
filter: { expression: '' },
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
},
|
||||
);
|
||||
|
||||
const queryData: ICurrentQueryData = {
|
||||
name: viewName,
|
||||
id: updatedQuery.id,
|
||||
query: updatedQuery,
|
||||
};
|
||||
|
||||
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
|
||||
}, [
|
||||
stagedQuery,
|
||||
updateQueriesData,
|
||||
fieldFilterKey,
|
||||
fieldType,
|
||||
dataType,
|
||||
fieldData,
|
||||
handleChangeSelectedView,
|
||||
viewName,
|
||||
]);
|
||||
|
||||
// Memoize textToCopy computation
|
||||
const textToCopy = useMemo(() => {
|
||||
let text = fieldData.value;
|
||||
@@ -327,13 +397,21 @@ export default function TableViewActions(
|
||||
content={
|
||||
<div>
|
||||
<Button
|
||||
className="group-by-clause"
|
||||
className="more-filter-actions"
|
||||
type="text"
|
||||
icon={<GroupByIcon />}
|
||||
onClick={handleGroupByAttribute}
|
||||
>
|
||||
Group By Attribute
|
||||
</Button>
|
||||
<Button
|
||||
className="more-filter-actions"
|
||||
type="text"
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={handleReplaceFilter}
|
||||
>
|
||||
Replace filters with this value
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
rootClassName="table-view-actions-content"
|
||||
@@ -405,13 +483,21 @@ export default function TableViewActions(
|
||||
content={
|
||||
<div>
|
||||
<Button
|
||||
className="group-by-clause"
|
||||
className="more-filter-actions"
|
||||
type="text"
|
||||
icon={<GroupByIcon />}
|
||||
onClick={handleGroupByAttribute}
|
||||
>
|
||||
Group By Attribute
|
||||
</Button>
|
||||
<Button
|
||||
className="more-filter-actions"
|
||||
type="text"
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={handleReplaceFilter}
|
||||
>
|
||||
Replace filters with this value
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
rootClassName="table-view-actions-content"
|
||||
|
||||
@@ -407,6 +407,10 @@
|
||||
color: var(--text-neutral-light-200) !important;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--text-ink-500) !important;
|
||||
}
|
||||
|
||||
&:hover .ant-select-selector {
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Form, Input, Select, Tooltip, Typography } from 'antd';
|
||||
import { Form, Input, Select, Typography } from 'antd';
|
||||
import getVersion from 'api/v1/version/get';
|
||||
import get from 'api/v2/sessions/context/get';
|
||||
import post from 'api/v2/sessions/email_password/post';
|
||||
@@ -220,6 +220,20 @@ function Login(): JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPasswordClick = useCallback((): void => {
|
||||
const email = form.getFieldValue('email');
|
||||
|
||||
if (!email || !sessionsContext || !sessionsContext?.orgs?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
history.push(ROUTES.FORGOT_PASSWORD, {
|
||||
email,
|
||||
orgId: sessionsOrgId,
|
||||
orgs: sessionsContext.orgs,
|
||||
});
|
||||
}, [form, sessionsContext, sessionsOrgId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (callbackAuthError) {
|
||||
setErrorMessage(
|
||||
@@ -345,11 +359,16 @@ function Login(): JSX.Element {
|
||||
<ParentContainer>
|
||||
<div className="password-label-container">
|
||||
<Label htmlFor="Password">Password</Label>
|
||||
<Tooltip title="Ask your admin to reset your password and send you a new invite link">
|
||||
<Typography.Link className="forgot-password-link">
|
||||
Forgot password?
|
||||
</Typography.Link>
|
||||
</Tooltip>
|
||||
<Typography.Link
|
||||
className="forgot-password-link"
|
||||
href="#"
|
||||
onClick={(event): void => {
|
||||
event.preventDefault();
|
||||
handleForgotPasswordClick();
|
||||
}}
|
||||
>
|
||||
Forgot password?
|
||||
</Typography.Link>
|
||||
</div>
|
||||
<FormContainer.Item name="password">
|
||||
<Input.Password
|
||||
|
||||
@@ -2,13 +2,16 @@ import { useEffect, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetIngestionKeys } from 'api/generated/services/gateway';
|
||||
import {
|
||||
GatewaytypesIngestionKeyDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { DOCS_BASE_URL } from 'constants/app';
|
||||
import { useGetGlobalConfig } from 'hooks/globalConfig/useGetGlobalConfig';
|
||||
import { useGetAllIngestionsKeys } from 'hooks/IngestionKeys/useGetAllIngestionKeys';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { ArrowUpRight, Copy, Info, Key, TriangleAlert } from 'lucide-react';
|
||||
import { IngestionKeyProps } from 'types/api/ingestionKeys/types';
|
||||
|
||||
import './IngestionDetails.styles.scss';
|
||||
|
||||
@@ -39,17 +42,17 @@ export default function OnboardingIngestionDetails(): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
const [, handleCopyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const [firstIngestionKey, setFirstIngestionKey] = useState<IngestionKeyProps>(
|
||||
{} as IngestionKeyProps,
|
||||
);
|
||||
const [
|
||||
firstIngestionKey,
|
||||
setFirstIngestionKey,
|
||||
] = useState<GatewaytypesIngestionKeyDTO>({} as GatewaytypesIngestionKeyDTO);
|
||||
|
||||
const {
|
||||
data: ingestionKeys,
|
||||
isLoading: isIngestionKeysLoading,
|
||||
error,
|
||||
isError,
|
||||
} = useGetAllIngestionsKeys({
|
||||
search: '',
|
||||
} = useGetIngestionKeys({
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
});
|
||||
@@ -69,8 +72,11 @@ export default function OnboardingIngestionDetails(): JSX.Element {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ingestionKeys?.data.data && ingestionKeys?.data.data.length > 0) {
|
||||
setFirstIngestionKey(ingestionKeys?.data.data[0]);
|
||||
if (
|
||||
ingestionKeys?.data?.data?.keys &&
|
||||
ingestionKeys?.data.data.keys.length > 0
|
||||
) {
|
||||
setFirstIngestionKey(ingestionKeys?.data.data.keys[0]);
|
||||
}
|
||||
}, [ingestionKeys]);
|
||||
|
||||
@@ -80,7 +86,10 @@ export default function OnboardingIngestionDetails(): JSX.Element {
|
||||
<div className="ingestion-endpoint-section-error-container">
|
||||
<Typography.Text className="ingestion-endpoint-section-error-text error">
|
||||
<TriangleAlert size={14} />{' '}
|
||||
{(error as AxiosError)?.message || 'Something went wrong'}
|
||||
{(error as AxiosError<RenderErrorResponseDTO>)?.response?.data?.error
|
||||
?.message ||
|
||||
(error as AxiosError)?.message ||
|
||||
'Something went wrong'}
|
||||
</Typography.Text>
|
||||
|
||||
<div className="ingestion-setup-details-links">
|
||||
@@ -176,7 +185,7 @@ export default function OnboardingIngestionDetails(): JSX.Element {
|
||||
</Typography.Text>
|
||||
|
||||
<Typography.Text className="ingestion-key-value-copy">
|
||||
{maskKey(firstIngestionKey?.value)}
|
||||
{maskKey(firstIngestionKey?.value || '')}
|
||||
|
||||
<Copy
|
||||
size={14}
|
||||
@@ -186,7 +195,9 @@ export default function OnboardingIngestionDetails(): JSX.Element {
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INGESTION_KEY_COPIED}`,
|
||||
{},
|
||||
);
|
||||
handleCopyKey(firstIngestionKey?.value);
|
||||
if (firstIngestionKey?.value) {
|
||||
handleCopyKey(firstIngestionKey.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Typography.Text>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import TimeSeriesPanel from '../DashboardContainer/visualization/panels/TimeSeriesPanel/TimeSeriesPanel';
|
||||
import HistogramPanelWrapper from './HistogramPanelWrapper';
|
||||
import ListPanelWrapper from './ListPanelWrapper';
|
||||
import PiePanelWrapper from './PiePanelWrapper';
|
||||
@@ -8,7 +9,7 @@ import UplotPanelWrapper from './UplotPanelWrapper';
|
||||
import ValuePanelWrapper from './ValuePanelWrapper';
|
||||
|
||||
export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.TIME_SERIES]: UplotPanelWrapper,
|
||||
[PANEL_TYPES.TIME_SERIES]: TimeSeriesPanel,
|
||||
[PANEL_TYPES.TABLE]: TablePanelWrapper,
|
||||
[PANEL_TYPES.LIST]: ListPanelWrapper,
|
||||
[PANEL_TYPES.VALUE]: ValuePanelWrapper,
|
||||
|
||||
@@ -32,6 +32,7 @@ export const routeConfig: Record<string, QueryParams[]> = {
|
||||
[ROUTES.LIST_ALL_ALERT]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.LIST_LICENSES]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.LOGIN]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.FORGOT_PASSWORD]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.LOGS]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.LOGS_BASE]: [QueryParams.resourceAttributes],
|
||||
[ROUTES.MY_SETTINGS]: [QueryParams.resourceAttributes],
|
||||
|
||||
61
frontend/src/hooks/useCopyToClipboard.ts
Normal file
61
frontend/src/hooks/useCopyToClipboard.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const DEFAULT_COPIED_RESET_MS = 2000;
|
||||
|
||||
export interface UseCopyToClipboardOptions {
|
||||
/** How long (ms) to keep "copied" state before resetting. Default 2000. */
|
||||
copiedResetMs?: number;
|
||||
}
|
||||
|
||||
export type ID = number | string | null;
|
||||
|
||||
export interface UseCopyToClipboardReturn {
|
||||
/** Copy text to clipboard. Pass an optional id to track which item was copied (e.g. seriesIndex). */
|
||||
copyToClipboard: (text: string, id?: ID) => void;
|
||||
/** True when something was just copied and still within the reset threshold. */
|
||||
isCopied: boolean;
|
||||
/** The id passed to the last successful copy, or null after reset. Use to show "copied" state for a specific item (e.g. copiedId === item.seriesIndex). */
|
||||
id: ID;
|
||||
}
|
||||
|
||||
export function useCopyToClipboard(
|
||||
options: UseCopyToClipboardOptions = {},
|
||||
): UseCopyToClipboardReturn {
|
||||
const { copiedResetMs = DEFAULT_COPIED_RESET_MS } = options;
|
||||
const [state, setState] = useState<{ isCopied: boolean; id: ID }>({
|
||||
isCopied: false,
|
||||
id: null,
|
||||
});
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = useCallback(
|
||||
(text: string, id?: ID): void => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setState({ isCopied: true, id: id ?? null });
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setState({ isCopied: false, id: null });
|
||||
timeoutRef.current = null;
|
||||
}, copiedResetMs);
|
||||
});
|
||||
},
|
||||
[copiedResetMs],
|
||||
);
|
||||
|
||||
return {
|
||||
copyToClipboard,
|
||||
isCopied: state.isCopied,
|
||||
id: state.id,
|
||||
};
|
||||
}
|
||||
445
frontend/src/lib/dashboardVariables/variableReference.test.ts
Normal file
445
frontend/src/lib/dashboardVariables/variableReference.test.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import {
|
||||
buildVariableReferencePattern,
|
||||
extractQueryTextStrings,
|
||||
getVariableReferencesInQuery,
|
||||
textContainsVariableReference,
|
||||
} from './variableReference';
|
||||
|
||||
describe('buildVariableReferencePattern', () => {
|
||||
const varName = 'deployment_environment';
|
||||
|
||||
it.each([
|
||||
['{{.deployment_environment}}', '{{.var}} syntax'],
|
||||
['{{ .deployment_environment }}', '{{.var}} with spaces'],
|
||||
['{{deployment_environment}}', '{{var}} syntax'],
|
||||
['{{ deployment_environment }}', '{{var}} with spaces'],
|
||||
['$deployment_environment', '$var syntax'],
|
||||
['[[deployment_environment]]', '[[var]] syntax'],
|
||||
['[[ deployment_environment ]]', '[[var]] with spaces'],
|
||||
])('matches %s (%s)', (text) => {
|
||||
expect(buildVariableReferencePattern(varName).test(text)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match partial variable names', () => {
|
||||
const pattern = buildVariableReferencePattern('env');
|
||||
// $env should match at word boundary, but $environment should not match $env
|
||||
expect(pattern.test('$environment')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches $var at word boundary within larger text', () => {
|
||||
const pattern = buildVariableReferencePattern('env');
|
||||
expect(pattern.test('SELECT * WHERE x = $env')).toBe(true);
|
||||
expect(pattern.test('$env AND y = 1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('textContainsVariableReference', () => {
|
||||
describe('guard clauses', () => {
|
||||
it('returns false for empty text', () => {
|
||||
expect(textContainsVariableReference('', 'var')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty variable name', () => {
|
||||
expect(textContainsVariableReference('some text', '')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('all syntax formats', () => {
|
||||
const varName = 'service_name';
|
||||
|
||||
it('detects {{.var}} format', () => {
|
||||
const query = "SELECT * FROM table WHERE service = '{{.service_name}}'";
|
||||
expect(textContainsVariableReference(query, varName)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects {{var}} format', () => {
|
||||
const query = "SELECT * FROM table WHERE service = '{{service_name}}'";
|
||||
expect(textContainsVariableReference(query, varName)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects $var format', () => {
|
||||
const query = "SELECT * FROM table WHERE service = '$service_name'";
|
||||
expect(textContainsVariableReference(query, varName)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects [[var]] format', () => {
|
||||
const query = "SELECT * FROM table WHERE service = '[[service_name]]'";
|
||||
expect(textContainsVariableReference(query, varName)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('embedded in larger text', () => {
|
||||
it('finds variable in a multi-line query', () => {
|
||||
const query = `SELECT JSONExtractString(labels, 'k8s_node_name') AS k8s_node_name
|
||||
FROM signoz_metrics.distributed_time_series_v4_1day
|
||||
WHERE metric_name = 'k8s_node_cpu_time' AND JSONExtractString(labels, 'k8s_cluster_name') = {{.k8s_cluster_name}}
|
||||
GROUP BY k8s_node_name`;
|
||||
expect(textContainsVariableReference(query, 'k8s_cluster_name')).toBe(true);
|
||||
expect(textContainsVariableReference(query, 'k8s_node_name')).toBe(false); // plain text, not a variable reference
|
||||
});
|
||||
});
|
||||
|
||||
describe('no false positives', () => {
|
||||
it('does not match substring of a longer variable name', () => {
|
||||
expect(
|
||||
textContainsVariableReference('$service_name_v2', 'service_name'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match plain text that happens to contain the name', () => {
|
||||
expect(
|
||||
textContainsVariableReference(
|
||||
'the service_name column is important',
|
||||
'service_name',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Query text extraction & variable reference detection ----
|
||||
|
||||
const baseQuery: Query = {
|
||||
id: 'test-query',
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
|
||||
clickhouse_sql: [],
|
||||
};
|
||||
|
||||
describe('extractQueryTextStrings', () => {
|
||||
it('returns empty array for query builder with no data', () => {
|
||||
expect(extractQueryTextStrings(baseQuery)).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts string values from query builder filter items', () => {
|
||||
const query: Query = {
|
||||
...baseQuery,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: {
|
||||
queryData: [
|
||||
({
|
||||
filters: {
|
||||
items: [
|
||||
{ id: '1', op: '=', value: ['$service_name', 'hardcoded'] },
|
||||
{ id: '2', op: '=', value: '$env' },
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
} as unknown) as IBuilderQuery,
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
};
|
||||
|
||||
const texts = extractQueryTextStrings(query);
|
||||
expect(texts).toEqual(['$service_name', 'hardcoded', '$env']);
|
||||
});
|
||||
|
||||
it('extracts filter expression from query builder', () => {
|
||||
const query: Query = {
|
||||
...baseQuery,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: {
|
||||
queryData: [
|
||||
({
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'env = $deployment_environment' },
|
||||
} as unknown) as IBuilderQuery,
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
};
|
||||
|
||||
const texts = extractQueryTextStrings(query);
|
||||
expect(texts).toEqual(['env = $deployment_environment']);
|
||||
});
|
||||
|
||||
it('skips non-string filter values', () => {
|
||||
const query: Query = {
|
||||
...baseQuery,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: {
|
||||
queryData: [
|
||||
({
|
||||
filters: {
|
||||
items: [{ id: '1', op: '=', value: [42, true] }],
|
||||
op: 'AND',
|
||||
},
|
||||
} as unknown) as IBuilderQuery,
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
};
|
||||
|
||||
expect(extractQueryTextStrings(query)).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts promql query strings', () => {
|
||||
const query: Query = {
|
||||
...baseQuery,
|
||||
queryType: EQueryType.PROM,
|
||||
promql: [
|
||||
{ name: 'A', query: 'up{env="$env"}', legend: '', disabled: false },
|
||||
{ name: 'B', query: 'cpu{ns="$namespace"}', legend: '', disabled: false },
|
||||
],
|
||||
};
|
||||
|
||||
expect(extractQueryTextStrings(query)).toEqual([
|
||||
'up{env="$env"}',
|
||||
'cpu{ns="$namespace"}',
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts clickhouse sql query strings', () => {
|
||||
const query: Query = {
|
||||
...baseQuery,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
clickhouse_sql: [
|
||||
{
|
||||
name: 'A',
|
||||
query: 'SELECT * WHERE env = {{.env}}',
|
||||
legend: '',
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(extractQueryTextStrings(query)).toEqual([
|
||||
'SELECT * WHERE env = {{.env}}',
|
||||
]);
|
||||
});
|
||||
|
||||
it('accumulates texts across multiple queryData entries', () => {
|
||||
const query: Query = {
|
||||
...baseQuery,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: {
|
||||
queryData: [
|
||||
({
|
||||
filters: {
|
||||
items: [{ id: '1', op: '=', value: '$env' }],
|
||||
op: 'AND',
|
||||
},
|
||||
} as unknown) as IBuilderQuery,
|
||||
({
|
||||
filters: {
|
||||
items: [{ id: '2', op: '=', value: ['$service_name'] }],
|
||||
op: 'AND',
|
||||
},
|
||||
} as unknown) as IBuilderQuery,
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
};
|
||||
|
||||
expect(extractQueryTextStrings(query)).toEqual(['$env', '$service_name']);
|
||||
});
|
||||
|
||||
it('collects both filter items and filter expression from the same queryData', () => {
|
||||
const query: Query = {
|
||||
...baseQuery,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: {
|
||||
queryData: [
|
||||
({
|
||||
filters: {
|
||||
items: [{ id: '1', op: '=', value: '$service_name' }],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'env = $deployment_environment' },
|
||||
} as unknown) as IBuilderQuery,
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
};
|
||||
|
||||
expect(extractQueryTextStrings(query)).toEqual([
|
||||
'$service_name',
|
||||
'env = $deployment_environment',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips promql entries with empty query strings', () => {
|
||||
const query: Query = {
|
||||
...baseQuery,
|
||||
queryType: EQueryType.PROM,
|
||||
promql: [
|
||||
{ name: 'A', query: '', legend: '', disabled: false },
|
||||
{ name: 'B', query: 'up{env="$env"}', legend: '', disabled: false },
|
||||
],
|
||||
};
|
||||
|
||||
expect(extractQueryTextStrings(query)).toEqual(['up{env="$env"}']);
|
||||
});
|
||||
|
||||
it('skips clickhouse entries with empty query strings', () => {
|
||||
const query: Query = {
|
||||
...baseQuery,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
clickhouse_sql: [
|
||||
{ name: 'A', query: '', legend: '', disabled: false },
|
||||
{
|
||||
name: 'B',
|
||||
query: 'SELECT * WHERE x = {{.env}}',
|
||||
legend: '',
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(extractQueryTextStrings(query)).toEqual([
|
||||
'SELECT * WHERE x = {{.env}}',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array for unknown query type', () => {
|
||||
const query = {
|
||||
...baseQuery,
|
||||
queryType: ('unknown' as unknown) as EQueryType,
|
||||
};
|
||||
expect(extractQueryTextStrings(query)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVariableReferencesInQuery', () => {
|
||||
const variableNames = [
|
||||
'deployment_environment',
|
||||
'service_name',
|
||||
'endpoint',
|
||||
'unused_var',
|
||||
];
|
||||
|
||||
it('returns empty array when query has no text', () => {
|
||||
expect(getVariableReferencesInQuery(baseQuery, variableNames)).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects variables referenced in query builder filters', () => {
|
||||
const query: Query = {
|
||||
...baseQuery,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: {
|
||||
queryData: [
|
||||
({
|
||||
filters: {
|
||||
items: [
|
||||
{ id: '1', op: '=', value: '$service_name' },
|
||||
{ id: '2', op: 'IN', value: ['$deployment_environment'] },
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
} as unknown) as IBuilderQuery,
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getVariableReferencesInQuery(query, variableNames);
|
||||
expect(result).toEqual(['deployment_environment', 'service_name']);
|
||||
});
|
||||
|
||||
it('detects variables in promql queries', () => {
|
||||
const query: Query = {
|
||||
...baseQuery,
|
||||
queryType: EQueryType.PROM,
|
||||
promql: [
|
||||
{
|
||||
name: 'A',
|
||||
query:
|
||||
'http_requests{env="{{.deployment_environment}}", endpoint="$endpoint"}',
|
||||
legend: '',
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getVariableReferencesInQuery(query, variableNames);
|
||||
expect(result).toEqual(['deployment_environment', 'endpoint']);
|
||||
});
|
||||
|
||||
it('detects variables in clickhouse sql queries', () => {
|
||||
const query: Query = {
|
||||
...baseQuery,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
clickhouse_sql: [
|
||||
{
|
||||
name: 'A',
|
||||
query: 'SELECT * FROM table WHERE service = [[service_name]]',
|
||||
legend: '',
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getVariableReferencesInQuery(query, variableNames);
|
||||
expect(result).toEqual(['service_name']);
|
||||
});
|
||||
|
||||
it('detects variables spread across multiple queryData entries', () => {
|
||||
const query: Query = {
|
||||
...baseQuery,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: {
|
||||
queryData: [
|
||||
({
|
||||
filters: {
|
||||
items: [{ id: '1', op: '=', value: '$service_name' }],
|
||||
op: 'AND',
|
||||
},
|
||||
} as unknown) as IBuilderQuery,
|
||||
({
|
||||
filter: { expression: 'env = $deployment_environment' },
|
||||
} as unknown) as IBuilderQuery,
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = getVariableReferencesInQuery(query, variableNames);
|
||||
expect(result).toEqual(['deployment_environment', 'service_name']);
|
||||
});
|
||||
|
||||
it('returns empty array when no variables are referenced', () => {
|
||||
const query: Query = {
|
||||
...baseQuery,
|
||||
queryType: EQueryType.PROM,
|
||||
promql: [
|
||||
{
|
||||
name: 'A',
|
||||
query: 'up{job="api"}',
|
||||
legend: '',
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getVariableReferencesInQuery(query, variableNames)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when variableNames list is empty', () => {
|
||||
const query: Query = {
|
||||
...baseQuery,
|
||||
queryType: EQueryType.PROM,
|
||||
promql: [
|
||||
{
|
||||
name: 'A',
|
||||
query: 'up{env="$deployment_environment"}',
|
||||
legend: '',
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(getVariableReferencesInQuery(query, [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
136
frontend/src/lib/dashboardVariables/variableReference.ts
Normal file
136
frontend/src/lib/dashboardVariables/variableReference.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { isArray } from 'lodash-es';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
/**
|
||||
* Builds a RegExp that matches any recognized variable reference syntax:
|
||||
* {{.variableName}} — dot prefix, optional whitespace
|
||||
* {{variableName}} — no dot, optional whitespace
|
||||
* $variableName — dollar prefix, word-boundary terminated
|
||||
* [[variableName]] — square brackets, optional whitespace
|
||||
*/
|
||||
export function buildVariableReferencePattern(variableName: string): RegExp {
|
||||
const patterns = [
|
||||
`\\{\\{\\s*?\\.${variableName}\\s*?\\}\\}`,
|
||||
`\\{\\{\\s*${variableName}\\s*\\}\\}`,
|
||||
`\\$${variableName}\\b`,
|
||||
`\\[\\[\\s*${variableName}\\s*\\]\\]`,
|
||||
];
|
||||
return new RegExp(patterns.join('|'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `text` contains a reference to `variableName` in any of the
|
||||
* recognized variable syntaxes.
|
||||
*/
|
||||
export function textContainsVariableReference(
|
||||
text: string,
|
||||
variableName: string,
|
||||
): boolean {
|
||||
if (!text || !variableName) {
|
||||
return false;
|
||||
}
|
||||
return buildVariableReferencePattern(variableName).test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all text strings from a widget Query that could contain variable
|
||||
* references. Covers:
|
||||
* - QUERY_BUILDER: filter items' string values + filter.expression
|
||||
* - PROM: each promql[].query
|
||||
* - CLICKHOUSE: each clickhouse_sql[].query
|
||||
*/
|
||||
function extractQueryBuilderTexts(query: Query): string[] {
|
||||
const texts: string[] = [];
|
||||
const queryDataList = query.builder?.queryData;
|
||||
if (isArray(queryDataList)) {
|
||||
queryDataList.forEach((queryData) => {
|
||||
// Collect string values from filter items
|
||||
queryData.filters?.items?.forEach((filter: TagFilterItem) => {
|
||||
if (isArray(filter.value)) {
|
||||
filter.value.forEach((v) => {
|
||||
if (typeof v === 'string') {
|
||||
texts.push(v);
|
||||
}
|
||||
});
|
||||
} else if (typeof filter.value === 'string') {
|
||||
texts.push(filter.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Collect filter expression
|
||||
if (queryData.filter?.expression) {
|
||||
texts.push(queryData.filter.expression);
|
||||
}
|
||||
});
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
function extractPromQLTexts(query: Query): string[] {
|
||||
const texts: string[] = [];
|
||||
if (isArray(query.promql)) {
|
||||
query.promql.forEach((promqlQuery) => {
|
||||
if (promqlQuery.query) {
|
||||
texts.push(promqlQuery.query);
|
||||
}
|
||||
});
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
function extractClickhouseSQLTexts(query: Query): string[] {
|
||||
const texts: string[] = [];
|
||||
if (isArray(query.clickhouse_sql)) {
|
||||
query.clickhouse_sql.forEach((clickhouseQuery) => {
|
||||
if (clickhouseQuery.query) {
|
||||
texts.push(clickhouseQuery.query);
|
||||
}
|
||||
});
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all text strings from a widget Query that could contain variable
|
||||
* references. Covers:
|
||||
* - QUERY_BUILDER: filter items' string values + filter.expression
|
||||
* - PROM: each promql[].query
|
||||
* - CLICKHOUSE: each clickhouse_sql[].query
|
||||
*/
|
||||
export function extractQueryTextStrings(query: Query): string[] {
|
||||
if (query.queryType === EQueryType.QUERY_BUILDER) {
|
||||
return extractQueryBuilderTexts(query);
|
||||
}
|
||||
|
||||
if (query.queryType === EQueryType.PROM) {
|
||||
return extractPromQLTexts(query);
|
||||
}
|
||||
|
||||
if (query.queryType === EQueryType.CLICKHOUSE) {
|
||||
return extractClickhouseSQLTexts(query);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a widget Query and an array of variable names, returns the subset of
|
||||
* variable names that are referenced in the query text.
|
||||
*
|
||||
* This performs text-based detection only. Structural checks (like
|
||||
* filter.key.key matching a variable attribute) are NOT included.
|
||||
*/
|
||||
export function getVariableReferencesInQuery(
|
||||
query: Query,
|
||||
variableNames: string[],
|
||||
): string[] {
|
||||
const texts = extractQueryTextStrings(query);
|
||||
if (texts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return variableNames.filter((name) =>
|
||||
texts.some((text) => textContainsVariableReference(text, name)),
|
||||
);
|
||||
}
|
||||
@@ -128,6 +128,15 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.legend-item-label-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.legend-marker {
|
||||
border-width: 2px;
|
||||
border-radius: 50%;
|
||||
@@ -157,10 +166,34 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.legend-copy-button {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 2px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
color: var(--bg-vanilla-400);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
opacity: 1;
|
||||
transition: opacity 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
.legend-copy-button {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,4 +205,17 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.legend-copy-button {
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { Input, Tooltip as AntdTooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { useCopyToClipboard } from 'hooks/useCopyToClipboard';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
|
||||
import { useLegendActions } from '../../hooks/useLegendActions';
|
||||
import { LegendPosition, LegendProps } from '../types';
|
||||
import { useLegendActions } from './useLegendActions';
|
||||
|
||||
import './Legend.styles.scss';
|
||||
|
||||
@@ -32,6 +34,7 @@ export default function Legend({
|
||||
});
|
||||
const legendContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [legendSearchQuery, setLegendSearchQuery] = useState('');
|
||||
const { copyToClipboard, id: copiedId } = useCopyToClipboard();
|
||||
|
||||
const legendItems = useMemo(() => Object.values(legendItemsMap), [
|
||||
legendItemsMap,
|
||||
@@ -59,26 +62,53 @@ export default function Legend({
|
||||
);
|
||||
}, [position, legendSearchQuery, legendItems]);
|
||||
|
||||
const handleCopyLegendItem = useCallback(
|
||||
(e: React.MouseEvent, seriesIndex: number, label: string): void => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(label, seriesIndex);
|
||||
},
|
||||
[copyToClipboard],
|
||||
);
|
||||
|
||||
const renderLegendItem = useCallback(
|
||||
(item: LegendItem): JSX.Element => (
|
||||
<AntdTooltip key={item.seriesIndex} title={item.label}>
|
||||
(item: LegendItem): JSX.Element => {
|
||||
const isCopied = copiedId === item.seriesIndex;
|
||||
return (
|
||||
<div
|
||||
key={item.seriesIndex}
|
||||
data-legend-item-id={item.seriesIndex}
|
||||
className={cx('legend-item', `legend-item-${position.toLowerCase()}`, {
|
||||
'legend-item-off': !item.show,
|
||||
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="legend-marker"
|
||||
style={{ borderColor: String(item.color) }}
|
||||
data-is-legend-marker={true}
|
||||
/>
|
||||
<span className="legend-label">{item.label}</span>
|
||||
<AntdTooltip title={item.label}>
|
||||
<div className="legend-item-label-trigger">
|
||||
<div
|
||||
className="legend-marker"
|
||||
style={{ borderColor: String(item.color) }}
|
||||
data-is-legend-marker={true}
|
||||
/>
|
||||
<span className="legend-label">{item.label}</span>
|
||||
</div>
|
||||
</AntdTooltip>
|
||||
<AntdTooltip title={isCopied ? 'Copied' : 'Copy'}>
|
||||
<button
|
||||
type="button"
|
||||
className="legend-copy-button"
|
||||
onClick={(e): void =>
|
||||
handleCopyLegendItem(e, item.seriesIndex, item.label ?? '')
|
||||
}
|
||||
aria-label={`Copy ${item.label}`}
|
||||
data-testid="legend-copy"
|
||||
>
|
||||
{isCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
</AntdTooltip>
|
||||
</div>
|
||||
</AntdTooltip>
|
||||
),
|
||||
[focusedSeriesIndex, position],
|
||||
);
|
||||
},
|
||||
[copiedId, focusedSeriesIndex, handleCopyLegendItem, position],
|
||||
);
|
||||
|
||||
const isEmptyState = useMemo(() => {
|
||||
@@ -106,6 +136,7 @@ export default function Legend({
|
||||
placeholder="Search..."
|
||||
value={legendSearchQuery}
|
||||
onChange={(e): void => setLegendSearchQuery(e.target.value)}
|
||||
data-testid="legend-search-input"
|
||||
className="legend-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { TimeSeriesTooltipProps } from '../types';
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
export default function TimeSeriesTooltip(
|
||||
props: TimeSeriesTooltipProps,
|
||||
): JSX.Element {
|
||||
return <Tooltip {...props} />;
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { TooltipContentItem, TooltipProps } from '../types';
|
||||
import { buildTooltipContent } from './utils';
|
||||
import { TooltipProps } from '../types';
|
||||
|
||||
import './Tooltip.styles.scss';
|
||||
|
||||
@@ -14,14 +13,14 @@ const TOOLTIP_LIST_MAX_HEIGHT = 330;
|
||||
const TOOLTIP_ITEM_HEIGHT = 38;
|
||||
|
||||
export default function Tooltip({
|
||||
seriesIndex,
|
||||
dataIndexes,
|
||||
uPlotInstance,
|
||||
timezone,
|
||||
yAxisUnit = '',
|
||||
decimalPrecision,
|
||||
content,
|
||||
}: TooltipProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const tooltipContent = content ?? [];
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
const data = uPlotInstance.data;
|
||||
const cursorIdx = uPlotInstance.cursor.idx;
|
||||
@@ -33,20 +32,6 @@ export default function Tooltip({
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
|
||||
}, [timezone, uPlotInstance.data, uPlotInstance.cursor.idx]);
|
||||
|
||||
const content = useMemo(
|
||||
(): TooltipContentItem[] =>
|
||||
buildTooltipContent({
|
||||
data: uPlotInstance.data,
|
||||
series: uPlotInstance.series,
|
||||
dataIndexes,
|
||||
activeSeriesIndex: seriesIndex,
|
||||
uPlotInstance,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
}),
|
||||
[uPlotInstance, seriesIndex, dataIndexes, yAxisUnit, decimalPrecision],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
@@ -60,16 +45,16 @@ export default function Tooltip({
|
||||
<div
|
||||
style={{
|
||||
height: Math.min(
|
||||
content.length * TOOLTIP_ITEM_HEIGHT,
|
||||
tooltipContent.length * TOOLTIP_ITEM_HEIGHT,
|
||||
TOOLTIP_LIST_MAX_HEIGHT,
|
||||
),
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{content.length > 0 ? (
|
||||
{tooltipContent.length > 0 ? (
|
||||
<Virtuoso
|
||||
className="uplot-tooltip-list"
|
||||
data={content}
|
||||
data={tooltipContent}
|
||||
defaultItemHeight={TOOLTIP_ITEM_HEIGHT}
|
||||
itemContent={(_, item): JSX.Element => (
|
||||
<div className="uplot-tooltip-item">
|
||||
|
||||
@@ -20,6 +20,27 @@ export function resolveSeriesColor(
|
||||
return FALLBACK_SERIES_COLOR;
|
||||
}
|
||||
|
||||
export function getTooltipBaseValue({
|
||||
data,
|
||||
index,
|
||||
dataIndex,
|
||||
isStackedBarChart,
|
||||
}: {
|
||||
data: AlignedData;
|
||||
index: number;
|
||||
dataIndex: number;
|
||||
isStackedBarChart?: boolean;
|
||||
}): number | null {
|
||||
let baseValue = data[index][dataIndex] ?? null;
|
||||
if (isStackedBarChart && index + 1 < data.length && baseValue !== null) {
|
||||
const nextValue = data[index + 1][dataIndex] ?? null;
|
||||
if (nextValue !== null) {
|
||||
baseValue = baseValue - nextValue;
|
||||
}
|
||||
}
|
||||
return baseValue;
|
||||
}
|
||||
|
||||
export function buildTooltipContent({
|
||||
data,
|
||||
series,
|
||||
@@ -28,6 +49,7 @@ export function buildTooltipContent({
|
||||
uPlotInstance,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
isStackedBarChart,
|
||||
}: {
|
||||
data: AlignedData;
|
||||
series: Series[];
|
||||
@@ -36,6 +58,7 @@ export function buildTooltipContent({
|
||||
uPlotInstance: uPlot;
|
||||
yAxisUnit: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
isStackedBarChart?: boolean;
|
||||
}): TooltipContentItem[] {
|
||||
const active: TooltipContentItem[] = [];
|
||||
const rest: TooltipContentItem[] = [];
|
||||
@@ -52,23 +75,29 @@ export function buildTooltipContent({
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = data[index]?.[dataIndex];
|
||||
const value = Number(raw);
|
||||
const displayValue = Number.isNaN(value) ? 0 : value;
|
||||
const baseValue = getTooltipBaseValue({
|
||||
data,
|
||||
index,
|
||||
dataIndex,
|
||||
isStackedBarChart,
|
||||
});
|
||||
|
||||
const isActive = index === activeSeriesIndex;
|
||||
|
||||
const item: TooltipContentItem = {
|
||||
label: String(s.label ?? ''),
|
||||
value: displayValue,
|
||||
tooltipValue: getToolTipValue(displayValue, yAxisUnit, decimalPrecision),
|
||||
color: resolveSeriesColor(s.stroke, uPlotInstance, index),
|
||||
isActive,
|
||||
};
|
||||
if (Number.isFinite(baseValue) && baseValue !== null) {
|
||||
const item: TooltipContentItem = {
|
||||
label: String(s.label ?? ''),
|
||||
value: baseValue,
|
||||
tooltipValue: getToolTipValue(baseValue, yAxisUnit, decimalPrecision),
|
||||
color: resolveSeriesColor(s.stroke, uPlotInstance, index),
|
||||
isActive,
|
||||
};
|
||||
|
||||
if (isActive) {
|
||||
active.push(item);
|
||||
} else {
|
||||
rest.push(item);
|
||||
if (isActive) {
|
||||
active.push(item);
|
||||
} else {
|
||||
rest.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
280
frontend/src/lib/uPlotV2/components/__tests__/Legend.test.tsx
Normal file
280
frontend/src/lib/uPlotV2/components/__tests__/Legend.test.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
RenderResult,
|
||||
screen,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
|
||||
import { useLegendActions } from '../../hooks/useLegendActions';
|
||||
import Legend from '../Legend/Legend';
|
||||
import { LegendPosition } from '../types';
|
||||
|
||||
const mockWriteText = jest.fn().mockResolvedValue(undefined);
|
||||
let clipboardSpy: jest.SpyInstance | undefined;
|
||||
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
className,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
className?: string;
|
||||
}): JSX.Element => (
|
||||
<div data-testid="virtuoso-grid" className={className}>
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex ?? index} data-testid="legend-item-wrapper">
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lib/uPlotV2/hooks/useLegendsSync');
|
||||
jest.mock('lib/uPlotV2/hooks/useLegendActions');
|
||||
|
||||
const mockUseLegendsSync = useLegendsSync as jest.MockedFunction<
|
||||
typeof useLegendsSync
|
||||
>;
|
||||
const mockUseLegendActions = useLegendActions as jest.MockedFunction<
|
||||
typeof useLegendActions
|
||||
>;
|
||||
|
||||
describe('Legend', () => {
|
||||
beforeAll(() => {
|
||||
// JSDOM does not define navigator.clipboard; add it so we can spy on writeText
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: () => Promise.resolve() },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
const baseLegendItemsMap = {
|
||||
0: {
|
||||
seriesIndex: 0,
|
||||
label: 'A',
|
||||
show: true,
|
||||
color: '#ff0000',
|
||||
},
|
||||
1: {
|
||||
seriesIndex: 1,
|
||||
label: 'B',
|
||||
show: false,
|
||||
color: '#00ff00',
|
||||
},
|
||||
2: {
|
||||
seriesIndex: 2,
|
||||
label: 'C',
|
||||
show: true,
|
||||
color: '#0000ff',
|
||||
},
|
||||
};
|
||||
|
||||
let onLegendClick: jest.Mock;
|
||||
let onLegendMouseMove: jest.Mock;
|
||||
let onLegendMouseLeave: jest.Mock;
|
||||
let onFocusSeries: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
onLegendClick = jest.fn();
|
||||
onLegendMouseMove = jest.fn();
|
||||
onLegendMouseLeave = jest.fn();
|
||||
onFocusSeries = jest.fn();
|
||||
mockWriteText.mockClear();
|
||||
|
||||
clipboardSpy = jest
|
||||
.spyOn(navigator.clipboard, 'writeText')
|
||||
.mockImplementation(mockWriteText);
|
||||
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: baseLegendItemsMap,
|
||||
focusedSeriesIndex: 1,
|
||||
setFocusedSeriesIndex: jest.fn(),
|
||||
});
|
||||
|
||||
mockUseLegendActions.mockReturnValue({
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onFocusSeries,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clipboardSpy?.mockRestore();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderLegend = (position?: LegendPosition): RenderResult =>
|
||||
render(
|
||||
<Legend
|
||||
position={position}
|
||||
// config is not used directly in the component, it's consumed by the mocked hook
|
||||
config={{} as any}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe('layout and position', () => {
|
||||
it('renders search input when legend position is RIGHT', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render search input when legend position is BOTTOM (default)', () => {
|
||||
renderLegend();
|
||||
|
||||
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the marker with the correct border color', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const legendMarker = document.querySelector(
|
||||
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(legendMarker).toHaveStyle({
|
||||
'border-color': '#ff0000',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all legend items in the grid by default', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('virtuoso-grid')).toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search behavior (RIGHT position)', () => {
|
||||
it('filters legend items based on search query (case-insensitive)', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, 'A');
|
||||
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.queryByText('B')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('C')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no legend items match the search query', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, 'network');
|
||||
|
||||
expect(
|
||||
screen.getByText(/No series found matching "network"/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not filter or show empty state when search query is empty or only whitespace', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, ' ');
|
||||
|
||||
expect(
|
||||
screen.queryByText(/No series found matching/i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.getByText('C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('legend actions', () => {
|
||||
it('calls onLegendClick when a legend item is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByText('A'));
|
||||
|
||||
expect(onLegendClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls mouseMove when the mouse moves over a legend item', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const legendItem = document.querySelector(
|
||||
'[data-legend-item-id="0"]',
|
||||
) as HTMLElement;
|
||||
|
||||
await user.hover(legendItem);
|
||||
|
||||
expect(onLegendMouseMove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onLegendMouseLeave when the mouse leaves the legend container', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const container = document.querySelector('.legend-container') as HTMLElement;
|
||||
|
||||
await user.hover(container);
|
||||
await user.unhover(container);
|
||||
|
||||
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy action', () => {
|
||||
it('copies the legend label to clipboard when copy button is clicked', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const firstLegendItem = document.querySelector(
|
||||
'[data-legend-item-id="0"]',
|
||||
) as HTMLElement;
|
||||
const copyButton = within(firstLegendItem).getByTestId('legend-copy');
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteText).toHaveBeenCalledWith('A');
|
||||
});
|
||||
|
||||
it('copies the correct label when copy is clicked on a different legend item', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const thirdLegendItem = document.querySelector(
|
||||
'[data-legend-item-id="2"]',
|
||||
) as HTMLElement;
|
||||
const copyButton = within(thirdLegendItem).getByTestId('legend-copy');
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteText).toHaveBeenCalledWith('C');
|
||||
});
|
||||
|
||||
it('does not call onLegendClick when copy button is clicked', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const firstLegendItem = document.querySelector(
|
||||
'[data-legend-item-id="0"]',
|
||||
) as HTMLElement;
|
||||
const copyButton = within(firstLegendItem).getByTestId('legend-copy');
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(onLegendClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,11 +59,22 @@ export interface TooltipRenderArgs {
|
||||
viaSync: boolean;
|
||||
}
|
||||
|
||||
export type TooltipProps = TooltipRenderArgs & {
|
||||
export interface BaseTooltipProps {
|
||||
timezone: string;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
};
|
||||
content?: TooltipContentItem[];
|
||||
}
|
||||
|
||||
export interface TimeSeriesTooltipProps
|
||||
extends BaseTooltipProps,
|
||||
TooltipRenderArgs {}
|
||||
|
||||
export interface BarTooltipProps extends BaseTooltipProps, TooltipRenderArgs {
|
||||
isStackedBarChart?: boolean;
|
||||
}
|
||||
|
||||
export type TooltipProps = TimeSeriesTooltipProps | BarTooltipProps;
|
||||
|
||||
export enum LegendPosition {
|
||||
BOTTOM = 'bottom',
|
||||
|
||||
@@ -4,12 +4,12 @@ import uPlot, { Axis } from 'uplot';
|
||||
|
||||
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
|
||||
import getGridColor from '../../uPlotLib/utils/getGridColor';
|
||||
import { buildYAxisSizeCalculator } from '../utils/axis';
|
||||
import { AxisProps, ConfigBuilder } from './types';
|
||||
|
||||
const PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT = [
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
PANEL_TYPES.BAR,
|
||||
PANEL_TYPES.PIE,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -114,81 +114,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate axis size from existing size property
|
||||
*/
|
||||
private getExistingAxisSize(
|
||||
self: uPlot,
|
||||
axis: Axis,
|
||||
values: string[] | undefined,
|
||||
axisIdx: number,
|
||||
cycleNum: number,
|
||||
): number {
|
||||
const internalSize = (axis as { _size?: number })._size;
|
||||
if (internalSize !== undefined) {
|
||||
return internalSize;
|
||||
}
|
||||
|
||||
const existingSize = axis.size;
|
||||
if (typeof existingSize === 'function') {
|
||||
return existingSize(self, values ?? [], axisIdx, cycleNum);
|
||||
}
|
||||
|
||||
return existingSize ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate text width for longest value
|
||||
*/
|
||||
private calculateTextWidth(
|
||||
self: uPlot,
|
||||
axis: Axis,
|
||||
values: string[] | undefined,
|
||||
): number {
|
||||
if (!values || values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find longest value
|
||||
const longestVal = values.reduce(
|
||||
(acc, val) => (val.length > acc.length ? val : acc),
|
||||
'',
|
||||
);
|
||||
|
||||
if (longestVal === '' || !axis.font?.[0]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-destructuring, no-param-reassign
|
||||
self.ctx.font = axis.font[0];
|
||||
return self.ctx.measureText(longestVal).width / devicePixelRatio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Y-axis dynamic size calculator
|
||||
*/
|
||||
private buildYAxisSizeCalculator(): uPlot.Axis.Size {
|
||||
return (
|
||||
self: uPlot,
|
||||
values: string[] | undefined,
|
||||
axisIdx: number,
|
||||
cycleNum: number,
|
||||
): number => {
|
||||
const axis = self.axes[axisIdx];
|
||||
|
||||
// Bail out, force convergence
|
||||
if (cycleNum > 1) {
|
||||
return this.getExistingAxisSize(self, axis, values, axisIdx, cycleNum);
|
||||
}
|
||||
|
||||
const gap = this.props.gap ?? 5;
|
||||
let axisSize = (axis.ticks?.size ?? 0) + gap;
|
||||
axisSize += this.calculateTextWidth(self, axis, values);
|
||||
|
||||
return Math.ceil(axisSize);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dynamic size calculator for Y-axis
|
||||
*/
|
||||
@@ -202,7 +127,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
|
||||
// Y-axis needs dynamic sizing based on text width
|
||||
if (scaleKey === 'y') {
|
||||
return this.buildYAxisSizeCalculator();
|
||||
return buildYAxisSizeCalculator(this.props.gap ?? 5);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SeriesVisibilityState } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { getStoredSeriesVisibility } from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
|
||||
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
|
||||
import { thresholdsDrawHook } from 'lib/uPlotV2/hooks/useThresholdsDrawHook';
|
||||
@@ -235,9 +236,9 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns stored series visibility map from localStorage when preferences source is LOCAL_STORAGE, otherwise null.
|
||||
* Returns stored series visibility by index from localStorage when preferences source is LOCAL_STORAGE, otherwise null.
|
||||
*/
|
||||
private getStoredVisibilityMap(): Map<string, boolean> | null {
|
||||
private getStoredVisibility(): SeriesVisibilityState | null {
|
||||
if (
|
||||
this.widgetId &&
|
||||
this.selectionPreferencesSource === SelectionPreferencesSource.LOCAL_STORAGE
|
||||
@@ -251,22 +252,23 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
* Get legend items with visibility state restored from localStorage if available
|
||||
*/
|
||||
getLegendItems(): Record<number, LegendItem> {
|
||||
const visibilityMap = this.getStoredVisibilityMap();
|
||||
const isAnySeriesHidden = !!(
|
||||
visibilityMap && Array.from(visibilityMap.values()).some((show) => !show)
|
||||
const seriesVisibilityState = this.getStoredVisibility();
|
||||
const isAnySeriesHidden = !!seriesVisibilityState?.visibility?.some(
|
||||
(show) => !show,
|
||||
);
|
||||
|
||||
return this.series.reduce((acc, s: UPlotSeriesBuilder, index: number) => {
|
||||
const seriesConfig = s.getConfig();
|
||||
const label = seriesConfig.label ?? '';
|
||||
const seriesIndex = index + 1; // +1 because the first series is the timestamp
|
||||
|
||||
const show = resolveSeriesVisibility(
|
||||
label,
|
||||
seriesConfig.show,
|
||||
visibilityMap,
|
||||
// +1 because uPlot series 0 is x-axis/time; data series are at 1, 2, ... (also matches stored visibility[0]=time, visibility[1]=first data, ...)
|
||||
const seriesIndex = index + 1;
|
||||
const show = resolveSeriesVisibility({
|
||||
seriesIndex,
|
||||
seriesShow: seriesConfig.show,
|
||||
seriesLabel: label,
|
||||
seriesVisibilityState,
|
||||
isAnySeriesHidden,
|
||||
);
|
||||
});
|
||||
|
||||
acc[seriesIndex] = {
|
||||
seriesIndex,
|
||||
@@ -294,22 +296,23 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
...DEFAULT_PLOT_CONFIG,
|
||||
};
|
||||
|
||||
const visibilityMap = this.getStoredVisibilityMap();
|
||||
const isAnySeriesHidden = !!(
|
||||
visibilityMap && Array.from(visibilityMap.values()).some((show) => !show)
|
||||
const seriesVisibilityState = this.getStoredVisibility();
|
||||
const isAnySeriesHidden = !!seriesVisibilityState?.visibility?.some(
|
||||
(show) => !show,
|
||||
);
|
||||
|
||||
config.series = [
|
||||
{ value: (): string => '' }, // Base series for timestamp
|
||||
...this.series.map((s) => {
|
||||
...this.series.map((s, index) => {
|
||||
const series = s.getConfig();
|
||||
const label = series.label ?? '';
|
||||
const visible = resolveSeriesVisibility(
|
||||
label,
|
||||
series.show,
|
||||
visibilityMap,
|
||||
// Stored visibility[0] is x-axis/time; data series start at visibility[1]
|
||||
const visible = resolveSeriesVisibility({
|
||||
seriesIndex: index + 1,
|
||||
seriesShow: series.show,
|
||||
seriesLabel: series.label ?? '',
|
||||
seriesVisibilityState,
|
||||
isAnySeriesHidden,
|
||||
);
|
||||
});
|
||||
return {
|
||||
...series,
|
||||
show: visible,
|
||||
|
||||
@@ -15,7 +15,33 @@ import {
|
||||
* Builder for uPlot series configuration
|
||||
* Handles creation of series settings
|
||||
*/
|
||||
|
||||
/**
|
||||
* Path builders are static and shared across all instances of UPlotSeriesBuilder
|
||||
*/
|
||||
let builders: PathBuilders | null = null;
|
||||
export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
constructor(props: SeriesProps) {
|
||||
super(props);
|
||||
const pathBuilders = uPlot.paths;
|
||||
|
||||
if (!builders) {
|
||||
const linearBuilder = pathBuilders.linear;
|
||||
const splineBuilder = pathBuilders.spline;
|
||||
const steppedBuilder = pathBuilders.stepped;
|
||||
|
||||
if (!linearBuilder || !splineBuilder || !steppedBuilder) {
|
||||
throw new Error('Required uPlot path builders are not available');
|
||||
}
|
||||
builders = {
|
||||
linear: linearBuilder(),
|
||||
spline: splineBuilder(),
|
||||
stepBefore: steppedBuilder({ align: -1 }),
|
||||
stepAfter: steppedBuilder({ align: 1 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private buildLineConfig({
|
||||
lineColor,
|
||||
lineWidth,
|
||||
@@ -198,8 +224,6 @@ interface PathBuilders {
|
||||
[key: string]: Series.PathBuilder;
|
||||
}
|
||||
|
||||
let builders: PathBuilders | null = null;
|
||||
|
||||
/**
|
||||
* Get path builder based on draw style and interpolation
|
||||
*/
|
||||
@@ -207,23 +231,8 @@ function getPathBuilder(
|
||||
style: DrawStyle,
|
||||
lineInterpolation?: LineInterpolation,
|
||||
): Series.PathBuilder {
|
||||
const pathBuilders = uPlot.paths;
|
||||
|
||||
if (!builders) {
|
||||
const linearBuilder = pathBuilders.linear;
|
||||
const splineBuilder = pathBuilders.spline;
|
||||
const steppedBuilder = pathBuilders.stepped;
|
||||
|
||||
if (!linearBuilder || !splineBuilder || !steppedBuilder) {
|
||||
throw new Error('Required uPlot path builders are not available');
|
||||
}
|
||||
|
||||
builders = {
|
||||
linear: linearBuilder(),
|
||||
spline: splineBuilder(),
|
||||
stepBefore: steppedBuilder({ align: -1 }),
|
||||
stepAfter: steppedBuilder({ align: 1 }),
|
||||
};
|
||||
throw new Error('Required uPlot path builders are not available');
|
||||
}
|
||||
|
||||
if (style === DrawStyle.Line) {
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { AxisProps } from '../types';
|
||||
import { UPlotAxisBuilder } from '../UPlotAxisBuilder';
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
getToolTipValue: jest.fn(),
|
||||
}));
|
||||
|
||||
const createAxisProps = (overrides: Partial<AxisProps> = {}): AxisProps => ({
|
||||
scaleKey: 'x',
|
||||
label: 'Time',
|
||||
isDarkMode: false,
|
||||
show: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('UPlotAxisBuilder', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('builds basic axis config with defaults', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
label: 'Time',
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.scale).toBe('x');
|
||||
expect(config.label).toBe('Time');
|
||||
expect(config.show).toBe(true);
|
||||
expect(config.side).toBe(2);
|
||||
expect(config.gap).toBe(5);
|
||||
|
||||
// Default grid and ticks are created
|
||||
expect(config.grid).toEqual({
|
||||
stroke: 'rgba(0,0,0,0.5)',
|
||||
width: 0.2,
|
||||
show: true,
|
||||
});
|
||||
expect(config.ticks).toEqual({
|
||||
width: 0.3,
|
||||
show: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets config values when provided', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
label: 'Time',
|
||||
show: false,
|
||||
side: 0,
|
||||
gap: 10,
|
||||
grid: {
|
||||
stroke: '#ff0000',
|
||||
width: 1,
|
||||
show: false,
|
||||
},
|
||||
ticks: {
|
||||
stroke: '#00ff00',
|
||||
width: 1,
|
||||
show: false,
|
||||
size: 10,
|
||||
},
|
||||
values: ['1', '2', '3'],
|
||||
space: 20,
|
||||
size: 100,
|
||||
stroke: '#0000ff',
|
||||
}),
|
||||
);
|
||||
const config = builder.getConfig();
|
||||
expect(config.scale).toBe('x');
|
||||
expect(config.label).toBe('Time');
|
||||
expect(config.show).toBe(false);
|
||||
expect(config.gap).toBe(10);
|
||||
expect(config.grid).toEqual({
|
||||
stroke: '#ff0000',
|
||||
width: 1,
|
||||
show: false,
|
||||
});
|
||||
expect(config.ticks).toEqual({
|
||||
stroke: '#00ff00',
|
||||
width: 1,
|
||||
show: false,
|
||||
size: 10,
|
||||
});
|
||||
expect(config.values).toEqual(['1', '2', '3']);
|
||||
expect(config.space).toBe(20);
|
||||
expect(config.size).toBe(100);
|
||||
expect(config.stroke).toBe('#0000ff');
|
||||
});
|
||||
|
||||
it('merges custom grid config over defaults and respects isDarkMode and isLogScale', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
isDarkMode: true,
|
||||
isLogScale: true,
|
||||
grid: {
|
||||
width: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.grid).toEqual({
|
||||
// stroke falls back to theme-based default when not provided
|
||||
stroke: 'rgba(231,233,237,0.3)',
|
||||
// provided width overrides default
|
||||
width: 1,
|
||||
// show falls back to default when not provided
|
||||
show: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses provided ticks config when present and falls back to defaults otherwise', () => {
|
||||
const customTicks = { width: 1, show: false };
|
||||
const withTicks = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
ticks: customTicks,
|
||||
}),
|
||||
);
|
||||
const withoutTicks = new UPlotAxisBuilder(createAxisProps());
|
||||
|
||||
expect(withTicks.getConfig().ticks).toBe(customTicks);
|
||||
expect(withoutTicks.getConfig().ticks).toEqual({
|
||||
width: 0.3,
|
||||
show: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses time-based X-axis values formatter for time-series like panels', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.values).toBe(uPlotXAxisValuesFormat);
|
||||
});
|
||||
|
||||
it('does not attach X-axis datetime formatter when panel type is not supported', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.LIST, // not in PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.values).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds Y-axis values formatter that delegates to getToolTipValue', () => {
|
||||
const yBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'y',
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = yBuilder.getConfig();
|
||||
expect(typeof config.values).toBe('function');
|
||||
|
||||
(getToolTipValue as jest.Mock).mockImplementation(
|
||||
(value: string, unit?: string, precision?: unknown) =>
|
||||
`formatted:${value}:${unit}:${precision}`,
|
||||
);
|
||||
|
||||
// Simulate uPlot calling the values formatter
|
||||
const valuesFn = (config.values as unknown) as (
|
||||
self: uPlot,
|
||||
vals: unknown[],
|
||||
) => string[];
|
||||
const result = valuesFn({} as uPlot, [1, null, 2, Number.NaN]);
|
||||
|
||||
expect(getToolTipValue).toHaveBeenCalledTimes(2);
|
||||
expect(getToolTipValue).toHaveBeenNthCalledWith(1, '1', 'ms', 3);
|
||||
expect(getToolTipValue).toHaveBeenNthCalledWith(2, '2', 'ms', 3);
|
||||
|
||||
// Null/NaN values should map to empty strings
|
||||
expect(result).toEqual(['formatted:1:ms:3', '', 'formatted:2:ms:3', '']);
|
||||
});
|
||||
|
||||
it('adds dynamic size calculator only for Y-axis when size is not provided', () => {
|
||||
const yBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'y',
|
||||
}),
|
||||
);
|
||||
const xBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
}),
|
||||
);
|
||||
|
||||
const yConfig = yBuilder.getConfig();
|
||||
const xConfig = xBuilder.getConfig();
|
||||
|
||||
expect(typeof yConfig.size).toBe('function');
|
||||
expect(xConfig.size).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses explicit size function when provided', () => {
|
||||
const sizeFn: uPlot.Axis.Size = jest.fn(() => 100) as uPlot.Axis.Size;
|
||||
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'y',
|
||||
size: sizeFn,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.size).toBe(sizeFn);
|
||||
});
|
||||
|
||||
it('builds stroke color based on stroke and isDarkMode', () => {
|
||||
const explicitStroke = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
stroke: '#ff0000',
|
||||
}),
|
||||
);
|
||||
const darkStroke = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
stroke: undefined,
|
||||
isDarkMode: true,
|
||||
}),
|
||||
);
|
||||
const lightStroke = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
stroke: undefined,
|
||||
isDarkMode: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(explicitStroke.getConfig().stroke).toBe('#ff0000');
|
||||
expect(darkStroke.getConfig().stroke).toBe('white');
|
||||
expect(lightStroke.getConfig().stroke).toBe('black');
|
||||
});
|
||||
|
||||
it('uses explicit values formatter when provided', () => {
|
||||
const customValues: uPlot.Axis.Values = jest.fn(() => ['a', 'b', 'c']);
|
||||
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'y',
|
||||
values: customValues,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.values).toBe(customValues);
|
||||
});
|
||||
|
||||
it('returns undefined values for scaleKey neither x nor y', () => {
|
||||
const builder = new UPlotAxisBuilder(createAxisProps({ scaleKey: 'custom' }));
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.values).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes space in config when provided', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'y', space: 50 }),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.space).toBe(50);
|
||||
});
|
||||
|
||||
it('includes PANEL_TYPES.BAR and PANEL_TYPES.TIME_SERIES in X-axis datetime formatter', () => {
|
||||
const barBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
}),
|
||||
);
|
||||
expect(barBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
|
||||
|
||||
const timeSeriesBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
);
|
||||
expect(timeSeriesBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
|
||||
});
|
||||
|
||||
it('should return the existing size when cycleNum > 1', () => {
|
||||
const builder = new UPlotAxisBuilder(createAxisProps({ scaleKey: 'y' }));
|
||||
|
||||
const config = builder.getConfig();
|
||||
const sizeFn = config.size;
|
||||
expect(typeof sizeFn).toBe('function');
|
||||
|
||||
const mockAxis = {
|
||||
_size: 80,
|
||||
ticks: { size: 10 },
|
||||
font: ['12px sans-serif'],
|
||||
};
|
||||
const mockSelf = ({
|
||||
axes: [mockAxis],
|
||||
ctx: { measureText: jest.fn(() => ({ width: 60 })), font: '' },
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const result = (sizeFn as (
|
||||
s: uPlot,
|
||||
v: string[],
|
||||
a: number,
|
||||
c: number,
|
||||
) => number)(
|
||||
mockSelf,
|
||||
['100', '200'],
|
||||
0,
|
||||
2, // cycleNum > 1
|
||||
);
|
||||
|
||||
expect(result).toBe(80);
|
||||
});
|
||||
|
||||
it('should invoke the size calculator and compute from text width when cycleNum <= 1', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'y', gap: 8 }),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const sizeFn = config.size;
|
||||
expect(typeof sizeFn).toBe('function');
|
||||
|
||||
const mockAxis = {
|
||||
ticks: { size: 12 },
|
||||
font: ['12px sans-serif'],
|
||||
};
|
||||
const measureText = jest.fn(() => ({ width: 48 }));
|
||||
const mockSelf = ({
|
||||
axes: [mockAxis],
|
||||
ctx: {
|
||||
measureText,
|
||||
get font() {
|
||||
return '';
|
||||
},
|
||||
set font(_v: string) {
|
||||
/* noop */
|
||||
},
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const result = (sizeFn as (
|
||||
s: uPlot,
|
||||
v: string[],
|
||||
a: number,
|
||||
c: number,
|
||||
) => number)(
|
||||
mockSelf,
|
||||
['10', '2000ms'],
|
||||
0,
|
||||
0, // cycleNum <= 1
|
||||
);
|
||||
|
||||
expect(measureText).toHaveBeenCalledWith('2000ms');
|
||||
expect(result).toBeGreaterThanOrEqual(12 + 8);
|
||||
});
|
||||
|
||||
it('merge updates axis props', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'y', label: 'Original' }),
|
||||
);
|
||||
|
||||
builder.merge({ label: 'Merged', yAxisUnit: 'bytes' });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.label).toBe('Merged');
|
||||
expect(config.values).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { SeriesProps } from '../types';
|
||||
import { DrawStyle, SelectionPreferencesSource } from '../types';
|
||||
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
|
||||
|
||||
// Mock only the real boundary that hits localStorage
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
() => ({
|
||||
getStoredSeriesVisibility: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
const getStoredSeriesVisibilityMock = jest.requireMock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
) as {
|
||||
getStoredSeriesVisibility: jest.Mock;
|
||||
};
|
||||
|
||||
describe('UPlotConfigBuilder', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const createSeriesProps = (
|
||||
overrides: Partial<SeriesProps> = {},
|
||||
): SeriesProps => ({
|
||||
scaleKey: 'y',
|
||||
label: 'Requests',
|
||||
colorMapping: {},
|
||||
drawStyle: DrawStyle.Line,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('returns correct save selection preference flag from constructor args', () => {
|
||||
const builder = new UPlotConfigBuilder({
|
||||
shouldSaveSelectionPreference: true,
|
||||
});
|
||||
|
||||
expect(builder.getShouldSaveSelectionPreference()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns widgetId from constructor args', () => {
|
||||
const builder = new UPlotConfigBuilder({ widgetId: 'widget-123' });
|
||||
|
||||
expect(builder.getWidgetId()).toBe('widget-123');
|
||||
});
|
||||
|
||||
it('sets tzDate from constructor and includes it in config', () => {
|
||||
const tzDate = (ts: number): Date => new Date(ts);
|
||||
const builder = new UPlotConfigBuilder({ tzDate });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.tzDate).toBe(tzDate);
|
||||
});
|
||||
|
||||
it('does not call onDragSelect for click without drag (width === 0)', () => {
|
||||
const onDragSelect = jest.fn();
|
||||
const builder = new UPlotConfigBuilder({ onDragSelect });
|
||||
|
||||
const config = builder.getConfig();
|
||||
const setSelectHooks = config.hooks?.setSelect ?? [];
|
||||
expect(setSelectHooks.length).toBe(1);
|
||||
|
||||
const uplotInstance = ({
|
||||
select: { left: 10, width: 0 },
|
||||
posToVal: jest.fn(),
|
||||
} as unknown) as uPlot;
|
||||
|
||||
// Simulate uPlot calling the hook
|
||||
const setSelectHook = setSelectHooks[0];
|
||||
setSelectHook!(uplotInstance);
|
||||
|
||||
expect(onDragSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onDragSelect with start and end times in milliseconds for a drag selection', () => {
|
||||
const onDragSelect = jest.fn();
|
||||
const builder = new UPlotConfigBuilder({ onDragSelect });
|
||||
|
||||
const config = builder.getConfig();
|
||||
const setSelectHooks = config.hooks?.setSelect ?? [];
|
||||
expect(setSelectHooks.length).toBe(1);
|
||||
|
||||
const posToVal = jest
|
||||
.fn()
|
||||
// left position
|
||||
.mockReturnValueOnce(100)
|
||||
// left + width
|
||||
.mockReturnValueOnce(110);
|
||||
|
||||
const uplotInstance = ({
|
||||
select: { left: 50, width: 20 },
|
||||
posToVal,
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const setSelectHook = setSelectHooks[0];
|
||||
setSelectHook!(uplotInstance);
|
||||
|
||||
expect(onDragSelect).toHaveBeenCalledTimes(1);
|
||||
// 100 and 110 seconds converted to milliseconds
|
||||
expect(onDragSelect).toHaveBeenCalledWith(100_000, 110_000);
|
||||
});
|
||||
|
||||
it('adds and removes hooks via addHook, and exposes them through getConfig', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const drawHook = jest.fn();
|
||||
|
||||
const remove = builder.addHook('draw', drawHook as uPlot.Hooks.Defs['draw']);
|
||||
|
||||
let config = builder.getConfig();
|
||||
expect(config.hooks?.draw).toContain(drawHook);
|
||||
|
||||
// Remove and ensure it no longer appears in config
|
||||
remove();
|
||||
config = builder.getConfig();
|
||||
expect(config.hooks?.draw ?? []).not.toContain(drawHook);
|
||||
});
|
||||
|
||||
it('adds axes, scales, and series and wires them into the final config', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
// Add axis and scale
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Requests' });
|
||||
builder.addScale({ scaleKey: 'y' });
|
||||
|
||||
// Add two series – legend indices should start from 1 (0 is the timestamp series)
|
||||
builder.addSeries(createSeriesProps({ label: 'Requests' }));
|
||||
builder.addSeries(createSeriesProps({ label: 'Errors' }));
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
// Axes
|
||||
expect(config.axes).toHaveLength(1);
|
||||
expect(config.axes?.[0].scale).toBe('y');
|
||||
|
||||
// Scales are returned as an object keyed by scaleKey
|
||||
expect(config.scales).toBeDefined();
|
||||
expect(Object.keys(config.scales ?? {})).toContain('y');
|
||||
|
||||
// Series: base timestamp + 2 data series
|
||||
expect(config.series).toHaveLength(3);
|
||||
// Base series (index 0) has a value formatter that returns empty string
|
||||
const baseSeries = config.series?.[0] as { value?: () => string };
|
||||
expect(typeof baseSeries?.value).toBe('function');
|
||||
expect(baseSeries?.value?.()).toBe('');
|
||||
|
||||
// Legend items align with series and carry label and color from series config
|
||||
const legendItems = builder.getLegendItems();
|
||||
expect(Object.keys(legendItems)).toEqual(['1', '2']);
|
||||
expect(legendItems[1].seriesIndex).toBe(1);
|
||||
expect(legendItems[1].label).toBe('Requests');
|
||||
expect(legendItems[2].label).toBe('Errors');
|
||||
});
|
||||
|
||||
it('merges axis when addAxis is called twice with same scaleKey', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Requests' });
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Updated Label', show: false });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.axes).toHaveLength(1);
|
||||
expect(config.axes?.[0].label).toBe('Updated Label');
|
||||
expect(config.axes?.[0].show).toBe(false);
|
||||
});
|
||||
|
||||
it('merges scale when addScale is called twice with same scaleKey', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.addScale({ scaleKey: 'y', min: 0 });
|
||||
builder.addScale({ scaleKey: 'y', max: 100 });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
// Only one scale entry for 'y' (merge path used, no duplicate added)
|
||||
expect(config.scales).toBeDefined();
|
||||
const scales = config.scales ?? {};
|
||||
expect(Object.keys(scales)).toEqual(['y']);
|
||||
expect(scales.y?.range).toBeDefined();
|
||||
});
|
||||
|
||||
it('restores visibility state from localStorage when selectionPreferencesSource is LOCAL_STORAGE', () => {
|
||||
// Index 0 = x-axis/time; indices 1,2 = data series (Requests, Errors). resolveSeriesVisibility matches by seriesIndex + seriesLabel.
|
||||
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue({
|
||||
labels: ['x-axis', 'Requests', 'Errors'],
|
||||
visibility: [true, true, false],
|
||||
});
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
|
||||
});
|
||||
|
||||
builder.addSeries(createSeriesProps({ label: 'Requests' }));
|
||||
builder.addSeries(createSeriesProps({ label: 'Errors' }));
|
||||
|
||||
const legendItems = builder.getLegendItems();
|
||||
|
||||
// When any series is hidden, legend visibility is driven by the stored map
|
||||
expect(legendItems[1].show).toBe(true);
|
||||
expect(legendItems[2].show).toBe(false);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const [, firstSeries, secondSeries] = config.series ?? [];
|
||||
|
||||
expect(firstSeries?.show).toBe(true);
|
||||
expect(secondSeries?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('does not attempt to read stored visibility when using in-memory preferences', () => {
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
|
||||
});
|
||||
|
||||
builder.addSeries(createSeriesProps({ label: 'Requests' }));
|
||||
|
||||
builder.getLegendItems();
|
||||
builder.getConfig();
|
||||
|
||||
expect(
|
||||
getStoredSeriesVisibilityMock.getStoredSeriesVisibility,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds thresholds only once per scale key', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const thresholdsOptions = {
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 100 }],
|
||||
};
|
||||
|
||||
builder.addThresholds(thresholdsOptions);
|
||||
builder.addThresholds(thresholdsOptions);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const drawHooks = config.hooks?.draw ?? [];
|
||||
|
||||
// Only a single draw hook should be registered for the same scaleKey
|
||||
expect(drawHooks.length).toBe(1);
|
||||
});
|
||||
|
||||
it('adds multiple thresholds when scale key is different', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const thresholdsOptions = {
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 100 }],
|
||||
};
|
||||
builder.addThresholds(thresholdsOptions);
|
||||
const thresholdsOptions2 = {
|
||||
scaleKey: 'y2',
|
||||
thresholds: [{ thresholdValue: 200 }],
|
||||
};
|
||||
builder.addThresholds(thresholdsOptions2);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const drawHooks = config.hooks?.draw ?? [];
|
||||
|
||||
// Two draw hooks should be registered for different scaleKeys
|
||||
expect(drawHooks.length).toBe(2);
|
||||
});
|
||||
|
||||
it('merges cursor configuration with defaults instead of replacing them', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.setCursor({
|
||||
drag: { setScale: false },
|
||||
});
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.cursor?.drag?.setScale).toBe(false);
|
||||
// Points configuration from DEFAULT_CURSOR_CONFIG should still be present
|
||||
expect(config.cursor?.points).toBeDefined();
|
||||
});
|
||||
|
||||
it('adds plugins and includes them in config', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const plugin: uPlot.Plugin = {
|
||||
opts: (): void => {},
|
||||
hooks: {},
|
||||
};
|
||||
|
||||
builder.addPlugin(plugin);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.plugins).toContain(plugin);
|
||||
});
|
||||
|
||||
it('sets padding, legend, focus, select, tzDate, bands and includes them in config', () => {
|
||||
const tzDate = (ts: number): Date => new Date(ts);
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const bands: uPlot.Band[] = [{ series: [1, 2], fill: (): string => '#000' }];
|
||||
|
||||
builder.setBands(bands);
|
||||
builder.setPadding([10, 20, 30, 40]);
|
||||
builder.setLegend({ show: true, live: true });
|
||||
builder.setFocus({ alpha: 0.5 });
|
||||
builder.setSelect({ left: 0, width: 0, top: 0, height: 0 });
|
||||
builder.setTzDate(tzDate);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.bands).toEqual(bands);
|
||||
expect(config.padding).toEqual([10, 20, 30, 40]);
|
||||
expect(config.legend).toEqual({ show: true, live: true });
|
||||
expect(config.focus).toEqual({ alpha: 0.5 });
|
||||
expect(config.select).toEqual({ left: 0, width: 0, top: 0, height: 0 });
|
||||
expect(config.tzDate).toBe(tzDate);
|
||||
});
|
||||
|
||||
it('does not include plugins when none added', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.plugins).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not include bands when empty', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.bands).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import * as scaleUtils from '../../utils/scale';
|
||||
import type { ScaleProps } from '../types';
|
||||
import { DistributionType } from '../types';
|
||||
import { UPlotScaleBuilder } from '../UPlotScaleBuilder';
|
||||
|
||||
const createScaleProps = (overrides: Partial<ScaleProps> = {}): ScaleProps => ({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
auto: undefined,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
softMin: undefined,
|
||||
softMax: undefined,
|
||||
distribution: DistributionType.Linear,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('UPlotScaleBuilder', () => {
|
||||
const getFallbackMinMaxSpy = jest.spyOn(
|
||||
scaleUtils,
|
||||
'getFallbackMinMaxTimeStamp',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initializes softMin/softMax correctly when both are 0 (treated as unset)', () => {
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
softMin: 0,
|
||||
softMax: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// Non-time scale so config path uses thresholds pipeline; we just care that
|
||||
// adjustSoftLimitsWithThresholds receives null soft limits instead of 0/0.
|
||||
const adjustSpy = jest.spyOn(scaleUtils, 'adjustSoftLimitsWithThresholds');
|
||||
|
||||
builder.getConfig();
|
||||
|
||||
expect(adjustSpy).toHaveBeenCalledWith(null, null, undefined, undefined);
|
||||
});
|
||||
|
||||
it('handles time scales using explicit min/max and rounds max down to the previous minute', () => {
|
||||
const min = 1_700_000_000; // seconds
|
||||
const max = 1_700_000_600; // seconds
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min,
|
||||
max,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const xScale = config.x;
|
||||
|
||||
expect(xScale.time).toBe(true);
|
||||
expect(xScale.auto).toBe(false);
|
||||
expect(Array.isArray(xScale.range)).toBe(true);
|
||||
|
||||
const [resolvedMin, resolvedMax] = xScale.range as [number, number];
|
||||
|
||||
// min is passed through
|
||||
expect(resolvedMin).toBe(min);
|
||||
|
||||
// max is coerced to "endTime - 1 minute" and rounded down to minute precision
|
||||
const oneMinuteAgoTimestamp = (max - 60) * 1000;
|
||||
const currentDate = new Date(oneMinuteAgoTimestamp);
|
||||
currentDate.setSeconds(0);
|
||||
currentDate.setMilliseconds(0);
|
||||
const expectedMax = Math.floor(currentDate.getTime() / 1000);
|
||||
|
||||
expect(resolvedMax).toBe(expectedMax);
|
||||
});
|
||||
|
||||
it('falls back to getFallbackMinMaxTimeStamp when time scale has no min/max', () => {
|
||||
getFallbackMinMaxSpy.mockReturnValue({
|
||||
fallbackMin: 100,
|
||||
fallbackMax: 200,
|
||||
});
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const [resolvedMin, resolvedMax] = config.x.range as [number, number];
|
||||
|
||||
expect(getFallbackMinMaxSpy).toHaveBeenCalled();
|
||||
expect(resolvedMin).toBe(100);
|
||||
// max is aligned to "fallbackMax - 60 seconds" minute boundary
|
||||
expect(resolvedMax).toBeLessThanOrEqual(200);
|
||||
expect(resolvedMax).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('pipes limits through soft-limit adjustment and log-scale normalization before range config', () => {
|
||||
const adjustSpy = jest.spyOn(scaleUtils, 'adjustSoftLimitsWithThresholds');
|
||||
const normalizeSpy = jest.spyOn(scaleUtils, 'normalizeLogScaleLimits');
|
||||
const getRangeConfigSpy = jest.spyOn(scaleUtils, 'getRangeConfig');
|
||||
|
||||
const thresholds = {
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 10 }],
|
||||
yAxisUnit: 'ms',
|
||||
};
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
softMin: 1,
|
||||
softMax: 5,
|
||||
min: 0,
|
||||
max: 100,
|
||||
distribution: DistributionType.Logarithmic,
|
||||
thresholds,
|
||||
logBase: 2,
|
||||
padMinBy: 0.1,
|
||||
padMaxBy: 0.2,
|
||||
}),
|
||||
);
|
||||
|
||||
builder.getConfig();
|
||||
|
||||
expect(adjustSpy).toHaveBeenCalledWith(1, 5, thresholds.thresholds, 'ms');
|
||||
expect(normalizeSpy).toHaveBeenCalledWith({
|
||||
distr: DistributionType.Logarithmic,
|
||||
logBase: 2,
|
||||
limits: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
softMin: expect.anything(),
|
||||
softMax: expect.anything(),
|
||||
},
|
||||
});
|
||||
expect(getRangeConfigSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('computes distribution config for non-time scales and wires range function when range is not provided', () => {
|
||||
const createRangeFnSpy = jest.spyOn(scaleUtils, 'createRangeFunction');
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
distribution: DistributionType.Linear,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const yScale = config.y;
|
||||
|
||||
expect(createRangeFnSpy).toHaveBeenCalled();
|
||||
|
||||
// range should be a function when not provided explicitly
|
||||
expect(typeof yScale.range).toBe('function');
|
||||
|
||||
// distribution config should be applied
|
||||
expect(yScale.distr).toBeDefined();
|
||||
expect(yScale.log).toBeDefined();
|
||||
});
|
||||
|
||||
it('respects explicit range function when provided on props', () => {
|
||||
const explicitRange: uPlot.Scale.Range = jest.fn(() => [
|
||||
0,
|
||||
10,
|
||||
]) as uPlot.Scale.Range;
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'y',
|
||||
range: explicitRange,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const yScale = config.y;
|
||||
|
||||
expect(yScale.range).toBe(explicitRange);
|
||||
});
|
||||
|
||||
it('derives auto flag when not explicitly provided, based on hasFixedRange and time', () => {
|
||||
const getRangeConfigSpy = jest.spyOn(scaleUtils, 'getRangeConfig');
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
min: 0,
|
||||
max: 100,
|
||||
time: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const yScale = config.y;
|
||||
|
||||
expect(getRangeConfigSpy).toHaveBeenCalled();
|
||||
// For non-time scale with fixed min/max, hasFixedRange is true → auto should remain false
|
||||
expect(yScale.auto).toBe(false);
|
||||
});
|
||||
|
||||
it('merge updates internal min/max/soft limits while preserving other props', () => {
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'y',
|
||||
min: 0,
|
||||
max: 10,
|
||||
softMin: 1,
|
||||
softMax: 9,
|
||||
time: false,
|
||||
}),
|
||||
);
|
||||
|
||||
builder.merge({
|
||||
min: 2,
|
||||
softMax: undefined,
|
||||
});
|
||||
|
||||
expect(builder.props.min).toBe(2);
|
||||
expect(builder.props.softMax).toBe(undefined);
|
||||
expect(builder.props.max).toBe(10);
|
||||
expect(builder.props.softMin).toBe(1);
|
||||
expect(builder.props.time).toBe(false);
|
||||
expect(builder.props.scaleKey).toBe('y');
|
||||
expect(builder.props.distribution).toBe(DistributionType.Linear);
|
||||
expect(builder.props.thresholds).toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { SeriesProps } from '../types';
|
||||
import {
|
||||
DrawStyle,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
VisibilityMode,
|
||||
} from '../types';
|
||||
import { UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
|
||||
|
||||
const createBaseProps = (
|
||||
overrides: Partial<SeriesProps> = {},
|
||||
): SeriesProps => ({
|
||||
scaleKey: 'y',
|
||||
label: 'Requests',
|
||||
colorMapping: {},
|
||||
drawStyle: DrawStyle.Line,
|
||||
isDarkMode: false,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
interface MockPath extends uPlot.Series.Paths {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
describe('UPlotSeriesBuilder', () => {
|
||||
it('maps basic props into uPlot series config', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: 'Latency',
|
||||
spanGaps: true,
|
||||
show: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.scale).toBe('y');
|
||||
expect(config.label).toBe('Latency');
|
||||
expect(config.spanGaps).toBe(true);
|
||||
expect(config.show).toBe(false);
|
||||
expect(config.pxAlign).toBe(true);
|
||||
expect(typeof config.value).toBe('function');
|
||||
});
|
||||
|
||||
it('uses explicit lineColor when provided, regardless of mapping', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineColor: '#ff00ff',
|
||||
colorMapping: { Requests: '#00ff00' },
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.stroke).toBe('#ff00ff');
|
||||
});
|
||||
|
||||
it('falls back to theme colors when no label is provided', () => {
|
||||
const darkBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: undefined,
|
||||
isDarkMode: true,
|
||||
lineColor: undefined,
|
||||
}),
|
||||
);
|
||||
const lightBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: undefined,
|
||||
isDarkMode: false,
|
||||
lineColor: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const darkConfig = darkBuilder.getConfig();
|
||||
const lightConfig = lightBuilder.getConfig();
|
||||
|
||||
expect(darkConfig.stroke).toBe(themeColors.white);
|
||||
expect(lightConfig.stroke).toBe(themeColors.black);
|
||||
});
|
||||
|
||||
it('uses colorMapping when available and no explicit lineColor is provided', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: 'Requests',
|
||||
colorMapping: { Requests: '#123456' },
|
||||
lineColor: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.stroke).toBe('#123456');
|
||||
});
|
||||
|
||||
it('passes through a custom pathBuilder when provided', () => {
|
||||
const customPaths = (jest.fn() as unknown) as uPlot.Series.PathBuilder;
|
||||
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
pathBuilder: customPaths,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.paths).toBe(customPaths);
|
||||
});
|
||||
|
||||
it('does not build line paths when drawStyle is Points, but still renders points', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Points,
|
||||
pointSize: 4,
|
||||
lineWidth: 2,
|
||||
lineColor: '#aa00aa',
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(typeof config.paths).toBe('function');
|
||||
expect(config.paths && config.paths({} as uPlot, 1, 0, 10)).toBeNull();
|
||||
|
||||
expect(config.points).toBeDefined();
|
||||
expect(config.points?.stroke).toBe('#aa00aa');
|
||||
expect(config.points?.fill).toBe('#aa00aa');
|
||||
expect(config.points?.show).toBe(true);
|
||||
expect(config.points?.size).toBe(4);
|
||||
});
|
||||
|
||||
it('derives point size based on lineWidth and pointSize', () => {
|
||||
const smallPointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineWidth: 4,
|
||||
pointSize: 2,
|
||||
}),
|
||||
);
|
||||
const largePointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineWidth: 2,
|
||||
pointSize: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
const smallConfig = smallPointsBuilder.getConfig();
|
||||
const largeConfig = largePointsBuilder.getConfig();
|
||||
|
||||
expect(smallConfig.points?.size).toBeUndefined();
|
||||
expect(largeConfig.points?.size).toBe(4);
|
||||
});
|
||||
|
||||
it('uses pointsBuilder when provided instead of default visibility logic', () => {
|
||||
const pointsBuilder: uPlot.Series.Points.Show = jest.fn(
|
||||
() => true,
|
||||
) as uPlot.Series.Points.Show;
|
||||
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
pointsBuilder,
|
||||
drawStyle: DrawStyle.Line,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.points?.show).toBe(pointsBuilder);
|
||||
});
|
||||
|
||||
it('respects VisibilityMode for point visibility when no custom pointsBuilder is given', () => {
|
||||
const neverPointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
showPoints: VisibilityMode.Never,
|
||||
}),
|
||||
);
|
||||
const alwaysPointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
showPoints: VisibilityMode.Always,
|
||||
}),
|
||||
);
|
||||
|
||||
const neverConfig = neverPointsBuilder.getConfig();
|
||||
const alwaysConfig = alwaysPointsBuilder.getConfig();
|
||||
|
||||
expect(neverConfig.points?.show).toBe(false);
|
||||
expect(alwaysConfig.points?.show).toBe(true);
|
||||
});
|
||||
|
||||
it('applies LineStyle.Dashed and lineCap to line config', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineStyle: LineStyle.Dashed,
|
||||
lineCap: 'round' as CanvasLineCap,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.dash).toEqual([10, 10]);
|
||||
expect(config.cap).toBe('round');
|
||||
});
|
||||
|
||||
it('builds default paths for Line drawStyle and invokes the path builder', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.Linear,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
const result = config.paths?.({} as uPlot, 1, 0, 10);
|
||||
expect((result as MockPath).name).toBe('linear');
|
||||
});
|
||||
|
||||
it('uses StepBefore and StepAfter interpolation for line paths', () => {
|
||||
const stepBeforeBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.StepBefore,
|
||||
}),
|
||||
);
|
||||
const stepAfterBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.StepAfter,
|
||||
}),
|
||||
);
|
||||
|
||||
const stepBeforeConfig = stepBeforeBuilder.getConfig();
|
||||
const stepAfterConfig = stepAfterBuilder.getConfig();
|
||||
const stepBeforePath = stepBeforeConfig.paths?.({} as uPlot, 1, 0, 5);
|
||||
const stepAfterPath = stepAfterConfig.paths?.({} as uPlot, 1, 0, 5);
|
||||
expect((stepBeforePath as MockPath).name).toBe('stepped-(-1)');
|
||||
expect((stepAfterPath as MockPath).name).toBe('stepped-(1)');
|
||||
});
|
||||
|
||||
it('defaults to spline interpolation when lineInterpolation is Spline or undefined', () => {
|
||||
const splineBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.Spline,
|
||||
}),
|
||||
);
|
||||
const defaultBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({ drawStyle: DrawStyle.Line }),
|
||||
);
|
||||
|
||||
const splineConfig = splineBuilder.getConfig();
|
||||
const defaultConfig = defaultBuilder.getConfig();
|
||||
|
||||
const splinePath = splineConfig.paths?.({} as uPlot, 1, 0, 10);
|
||||
const defaultPath = defaultConfig.paths?.({} as uPlot, 1, 0, 10);
|
||||
|
||||
expect((splinePath as MockPath).name).toBe('spline');
|
||||
expect((defaultPath as MockPath).name).toBe('spline');
|
||||
});
|
||||
|
||||
it('uses generateColor when label has no colorMapping and no lineColor', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: 'CustomSeries',
|
||||
colorMapping: {},
|
||||
lineColor: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.stroke).toBe('#E64A3C');
|
||||
});
|
||||
|
||||
it('passes through pointsFilter when provided', () => {
|
||||
const pointsFilter: uPlot.Series.Points.Filter = jest.fn(
|
||||
(_self, _seriesIdx, _show) => null,
|
||||
);
|
||||
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
pointsFilter,
|
||||
drawStyle: DrawStyle.Line,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.points?.filter).toBe(pointsFilter);
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,7 @@ export enum LineStyle {
|
||||
export enum DrawStyle {
|
||||
Line = 'line',
|
||||
Points = 'points',
|
||||
Bar = 'bar',
|
||||
}
|
||||
|
||||
export enum LineInterpolation {
|
||||
@@ -128,7 +129,7 @@ export enum VisibilityMode {
|
||||
export interface SeriesProps {
|
||||
scaleKey: string;
|
||||
label?: string;
|
||||
|
||||
panelType: PANEL_TYPES;
|
||||
colorMapping: Record<string, string>;
|
||||
drawStyle: DrawStyle;
|
||||
pathBuilder?: Series.PathBuilder;
|
||||
|
||||
395
frontend/src/lib/uPlotV2/context/__tests__/PlotContext.test.tsx
Normal file
395
frontend/src/lib/uPlotV2/context/__tests__/PlotContext.test.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { updateSeriesVisibilityToLocalStorage } from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
|
||||
import {
|
||||
PlotContextProvider,
|
||||
usePlotContext,
|
||||
} from 'lib/uPlotV2/context/PlotContext';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
() => ({
|
||||
updateSeriesVisibilityToLocalStorage: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
const mockUpdateSeriesVisibilityToLocalStorage = updateSeriesVisibilityToLocalStorage as jest.MockedFunction<
|
||||
typeof updateSeriesVisibilityToLocalStorage
|
||||
>;
|
||||
|
||||
interface MockSeries extends Partial<uPlot.Series> {
|
||||
label?: string;
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
const createMockPlot = (series: MockSeries[] = []): uPlot =>
|
||||
(({
|
||||
series,
|
||||
batch: jest.fn((fn: () => void) => fn()),
|
||||
setSeries: jest.fn(),
|
||||
} as unknown) as uPlot);
|
||||
|
||||
interface TestComponentProps {
|
||||
plot?: uPlot;
|
||||
widgetId?: string;
|
||||
shouldSaveSelectionPreference?: boolean;
|
||||
}
|
||||
|
||||
const TestComponent = ({
|
||||
plot,
|
||||
widgetId,
|
||||
shouldSaveSelectionPreference,
|
||||
}: TestComponentProps): JSX.Element => {
|
||||
const {
|
||||
setPlotContextInitialState,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
onToggleSeriesVisibility,
|
||||
onToggleSeriesOnOff,
|
||||
onFocusSeries,
|
||||
} = usePlotContext();
|
||||
const handleInit = (): void => {
|
||||
if (
|
||||
!plot ||
|
||||
!widgetId ||
|
||||
typeof shouldSaveSelectionPreference !== 'boolean'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPlotContextInitialState({
|
||||
uPlotInstance: plot,
|
||||
widgetId,
|
||||
shouldSaveSelectionPreference,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button type="button" data-testid="init" onClick={handleInit}>
|
||||
Init
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sync-visibility"
|
||||
onClick={(): void => syncSeriesVisibilityToLocalStorage()}
|
||||
>
|
||||
Sync visibility
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle-visibility"
|
||||
onClick={(): void => onToggleSeriesVisibility(1)}
|
||||
>
|
||||
Toggle visibility
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle-on-off-1"
|
||||
onClick={(): void => onToggleSeriesOnOff(1)}
|
||||
>
|
||||
Toggle on/off 1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle-on-off-5"
|
||||
onClick={(): void => onToggleSeriesOnOff(5)}
|
||||
>
|
||||
Toggle on/off 5
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="focus-series"
|
||||
onClick={(): void => onFocusSeries(1)}
|
||||
>
|
||||
Focus series
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe('PlotContext', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('throws when usePlotContext is used outside provider', () => {
|
||||
const Consumer = (): JSX.Element => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
usePlotContext();
|
||||
return <div />;
|
||||
};
|
||||
|
||||
expect(() => render(<Consumer />)).toThrow(
|
||||
'Should be used inside the context',
|
||||
);
|
||||
});
|
||||
|
||||
it('syncSeriesVisibilityToLocalStorage does nothing without plot or widgetId', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('sync-visibility'));
|
||||
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('syncSeriesVisibilityToLocalStorage serializes series visibility to localStorage helper', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
]);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
widgetId="widget-123"
|
||||
shouldSaveSelectionPreference
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('sync-visibility'));
|
||||
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledWith(
|
||||
'widget-123',
|
||||
[
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: false },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
describe('onToggleSeriesVisibility', () => {
|
||||
it('does nothing when plot instance is not set', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('toggle-visibility'));
|
||||
|
||||
// No errors and no calls to localStorage helper
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('highlights a single series and saves visibility when preferences are enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const series: MockSeries[] = [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
widgetId="widget-visibility"
|
||||
shouldSaveSelectionPreference
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('toggle-visibility'));
|
||||
|
||||
const setSeries = (plot.setSeries as jest.Mock).mock.calls;
|
||||
|
||||
// index 0 is skipped, so we expect calls for 1 and 2
|
||||
expect(setSeries).toEqual([
|
||||
[1, { show: true }],
|
||||
[2, { show: false }],
|
||||
]);
|
||||
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledWith(
|
||||
'widget-visibility',
|
||||
[
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('resets visibility for all series when toggling the same index again', async () => {
|
||||
const user = userEvent.setup();
|
||||
const series: MockSeries[] = [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
widgetId="widget-reset"
|
||||
shouldSaveSelectionPreference
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('toggle-visibility'));
|
||||
|
||||
(plot.setSeries as jest.Mock).mockClear();
|
||||
|
||||
await user.click(screen.getByTestId('toggle-visibility'));
|
||||
|
||||
const setSeries = (plot.setSeries as jest.Mock).mock.calls;
|
||||
|
||||
// After reset, all non-zero series should be shown
|
||||
expect(setSeries).toEqual([
|
||||
[1, { show: true }],
|
||||
[2, { show: true }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onToggleSeriesOnOff', () => {
|
||||
it('does nothing when plot instance is not set', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('toggle-on-off-1'));
|
||||
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles series show flag and saves visibility when preferences are enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const series: MockSeries[] = [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
widgetId="widget-toggle"
|
||||
shouldSaveSelectionPreference
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-1'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: false });
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalledWith(
|
||||
'widget-toggle',
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not toggle when target series does not exist', async () => {
|
||||
const user = userEvent.setup();
|
||||
const series: MockSeries[] = [{ label: 'x-axis', show: true }];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
widgetId="widget-missing-series"
|
||||
shouldSaveSelectionPreference
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-5'));
|
||||
|
||||
expect(plot.setSeries).not.toHaveBeenCalled();
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not persist visibility when preferences flag is disabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const series: MockSeries[] = [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
widgetId="widget-no-persist"
|
||||
shouldSaveSelectionPreference={false}
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('toggle-on-off-1'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: false });
|
||||
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onFocusSeries', () => {
|
||||
it('does nothing when plot instance is not set', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent />
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('focus-series'));
|
||||
});
|
||||
|
||||
it('sets focus on the given series index', async () => {
|
||||
const user = userEvent.setup();
|
||||
const plot = createMockPlot([
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
]);
|
||||
|
||||
render(
|
||||
<PlotContextProvider>
|
||||
<TestComponent
|
||||
plot={plot}
|
||||
widgetId="widget-focus"
|
||||
shouldSaveSelectionPreference={false}
|
||||
/>
|
||||
</PlotContextProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('init'));
|
||||
await user.click(screen.getByTestId('focus-series'));
|
||||
|
||||
expect(plot.setSeries).toHaveBeenCalledWith(1, { focus: true }, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
|
||||
import { useLegendActions } from 'lib/uPlotV2/hooks/useLegendActions';
|
||||
|
||||
jest.mock('lib/uPlotV2/context/PlotContext');
|
||||
|
||||
const mockUsePlotContext = usePlotContext as jest.MockedFunction<
|
||||
typeof usePlotContext
|
||||
>;
|
||||
|
||||
describe('useLegendActions', () => {
|
||||
let onToggleSeriesVisibility: jest.Mock;
|
||||
let onToggleSeriesOnOff: jest.Mock;
|
||||
let onFocusSeriesPlot: jest.Mock;
|
||||
let setPlotContextInitialState: jest.Mock;
|
||||
let syncSeriesVisibilityToLocalStorage: jest.Mock;
|
||||
let setFocusedSeriesIndexMock: jest.Mock;
|
||||
let cancelAnimationFrameSpy: jest.SpyInstance<void, [handle: number]>;
|
||||
|
||||
beforeAll(() => {
|
||||
jest
|
||||
.spyOn(global, 'requestAnimationFrame')
|
||||
.mockImplementation((cb: FrameRequestCallback): number => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
|
||||
cancelAnimationFrameSpy = jest
|
||||
.spyOn(global, 'cancelAnimationFrame')
|
||||
.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
onToggleSeriesVisibility = jest.fn();
|
||||
onToggleSeriesOnOff = jest.fn();
|
||||
onFocusSeriesPlot = jest.fn();
|
||||
setPlotContextInitialState = jest.fn();
|
||||
syncSeriesVisibilityToLocalStorage = jest.fn();
|
||||
setFocusedSeriesIndexMock = jest.fn();
|
||||
|
||||
mockUsePlotContext.mockReturnValue({
|
||||
onToggleSeriesVisibility,
|
||||
onToggleSeriesOnOff,
|
||||
onFocusSeries: onFocusSeriesPlot,
|
||||
setPlotContextInitialState,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
});
|
||||
|
||||
cancelAnimationFrameSpy.mockClear();
|
||||
});
|
||||
|
||||
const createMouseEvent = (options: {
|
||||
legendItemId?: number;
|
||||
isMarker?: boolean;
|
||||
}): any => {
|
||||
const { legendItemId, isMarker = false } = options;
|
||||
|
||||
return {
|
||||
target: {
|
||||
dataset: {
|
||||
...(isMarker ? { isLegendMarker: 'true' } : {}),
|
||||
},
|
||||
closest: jest.fn(() =>
|
||||
legendItemId !== undefined
|
||||
? { dataset: { legendItemId: String(legendItemId) } }
|
||||
: null,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('onLegendClick', () => {
|
||||
it('toggles series visibility when clicking on legend label', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendClick(createMouseEvent({ legendItemId: 0 }));
|
||||
|
||||
expect(onToggleSeriesVisibility).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleSeriesVisibility).toHaveBeenCalledWith(0);
|
||||
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles series on/off when clicking on marker', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendClick(
|
||||
createMouseEvent({ legendItemId: 0, isMarker: true }),
|
||||
);
|
||||
|
||||
expect(onToggleSeriesOnOff).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(0);
|
||||
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when click target is not inside a legend item', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendClick(createMouseEvent({}));
|
||||
|
||||
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
|
||||
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onFocusSeries', () => {
|
||||
it('schedules focus update and calls plot focus handler via mouse move', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(0);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('cancels previous animation frame before scheduling new one on subsequent mouse moves', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLegendMouseMove', () => {
|
||||
it('focuses new series when hovering over different legend item', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(1);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does nothing when hovering over already focused series', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
|
||||
expect(setFocusedSeriesIndexMock).not.toHaveBeenCalled();
|
||||
expect(onFocusSeriesPlot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLegendMouseLeave', () => {
|
||||
it('cancels pending animation frame and clears focus state', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current.onLegendMouseLeave();
|
||||
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(null);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
192
frontend/src/lib/uPlotV2/hooks/__tests__/useLegendsSync.test.ts
Normal file
192
frontend/src/lib/uPlotV2/hooks/__tests__/useLegendsSync.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { act, cleanup, renderHook } from '@testing-library/react';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
|
||||
describe('useLegendsSync', () => {
|
||||
let requestAnimationFrameSpy: jest.SpyInstance<
|
||||
number,
|
||||
[callback: FrameRequestCallback]
|
||||
>;
|
||||
let cancelAnimationFrameSpy: jest.SpyInstance<void, [handle: number]>;
|
||||
|
||||
beforeAll(() => {
|
||||
requestAnimationFrameSpy = jest
|
||||
.spyOn(global, 'requestAnimationFrame')
|
||||
.mockImplementation((cb: FrameRequestCallback): number => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
|
||||
cancelAnimationFrameSpy = jest
|
||||
.spyOn(global, 'cancelAnimationFrame')
|
||||
.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createMockConfig = (
|
||||
legendItems: Record<number, LegendItem>,
|
||||
): {
|
||||
config: UPlotConfigBuilder;
|
||||
invokeSetSeries: (
|
||||
seriesIndex: number | null,
|
||||
opts: { show?: boolean; focus?: boolean },
|
||||
fireHook?: boolean,
|
||||
) => void;
|
||||
} => {
|
||||
let setSeriesHandler:
|
||||
| ((u: uPlot, seriesIndex: number | null, opts: uPlot.Series) => void)
|
||||
| null = null;
|
||||
|
||||
const config = ({
|
||||
getLegendItems: jest.fn(() => legendItems),
|
||||
addHook: jest.fn(
|
||||
(
|
||||
hookName: string,
|
||||
handler: (
|
||||
u: uPlot,
|
||||
seriesIndex: number | null,
|
||||
opts: uPlot.Series,
|
||||
) => void,
|
||||
) => {
|
||||
if (hookName === 'setSeries') {
|
||||
setSeriesHandler = handler;
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
setSeriesHandler = null;
|
||||
};
|
||||
},
|
||||
),
|
||||
} as unknown) as UPlotConfigBuilder;
|
||||
|
||||
const invokeSetSeries = (
|
||||
seriesIndex: number | null,
|
||||
opts: { show?: boolean; focus?: boolean },
|
||||
): void => {
|
||||
if (setSeriesHandler) {
|
||||
setSeriesHandler({} as uPlot, seriesIndex, { ...opts });
|
||||
}
|
||||
};
|
||||
|
||||
return { config, invokeSetSeries };
|
||||
};
|
||||
|
||||
it('initializes legend items from config', () => {
|
||||
const initialItems: Record<number, LegendItem> = {
|
||||
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
|
||||
2: { seriesIndex: 2, label: 'Memory', show: false, color: '#0f0' },
|
||||
};
|
||||
|
||||
const { config } = createMockConfig(initialItems);
|
||||
|
||||
const { result } = renderHook(() => useLegendsSync({ config }));
|
||||
|
||||
expect(config.getLegendItems).toHaveBeenCalledTimes(1);
|
||||
expect(config.addHook).toHaveBeenCalledWith(
|
||||
'setSeries',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
expect(result.current.legendItemsMap).toEqual(initialItems);
|
||||
});
|
||||
|
||||
it('updates focusedSeriesIndex when a series gains focus via setSeries by default', async () => {
|
||||
const initialItems: Record<number, LegendItem> = {
|
||||
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
|
||||
};
|
||||
|
||||
const { config, invokeSetSeries } = createMockConfig(initialItems);
|
||||
|
||||
const { result } = renderHook(() => useLegendsSync({ config }));
|
||||
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
invokeSetSeries(1, { focus: true });
|
||||
});
|
||||
|
||||
expect(result.current.focusedSeriesIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('does not update focusedSeriesIndex when subscribeToFocusChange is false', () => {
|
||||
const initialItems: Record<number, LegendItem> = {
|
||||
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
|
||||
};
|
||||
|
||||
const { config, invokeSetSeries } = createMockConfig(initialItems);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useLegendsSync({ config, subscribeToFocusChange: false }),
|
||||
);
|
||||
|
||||
invokeSetSeries(1, { focus: true });
|
||||
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('updates legendItemsMap visibility when show changes for a series', async () => {
|
||||
const initialItems: Record<number, LegendItem> = {
|
||||
0: { seriesIndex: 0, label: 'x-axis', show: true, color: '#000' },
|
||||
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
|
||||
};
|
||||
|
||||
const { config, invokeSetSeries } = createMockConfig(initialItems);
|
||||
|
||||
const { result } = renderHook(() => useLegendsSync({ config }));
|
||||
|
||||
// Toggle visibility of series 1
|
||||
await act(async () => {
|
||||
invokeSetSeries(1, { show: false });
|
||||
});
|
||||
|
||||
expect(result.current.legendItemsMap[1].show).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores visibility updates for unknown legend items or unchanged show values', () => {
|
||||
const initialItems: Record<number, LegendItem> = {
|
||||
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
|
||||
};
|
||||
|
||||
const { config, invokeSetSeries } = createMockConfig(initialItems);
|
||||
|
||||
const { result } = renderHook(() => useLegendsSync({ config }));
|
||||
|
||||
const before = result.current.legendItemsMap;
|
||||
|
||||
// Unknown series index
|
||||
invokeSetSeries(5, { show: false });
|
||||
// Unchanged visibility for existing item
|
||||
invokeSetSeries(1, { show: true });
|
||||
|
||||
const after = result.current.legendItemsMap;
|
||||
expect(after).toEqual(before);
|
||||
});
|
||||
|
||||
it('cancels pending visibility RAF on unmount', () => {
|
||||
const initialItems: Record<number, LegendItem> = {
|
||||
1: { seriesIndex: 1, label: 'CPU', show: true, color: '#f00' },
|
||||
};
|
||||
|
||||
const { config, invokeSetSeries } = createMockConfig(initialItems);
|
||||
|
||||
// Override RAF to not immediately invoke callback so we can assert cancellation
|
||||
requestAnimationFrameSpy.mockImplementationOnce(() => 42);
|
||||
|
||||
const { unmount } = renderHook(() => useLegendsSync({ config }));
|
||||
|
||||
invokeSetSeries(1, { show: false });
|
||||
|
||||
unmount();
|
||||
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(42);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,13 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import type {
|
||||
CSSProperties,
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { TooltipRenderArgs } from '../../components/types';
|
||||
import { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
|
||||
import type { TooltipRenderArgs } from '../../components/types';
|
||||
import type { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
|
||||
|
||||
export const TOOLTIP_OFFSET = 10;
|
||||
|
||||
@@ -17,7 +23,7 @@ export interface TooltipViewState {
|
||||
isHovering: boolean;
|
||||
isPinned: boolean;
|
||||
dismiss: () => void;
|
||||
contents?: React.ReactNode;
|
||||
contents?: ReactNode;
|
||||
}
|
||||
|
||||
export interface TooltipLayoutInfo {
|
||||
@@ -31,7 +37,7 @@ export interface TooltipPluginProps {
|
||||
canPinTooltip?: boolean;
|
||||
syncMode?: DashboardCursorSync;
|
||||
syncKey?: string;
|
||||
render: (args: TooltipRenderArgs) => React.ReactNode;
|
||||
render: (args: TooltipRenderArgs) => ReactNode;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
@@ -75,13 +81,11 @@ export interface TooltipControllerState {
|
||||
*/
|
||||
export interface TooltipControllerContext {
|
||||
controller: TooltipControllerState;
|
||||
layoutRef: React.MutableRefObject<TooltipLayoutInfo | undefined>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
rafId: React.MutableRefObject<number | null>;
|
||||
layoutRef: MutableRefObject<TooltipLayoutInfo | undefined>;
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
rafId: MutableRefObject<number | null>;
|
||||
updateState: (updates: Partial<TooltipViewState>) => void;
|
||||
renderRef: React.MutableRefObject<
|
||||
(args: TooltipRenderArgs) => React.ReactNode
|
||||
>;
|
||||
renderRef: MutableRefObject<(args: TooltipRenderArgs) => ReactNode>;
|
||||
syncMode: DashboardCursorSync;
|
||||
syncKey: string;
|
||||
canPinTooltip: boolean;
|
||||
|
||||
484
frontend/src/lib/uPlotV2/plugins/__tests__/TooltipPlugin.test.ts
Normal file
484
frontend/src/lib/uPlotV2/plugins/__tests__/TooltipPlugin.test.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import React from 'react';
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from 'tests/test-utils';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { TooltipRenderArgs } from '../../components/types';
|
||||
import { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
|
||||
import TooltipPlugin from '../TooltipPlugin/TooltipPlugin';
|
||||
import { DashboardCursorSync } from '../TooltipPlugin/types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type HookHandler = (...args: unknown[]) => void;
|
||||
|
||||
class TestConfigBuilder extends UPlotConfigBuilder {
|
||||
public registeredHooks: { type: string; handler: HookHandler }[] = [];
|
||||
|
||||
public removeCallbacks: jest.Mock[] = [];
|
||||
|
||||
// Override addHook so we can:
|
||||
// - capture handlers by hook name for tests
|
||||
// - return removable jest mocks to assert cleanup
|
||||
public addHook<T extends keyof uPlot.Hooks.Defs>(
|
||||
type: T,
|
||||
hook: uPlot.Hooks.Defs[T],
|
||||
): () => void {
|
||||
this.registeredHooks.push({
|
||||
type: String(type),
|
||||
handler: hook as HookHandler,
|
||||
});
|
||||
const remove = jest.fn();
|
||||
this.removeCallbacks.push(remove);
|
||||
return remove;
|
||||
}
|
||||
}
|
||||
|
||||
type ConfigMock = TestConfigBuilder;
|
||||
|
||||
function createConfigMock(): ConfigMock {
|
||||
return new TestConfigBuilder();
|
||||
}
|
||||
|
||||
function getHandler(config: ConfigMock, hookName: string): HookHandler {
|
||||
const entry = config.registeredHooks.find((h) => h.type === hookName);
|
||||
if (!entry) {
|
||||
throw new Error(`Hook "${hookName}" was not registered on config`);
|
||||
}
|
||||
return entry.handler;
|
||||
}
|
||||
|
||||
function createFakePlot(): {
|
||||
over: HTMLDivElement;
|
||||
setCursor: jest.Mock<void, [uPlot.Cursor]>;
|
||||
cursor: { event: Record<string, unknown> };
|
||||
} {
|
||||
return {
|
||||
over: document.createElement('div'),
|
||||
setCursor: jest.fn(),
|
||||
cursor: { event: {} },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TooltipPlugin', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
(callback as FrameRequestCallback)(0);
|
||||
return 0;
|
||||
});
|
||||
jest
|
||||
.spyOn(window, 'cancelAnimationFrame')
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Shorthand: render the plugin, initialise a fake plot, and trigger a
|
||||
* series focus so the tooltip becomes visible. Returns the fake plot
|
||||
* instance for further interaction (e.g. clicking the overlay).
|
||||
*/
|
||||
function renderAndActivateHover(
|
||||
config: ConfigMock,
|
||||
renderFn: (
|
||||
args: TooltipRenderArgs,
|
||||
) => React.ReactNode = (): React.ReactNode =>
|
||||
React.createElement('div', null, 'tooltip-body'),
|
||||
extraProps: Partial<React.ComponentProps<typeof TooltipPlugin>> = {},
|
||||
): ReturnType<typeof createFakePlot> {
|
||||
render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: renderFn,
|
||||
syncMode: DashboardCursorSync.None,
|
||||
...extraProps,
|
||||
}),
|
||||
);
|
||||
|
||||
const fakePlot = createFakePlot();
|
||||
const initHandler = getHandler(config, 'init');
|
||||
const setSeriesHandler = getHandler(config, 'setSeries');
|
||||
|
||||
act(() => {
|
||||
initHandler(fakePlot);
|
||||
setSeriesHandler(fakePlot, 1, { focus: true });
|
||||
});
|
||||
|
||||
return fakePlot;
|
||||
}
|
||||
|
||||
// ---- Initial state --------------------------------------------------------
|
||||
|
||||
describe('before any interaction', () => {
|
||||
it('does not render anything when there is no active hover', () => {
|
||||
const config = createConfigMock();
|
||||
|
||||
render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: () => React.createElement('div', null, 'tooltip-body'),
|
||||
syncMode: DashboardCursorSync.None,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
|
||||
});
|
||||
|
||||
it('registers all required uPlot hooks on mount', () => {
|
||||
const config = createConfigMock();
|
||||
|
||||
render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: () => null,
|
||||
syncMode: DashboardCursorSync.None,
|
||||
}),
|
||||
);
|
||||
|
||||
const registered = config.registeredHooks.map((h) => h.type);
|
||||
expect(registered).toContain('ready');
|
||||
expect(registered).toContain('init');
|
||||
expect(registered).toContain('setData');
|
||||
expect(registered).toContain('setSeries');
|
||||
expect(registered).toContain('setLegend');
|
||||
expect(registered).toContain('setCursor');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Tooltip rendering ------------------------------------------------------
|
||||
|
||||
describe('tooltip rendering', () => {
|
||||
it('renders contents into a portal on document.body when hover is active', () => {
|
||||
const config = createConfigMock();
|
||||
const renderTooltip = jest.fn(() =>
|
||||
React.createElement('div', null, 'tooltip-body'),
|
||||
);
|
||||
|
||||
renderAndActivateHover(config, renderTooltip);
|
||||
|
||||
expect(renderTooltip).toHaveBeenCalled();
|
||||
expect(screen.getByText('tooltip-body')).toBeInTheDocument();
|
||||
|
||||
const container = document.querySelector(
|
||||
'.tooltip-plugin-container',
|
||||
) as HTMLElement;
|
||||
expect(container).not.toBeNull();
|
||||
expect(container.parentElement).toBe(document.body);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Pin behaviour ----------------------------------------------------------
|
||||
|
||||
describe('pin behaviour', () => {
|
||||
it('pins the tooltip when canPinTooltip is true and overlay is clicked', () => {
|
||||
const config = createConfigMock();
|
||||
|
||||
const fakePlot = renderAndActivateHover(config, undefined, {
|
||||
canPinTooltip: true,
|
||||
});
|
||||
|
||||
const container = document.querySelector(
|
||||
'.tooltip-plugin-container',
|
||||
) as HTMLElement;
|
||||
expect(container.classList.contains('pinned')).toBe(false);
|
||||
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
return waitFor(() => {
|
||||
const updated = document.querySelector(
|
||||
'.tooltip-plugin-container',
|
||||
) as HTMLElement | null;
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated?.classList.contains('pinned')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('dismisses a pinned tooltip via the dismiss callback', async () => {
|
||||
const config = createConfigMock();
|
||||
|
||||
render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: (args: TooltipRenderArgs) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{ type: 'button', onClick: args.dismiss },
|
||||
'Dismiss',
|
||||
),
|
||||
syncMode: DashboardCursorSync.None,
|
||||
canPinTooltip: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const fakePlot = createFakePlot();
|
||||
|
||||
act(() => {
|
||||
getHandler(config, 'init')(fakePlot);
|
||||
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
|
||||
});
|
||||
|
||||
// Pin the tooltip.
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
// Wait until the tooltip is actually pinned (pointer events enabled)
|
||||
await waitFor(() => {
|
||||
const container = document.querySelector(
|
||||
'.tooltip-plugin-container',
|
||||
) as HTMLElement | null;
|
||||
expect(container).not.toBeNull();
|
||||
expect(container?.classList.contains('pinned')).toBe(true);
|
||||
});
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'Dismiss' });
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a pinned tooltip when the underlying data changes', () => {
|
||||
jest.useFakeTimers();
|
||||
const config = createConfigMock();
|
||||
|
||||
render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config: config,
|
||||
render: () => React.createElement('div', null, 'tooltip-body'),
|
||||
syncMode: DashboardCursorSync.None,
|
||||
canPinTooltip: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const fakePlot = createFakePlot();
|
||||
|
||||
act(() => {
|
||||
getHandler(config, 'init')(fakePlot);
|
||||
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
// Pin.
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(
|
||||
(document.querySelector(
|
||||
'.tooltip-plugin-container',
|
||||
) as HTMLElement)?.classList.contains('pinned'),
|
||||
).toBe(true);
|
||||
|
||||
// Simulate data update – should dismiss the pinned tooltip.
|
||||
act(() => {
|
||||
getHandler(config, 'setData')(fakePlot);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('unpins the tooltip on outside mousedown', () => {
|
||||
jest.useFakeTimers();
|
||||
const config = createConfigMock();
|
||||
|
||||
render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: () => React.createElement('div', null, 'pinned content'),
|
||||
syncMode: DashboardCursorSync.None,
|
||||
canPinTooltip: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const fakePlot = createFakePlot();
|
||||
|
||||
act(() => {
|
||||
getHandler(config, 'init')(fakePlot);
|
||||
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(
|
||||
document
|
||||
.querySelector('.tooltip-plugin-container')
|
||||
?.classList.contains('pinned'),
|
||||
).toBe(true);
|
||||
|
||||
// Click outside the tooltip container.
|
||||
act(() => {
|
||||
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('unpins the tooltip on outside keydown', () => {
|
||||
jest.useFakeTimers();
|
||||
const config = createConfigMock();
|
||||
|
||||
render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: () => React.createElement('div', null, 'pinned content'),
|
||||
syncMode: DashboardCursorSync.None,
|
||||
canPinTooltip: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const fakePlot = createFakePlot();
|
||||
|
||||
act(() => {
|
||||
getHandler(config, 'init')(fakePlot);
|
||||
getHandler(config, 'setSeries')(fakePlot, 1, { focus: true });
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(
|
||||
document
|
||||
.querySelector('.tooltip-plugin-container')
|
||||
?.classList.contains('pinned'),
|
||||
).toBe(true);
|
||||
|
||||
// Press a key outside the tooltip.
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(document.querySelector('.tooltip-plugin-container')).toBeNull();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Cursor sync ------------------------------------------------------------
|
||||
|
||||
describe('cursor sync', () => {
|
||||
it('enables uPlot cursor sync for time-based scales when mode is Tooltip', () => {
|
||||
const config = createConfigMock();
|
||||
const setCursorSpy = jest.spyOn(config, 'setCursor');
|
||||
config.addScale({ scaleKey: 'x', time: true });
|
||||
|
||||
render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: () => null,
|
||||
syncMode: DashboardCursorSync.Tooltip,
|
||||
syncKey: 'dashboard-sync',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(setCursorSpy).toHaveBeenCalledWith({
|
||||
sync: { key: 'dashboard-sync', scales: ['x', null] },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not enable cursor sync when mode is None', () => {
|
||||
const config = createConfigMock();
|
||||
const setCursorSpy = jest.spyOn(config, 'setCursor');
|
||||
config.addScale({ scaleKey: 'x', time: true });
|
||||
|
||||
render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: () => null,
|
||||
syncMode: DashboardCursorSync.None,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(setCursorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not enable cursor sync when scale is not time-based', () => {
|
||||
const config = createConfigMock();
|
||||
const setCursorSpy = jest.spyOn(config, 'setCursor');
|
||||
config.addScale({ scaleKey: 'x', time: false });
|
||||
|
||||
render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: () => null,
|
||||
syncMode: DashboardCursorSync.Tooltip,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(setCursorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Cleanup ----------------------------------------------------------------
|
||||
|
||||
describe('cleanup on unmount', () => {
|
||||
it('removes window event listeners and all uPlot hooks', () => {
|
||||
const config = createConfigMock();
|
||||
const addSpy = jest.spyOn(window, 'addEventListener');
|
||||
const removeSpy = jest.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { unmount } = render(
|
||||
React.createElement(TooltipPlugin, {
|
||||
config,
|
||||
render: () => null,
|
||||
syncMode: DashboardCursorSync.None,
|
||||
}),
|
||||
);
|
||||
|
||||
const resizeCall = addSpy.mock.calls.find(([type]) => type === 'resize');
|
||||
const scrollCall = addSpy.mock.calls.find(([type]) => type === 'scroll');
|
||||
|
||||
expect(resizeCall).toBeDefined();
|
||||
expect(scrollCall).toBeDefined();
|
||||
|
||||
const resizeListener = resizeCall?.[1] as EventListener;
|
||||
const scrollListener = scrollCall?.[1] as EventListener;
|
||||
const scrollOptions = scrollCall?.[2];
|
||||
|
||||
unmount();
|
||||
|
||||
config.removeCallbacks.forEach((removeFn) => {
|
||||
expect(removeFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('resize', resizeListener);
|
||||
expect(removeSpy).toHaveBeenCalledWith(
|
||||
'scroll',
|
||||
scrollListener,
|
||||
scrollOptions,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
218
frontend/src/lib/uPlotV2/utils/__tests__/axis.test.ts
Normal file
218
frontend/src/lib/uPlotV2/utils/__tests__/axis.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import type uPlot from 'uplot';
|
||||
import { Axis } from 'uplot';
|
||||
|
||||
import {
|
||||
buildYAxisSizeCalculator,
|
||||
calculateTextWidth,
|
||||
getExistingAxisSize,
|
||||
} from '../axis';
|
||||
|
||||
describe('axis utils', () => {
|
||||
describe('calculateTextWidth', () => {
|
||||
it('returns 0 when values are undefined or empty', () => {
|
||||
const mockSelf = ({
|
||||
ctx: {
|
||||
measureText: jest.fn(),
|
||||
font: '',
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
// internally the type is string but it is an array of strings
|
||||
const mockAxis: Axis = { font: (['12px sans-serif'] as unknown) as string };
|
||||
|
||||
expect(calculateTextWidth(mockSelf, mockAxis, undefined)).toBe(0);
|
||||
expect(calculateTextWidth(mockSelf, mockAxis, [])).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when longest value is empty string or axis has no usable font', () => {
|
||||
const mockSelf = ({
|
||||
ctx: {
|
||||
measureText: jest.fn(),
|
||||
font: '',
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const axisWithoutFont: Axis = { font: '' };
|
||||
const axisWithEmptyFontArray: Axis = { font: '' };
|
||||
|
||||
expect(calculateTextWidth(mockSelf, axisWithoutFont, [''])).toBe(0);
|
||||
expect(
|
||||
calculateTextWidth(mockSelf, axisWithEmptyFontArray, ['a', 'bb']),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('measures longest value using canvas context and axis font', () => {
|
||||
const measureText = jest.fn(() => ({ width: 100 }));
|
||||
const mockSelf = ({
|
||||
ctx: {
|
||||
font: '',
|
||||
measureText,
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const mockAxis: Axis = { font: (['14px Arial'] as unknown) as string };
|
||||
const values = ['1', '1234', '12'];
|
||||
const dpr =
|
||||
((global as unknown) as { devicePixelRatio?: number }).devicePixelRatio ??
|
||||
1;
|
||||
|
||||
const result = calculateTextWidth(mockSelf, mockAxis, values);
|
||||
|
||||
expect(measureText).toHaveBeenCalledWith('1234');
|
||||
expect(mockSelf.ctx.font).toBe('14px Arial');
|
||||
expect(result).toBe(100 / dpr);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExistingAxisSize', () => {
|
||||
it('returns internal _size when present', () => {
|
||||
const axis: any = {
|
||||
_size: 42,
|
||||
size: 10,
|
||||
};
|
||||
|
||||
const result = getExistingAxisSize({
|
||||
uplotInstance: ({} as unknown) as uPlot,
|
||||
axis,
|
||||
axisIdx: 0,
|
||||
cycleNum: 0,
|
||||
});
|
||||
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('invokes size function when _size is not set', () => {
|
||||
const sizeFn = jest.fn(() => 24);
|
||||
const axis: Axis = { size: sizeFn };
|
||||
const instance = ({} as unknown) as uPlot;
|
||||
|
||||
const result = getExistingAxisSize({
|
||||
uplotInstance: instance,
|
||||
axis,
|
||||
values: ['10', '20'],
|
||||
axisIdx: 1,
|
||||
cycleNum: 2,
|
||||
});
|
||||
|
||||
expect(sizeFn).toHaveBeenCalledWith(instance, ['10', '20'], 1, 2);
|
||||
expect(result).toBe(24);
|
||||
});
|
||||
|
||||
it('returns numeric size or 0 when neither _size nor size are provided', () => {
|
||||
const axisWithSize: Axis = { size: 16 };
|
||||
const axisWithoutSize: Axis = {};
|
||||
const instance = ({} as unknown) as uPlot;
|
||||
|
||||
expect(
|
||||
getExistingAxisSize({
|
||||
uplotInstance: instance,
|
||||
axis: axisWithSize,
|
||||
axisIdx: 0,
|
||||
cycleNum: 0,
|
||||
}),
|
||||
).toBe(16);
|
||||
|
||||
expect(
|
||||
getExistingAxisSize({
|
||||
uplotInstance: instance,
|
||||
axis: axisWithoutSize,
|
||||
axisIdx: 0,
|
||||
cycleNum: 0,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildYAxisSizeCalculator', () => {
|
||||
it('delegates to getExistingAxisSize when cycleNum > 1', () => {
|
||||
const sizeCalculator = buildYAxisSizeCalculator(5);
|
||||
|
||||
const axis: any = {
|
||||
_size: 80,
|
||||
ticks: { size: 10 },
|
||||
font: ['12px sans-serif'],
|
||||
};
|
||||
|
||||
const measureText = jest.fn(() => ({ width: 60 }));
|
||||
const self = ({
|
||||
axes: [axis],
|
||||
ctx: {
|
||||
font: '',
|
||||
measureText,
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
if (typeof sizeCalculator === 'number') {
|
||||
throw new Error('Size calculator is a number');
|
||||
}
|
||||
|
||||
const result = sizeCalculator(self, ['10', '20'], 0, 2);
|
||||
|
||||
expect(result).toBe(80);
|
||||
expect(measureText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('computes size from ticks, gap and text width when cycleNum <= 1', () => {
|
||||
const gap = 7;
|
||||
const sizeCalculator = buildYAxisSizeCalculator(gap);
|
||||
|
||||
const axis: Axis = {
|
||||
ticks: { size: 12 },
|
||||
font: (['12px sans-serif'] as unknown) as string,
|
||||
};
|
||||
|
||||
const measureText = jest.fn(() => ({ width: 50 }));
|
||||
const self = ({
|
||||
axes: [axis],
|
||||
ctx: {
|
||||
font: '',
|
||||
measureText,
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const dpr =
|
||||
((global as unknown) as { devicePixelRatio?: number }).devicePixelRatio ??
|
||||
1;
|
||||
const expected = Math.ceil(12 + gap + 50 / dpr);
|
||||
|
||||
if (typeof sizeCalculator === 'number') {
|
||||
throw new Error('Size calculator is a number');
|
||||
}
|
||||
|
||||
const result = sizeCalculator(self, ['short', 'the-longest'], 0, 0);
|
||||
expect(measureText).toHaveBeenCalledWith('the-longest');
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('uses 0 ticks size when ticks are not defined', () => {
|
||||
const gap = 4;
|
||||
const sizeCalculator = buildYAxisSizeCalculator(gap);
|
||||
|
||||
const axis: Axis = {
|
||||
font: (['12px sans-serif'] as unknown) as string,
|
||||
};
|
||||
|
||||
const measureText = jest.fn(() => ({ width: 40 }));
|
||||
const self = ({
|
||||
axes: [axis],
|
||||
ctx: {
|
||||
font: '',
|
||||
measureText,
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const dpr =
|
||||
((global as unknown) as { devicePixelRatio?: number }).devicePixelRatio ??
|
||||
1;
|
||||
const expected = Math.ceil(gap + 40 / dpr);
|
||||
|
||||
if (typeof sizeCalculator === 'number') {
|
||||
throw new Error('Size calculator is a number');
|
||||
}
|
||||
|
||||
const result = sizeCalculator(self, ['1', '123'], 0, 1);
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
62
frontend/src/lib/uPlotV2/utils/__tests__/dataUtils.test.ts
Normal file
62
frontend/src/lib/uPlotV2/utils/__tests__/dataUtils.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { isInvalidPlotValue, normalizePlotValue } from '../dataUtils';
|
||||
|
||||
describe('dataUtils', () => {
|
||||
describe('isInvalidPlotValue', () => {
|
||||
it('treats null and undefined as invalid', () => {
|
||||
expect(isInvalidPlotValue(null)).toBe(true);
|
||||
expect(isInvalidPlotValue(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats finite numbers as valid and non-finite as invalid', () => {
|
||||
expect(isInvalidPlotValue(0)).toBe(false);
|
||||
expect(isInvalidPlotValue(123.45)).toBe(false);
|
||||
expect(isInvalidPlotValue(Number.NaN)).toBe(true);
|
||||
expect(isInvalidPlotValue(Infinity)).toBe(true);
|
||||
expect(isInvalidPlotValue(-Infinity)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats well-formed numeric strings as valid', () => {
|
||||
expect(isInvalidPlotValue('0')).toBe(false);
|
||||
expect(isInvalidPlotValue('123.45')).toBe(false);
|
||||
expect(isInvalidPlotValue('-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats Infinity/NaN string variants and non-numeric strings as invalid', () => {
|
||||
expect(isInvalidPlotValue('+Inf')).toBe(true);
|
||||
expect(isInvalidPlotValue('-Inf')).toBe(true);
|
||||
expect(isInvalidPlotValue('Infinity')).toBe(true);
|
||||
expect(isInvalidPlotValue('-Infinity')).toBe(true);
|
||||
expect(isInvalidPlotValue('NaN')).toBe(true);
|
||||
expect(isInvalidPlotValue('not-a-number')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats non-number, non-string values as valid (left to caller)', () => {
|
||||
expect(isInvalidPlotValue({})).toBe(false);
|
||||
expect(isInvalidPlotValue([])).toBe(false);
|
||||
expect(isInvalidPlotValue(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePlotValue', () => {
|
||||
it('returns null for invalid values detected by isInvalidPlotValue', () => {
|
||||
expect(normalizePlotValue(null)).toBeNull();
|
||||
expect(normalizePlotValue(undefined)).toBeNull();
|
||||
expect(normalizePlotValue(NaN)).toBeNull();
|
||||
expect(normalizePlotValue(Infinity)).toBeNull();
|
||||
expect(normalizePlotValue('-Infinity')).toBeNull();
|
||||
expect(normalizePlotValue('not-a-number')).toBeNull();
|
||||
});
|
||||
|
||||
it('parses valid numeric strings into numbers', () => {
|
||||
expect(normalizePlotValue('0')).toBe(0);
|
||||
expect(normalizePlotValue('123.45')).toBe(123.45);
|
||||
expect(normalizePlotValue('-1')).toBe(-1);
|
||||
});
|
||||
|
||||
it('passes through valid numbers unchanged', () => {
|
||||
expect(normalizePlotValue(0)).toBe(0);
|
||||
expect(normalizePlotValue(123)).toBe(123);
|
||||
expect(normalizePlotValue(42.5)).toBe(42.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
201
frontend/src/lib/uPlotV2/utils/__tests__/scale.test.ts
Normal file
201
frontend/src/lib/uPlotV2/utils/__tests__/scale.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { DistributionType } from '../../config/types';
|
||||
import * as scaleUtils from '../scale';
|
||||
|
||||
describe('scale utils', () => {
|
||||
describe('normalizeLogScaleLimits', () => {
|
||||
it('returns limits unchanged when distribution is not logarithmic', () => {
|
||||
const limits = {
|
||||
min: 1,
|
||||
max: 100,
|
||||
softMin: 5,
|
||||
softMax: 50,
|
||||
};
|
||||
|
||||
const result = scaleUtils.normalizeLogScaleLimits({
|
||||
distr: DistributionType.Linear,
|
||||
logBase: 10,
|
||||
limits,
|
||||
});
|
||||
|
||||
expect(result).toEqual(limits);
|
||||
});
|
||||
|
||||
it('snaps positive limits to powers of the log base when distribution is logarithmic', () => {
|
||||
const result = scaleUtils.normalizeLogScaleLimits({
|
||||
distr: DistributionType.Logarithmic,
|
||||
logBase: 10,
|
||||
limits: {
|
||||
min: 3,
|
||||
max: 900,
|
||||
softMin: 12,
|
||||
softMax: 85,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.min).toBe(1); // 10^0
|
||||
expect(result.max).toBe(1000); // 10^3
|
||||
expect(result.softMin).toBe(10); // 10^1
|
||||
expect(result.softMax).toBe(100); // 10^2
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDistributionConfig', () => {
|
||||
it('returns empty config for time scales', () => {
|
||||
const config = scaleUtils.getDistributionConfig({
|
||||
time: true,
|
||||
distr: DistributionType.Linear,
|
||||
logBase: 2,
|
||||
});
|
||||
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it('returns linear distribution settings for non-time scales', () => {
|
||||
const config = scaleUtils.getDistributionConfig({
|
||||
time: false,
|
||||
distr: DistributionType.Linear,
|
||||
logBase: 2,
|
||||
});
|
||||
|
||||
expect(config.distr).toBe(1);
|
||||
expect(config.log).toBe(2);
|
||||
});
|
||||
|
||||
it('returns log distribution settings for non-time scales', () => {
|
||||
const config = scaleUtils.getDistributionConfig({
|
||||
time: false,
|
||||
distr: DistributionType.Logarithmic,
|
||||
logBase: 10,
|
||||
});
|
||||
|
||||
expect(config.distr).toBe(3);
|
||||
expect(config.log).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRangeConfig', () => {
|
||||
it('computes range config and fixed range flags correctly', () => {
|
||||
const {
|
||||
rangeConfig,
|
||||
hardMinOnly,
|
||||
hardMaxOnly,
|
||||
hasFixedRange,
|
||||
} = scaleUtils.getRangeConfig(0, 100, null, null, 0.1, 0.2);
|
||||
|
||||
expect(rangeConfig.min).toEqual({
|
||||
pad: 0.1,
|
||||
hard: 0,
|
||||
soft: undefined,
|
||||
mode: 3,
|
||||
});
|
||||
expect(rangeConfig.max).toEqual({
|
||||
pad: 0.2,
|
||||
hard: 100,
|
||||
soft: undefined,
|
||||
mode: 3,
|
||||
});
|
||||
expect(hardMinOnly).toBe(true);
|
||||
expect(hardMaxOnly).toBe(true);
|
||||
expect(hasFixedRange).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRangeFunction', () => {
|
||||
it('returns [dataMin, dataMax] when no fixed range and no data', () => {
|
||||
const params = {
|
||||
rangeConfig: {} as uPlot.Range.Config,
|
||||
hardMinOnly: false,
|
||||
hardMaxOnly: false,
|
||||
hasFixedRange: false,
|
||||
min: null,
|
||||
max: null,
|
||||
};
|
||||
|
||||
const rangeFn = scaleUtils.createRangeFunction(params);
|
||||
|
||||
const u = ({
|
||||
scales: {
|
||||
y: {
|
||||
distr: 1,
|
||||
log: 10,
|
||||
},
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const result = rangeFn(
|
||||
u,
|
||||
(null as unknown) as number,
|
||||
(null as unknown) as number,
|
||||
'y',
|
||||
);
|
||||
|
||||
expect(result).toEqual([null, null]);
|
||||
});
|
||||
|
||||
it('applies hard min/max for linear scale when only hard limits are set', () => {
|
||||
const params = {
|
||||
rangeConfig: {} as uPlot.Range.Config,
|
||||
hardMinOnly: true,
|
||||
hardMaxOnly: true,
|
||||
hasFixedRange: true,
|
||||
min: 0,
|
||||
max: 100,
|
||||
};
|
||||
|
||||
const rangeFn = scaleUtils.createRangeFunction(params);
|
||||
|
||||
// Use an undefined distr so the range function skips calling uPlot.rangeNum
|
||||
// and we can focus on the behavior of applyHardLimits.
|
||||
const u = ({
|
||||
scales: {
|
||||
y: {
|
||||
distr: undefined,
|
||||
log: 10,
|
||||
},
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const result = rangeFn(u, 10, 20, 'y');
|
||||
|
||||
// After applyHardLimits, the returned range should respect configured min/max
|
||||
expect(result).toEqual([0, 100]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adjustSoftLimitsWithThresholds', () => {
|
||||
it('returns original soft limits when there are no thresholds', () => {
|
||||
const result = scaleUtils.adjustSoftLimitsWithThresholds(1, 5, [], 'ms');
|
||||
|
||||
expect(result).toEqual({ softMin: 1, softMax: 5 });
|
||||
});
|
||||
|
||||
it('expands soft limits to include threshold min/max values', () => {
|
||||
const result = scaleUtils.adjustSoftLimitsWithThresholds(
|
||||
3,
|
||||
6,
|
||||
[{ thresholdValue: 2 }, { thresholdValue: 8 }],
|
||||
'ms',
|
||||
);
|
||||
|
||||
// min should be pulled down to the smallest threshold value
|
||||
expect(result.softMin).toBe(2);
|
||||
// max should be pushed up to the largest threshold value
|
||||
expect(result.softMax).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFallbackMinMaxTimeStamp', () => {
|
||||
it('returns a 24-hour window ending at approximately now', () => {
|
||||
const { fallbackMin, fallbackMax } = scaleUtils.getFallbackMinMaxTimeStamp();
|
||||
|
||||
// Difference should be exactly one day in seconds
|
||||
expect(fallbackMax - fallbackMin).toBe(86400);
|
||||
|
||||
// Both should be reasonable timestamps (not NaN or negative)
|
||||
expect(fallbackMin).toBeGreaterThan(0);
|
||||
expect(fallbackMax).toBeGreaterThan(fallbackMin);
|
||||
});
|
||||
});
|
||||
});
|
||||
34
frontend/src/lib/uPlotV2/utils/__tests__/threshold.test.ts
Normal file
34
frontend/src/lib/uPlotV2/utils/__tests__/threshold.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { findMinMaxThresholdValues } from '../threshold';
|
||||
|
||||
describe('findMinMaxThresholdValues', () => {
|
||||
it('returns [null, null] when thresholds array is empty or missing', () => {
|
||||
expect(findMinMaxThresholdValues([], 'ms')).toEqual([null, null]);
|
||||
});
|
||||
|
||||
it('returns min and max from thresholdValue when units are not provided', () => {
|
||||
const thresholds = [
|
||||
{ thresholdValue: 5 },
|
||||
{ thresholdValue: 1 },
|
||||
{ thresholdValue: 10 },
|
||||
];
|
||||
|
||||
const [min, max] = findMinMaxThresholdValues(thresholds);
|
||||
|
||||
expect(min).toBe(1);
|
||||
expect(max).toBe(10);
|
||||
});
|
||||
|
||||
it('ignores thresholds without a value or with unconvertible units', () => {
|
||||
const thresholds = [
|
||||
// Should be ignored: convertValue returns null for unknown unit
|
||||
{ thresholdValue: 100, thresholdUnit: 'unknown-unit' },
|
||||
// Should be used
|
||||
{ thresholdValue: 4 },
|
||||
];
|
||||
|
||||
const [min, max] = findMinMaxThresholdValues(thresholds, 'ms');
|
||||
|
||||
expect(min).toBe(4);
|
||||
expect(max).toBe(4);
|
||||
});
|
||||
});
|
||||
80
frontend/src/lib/uPlotV2/utils/axis.ts
Normal file
80
frontend/src/lib/uPlotV2/utils/axis.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Axis } from 'uplot';
|
||||
|
||||
/**
|
||||
* Calculate text width for longest value
|
||||
*/
|
||||
export function calculateTextWidth(
|
||||
self: uPlot,
|
||||
axis: Axis,
|
||||
values: string[] | undefined,
|
||||
): number {
|
||||
if (!values || values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find longest value
|
||||
const longestVal = values.reduce(
|
||||
(acc, val) => (val.length > acc.length ? val : acc),
|
||||
'',
|
||||
);
|
||||
|
||||
if (longestVal === '' || !axis.font?.[0]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
self.ctx.font = axis.font[0];
|
||||
return self.ctx.measureText(longestVal).width / devicePixelRatio;
|
||||
}
|
||||
|
||||
export function getExistingAxisSize({
|
||||
uplotInstance,
|
||||
axis,
|
||||
values,
|
||||
axisIdx,
|
||||
cycleNum,
|
||||
}: {
|
||||
uplotInstance: uPlot;
|
||||
axis: Axis;
|
||||
values?: string[];
|
||||
axisIdx: number;
|
||||
cycleNum: number;
|
||||
}): number {
|
||||
const internalSize = (axis as { _size?: number })._size;
|
||||
if (internalSize !== undefined) {
|
||||
return internalSize;
|
||||
}
|
||||
|
||||
const existingSize = axis.size;
|
||||
if (typeof existingSize === 'function') {
|
||||
return existingSize(uplotInstance, values ?? [], axisIdx, cycleNum);
|
||||
}
|
||||
|
||||
return existingSize ?? 0;
|
||||
}
|
||||
|
||||
export function buildYAxisSizeCalculator(gap: number): uPlot.Axis.Size {
|
||||
return (
|
||||
self: uPlot,
|
||||
values: string[] | undefined,
|
||||
axisIdx: number,
|
||||
cycleNum: number,
|
||||
): number => {
|
||||
const axis = self.axes[axisIdx];
|
||||
|
||||
// Bail out, force convergence
|
||||
if (cycleNum > 1) {
|
||||
return getExistingAxisSize({
|
||||
uplotInstance: self,
|
||||
axis,
|
||||
values,
|
||||
axisIdx,
|
||||
cycleNum,
|
||||
});
|
||||
}
|
||||
|
||||
let axisSize = (axis.ticks?.size ?? 0) + gap;
|
||||
axisSize += calculateTextWidth(self, axis, values);
|
||||
|
||||
return Math.ceil(axisSize);
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,25 @@
|
||||
export function resolveSeriesVisibility(
|
||||
label: string,
|
||||
seriesShow: boolean | undefined | null,
|
||||
visibilityMap: Map<string, boolean> | null,
|
||||
isAnySeriesHidden: boolean,
|
||||
): boolean {
|
||||
if (isAnySeriesHidden) {
|
||||
return visibilityMap?.get(label) ?? false;
|
||||
import { SeriesVisibilityState } from 'container/DashboardContainer/visualization/panels/types';
|
||||
|
||||
export function resolveSeriesVisibility({
|
||||
seriesIndex,
|
||||
seriesShow,
|
||||
seriesLabel,
|
||||
seriesVisibilityState,
|
||||
isAnySeriesHidden,
|
||||
}: {
|
||||
seriesIndex: number;
|
||||
seriesShow: boolean | undefined | null;
|
||||
seriesLabel: string;
|
||||
seriesVisibilityState: SeriesVisibilityState | null;
|
||||
isAnySeriesHidden: boolean;
|
||||
}): boolean {
|
||||
if (
|
||||
isAnySeriesHidden &&
|
||||
seriesVisibilityState?.visibility &&
|
||||
seriesVisibilityState.labels.length > seriesIndex &&
|
||||
seriesVisibilityState.labels[seriesIndex] === seriesLabel
|
||||
) {
|
||||
return seriesVisibilityState.visibility[seriesIndex] ?? false;
|
||||
}
|
||||
return seriesShow ?? true;
|
||||
}
|
||||
|
||||
@@ -7,4 +7,6 @@ import { handlers } from './handlers';
|
||||
// This configures a request mocking server with the given request handlers.
|
||||
export const server = setupServer(...handlers);
|
||||
|
||||
export * from './utils';
|
||||
|
||||
export { rest };
|
||||
|
||||
26
frontend/src/mocks-server/utils.ts
Normal file
26
frontend/src/mocks-server/utils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ResponseResolver, restContext, RestRequest } from 'msw';
|
||||
|
||||
export const createErrorResponse = (
|
||||
status: number,
|
||||
code: string,
|
||||
message: string,
|
||||
): ResponseResolver<RestRequest, typeof restContext> => (
|
||||
_req,
|
||||
res,
|
||||
ctx,
|
||||
): ReturnType<ResponseResolver<RestRequest, typeof restContext>> =>
|
||||
res(
|
||||
ctx.status(status),
|
||||
ctx.json({
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const handleInternalServerError = createErrorResponse(
|
||||
500,
|
||||
'INTERNAL_SERVER_ERROR',
|
||||
'Internal server error occurred',
|
||||
);
|
||||
@@ -0,0 +1,46 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { render, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ForgotPassword from '../index';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('lib/history', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
push: jest.fn(),
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const mockHistoryPush = history.push as jest.MockedFunction<
|
||||
typeof history.push
|
||||
>;
|
||||
|
||||
describe('ForgotPassword Page', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Route State Handling', () => {
|
||||
it('redirects to login when route state is missing', async () => {
|
||||
render(<ForgotPassword />, undefined, {
|
||||
initialRoute: '/forgot-password',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.LOGIN);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when route state is missing', () => {
|
||||
const { container } = render(<ForgotPassword />, undefined, {
|
||||
initialRoute: '/forgot-password',
|
||||
});
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
39
frontend/src/pages/ForgotPassword/index.tsx
Normal file
39
frontend/src/pages/ForgotPassword/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
import ROUTES from 'constants/routes';
|
||||
import ForgotPasswordContainer, {
|
||||
ForgotPasswordRouteState,
|
||||
} from 'container/ForgotPassword';
|
||||
import history from 'lib/history';
|
||||
|
||||
import '../Login/Login.styles.scss';
|
||||
|
||||
function ForgotPassword(): JSX.Element | null {
|
||||
const location = useLocation<ForgotPasswordRouteState | undefined>();
|
||||
const routeState = location.state;
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeState?.email) {
|
||||
history.push(ROUTES.LOGIN);
|
||||
}
|
||||
}, [routeState]);
|
||||
|
||||
if (!routeState?.email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPageContainer>
|
||||
<div className="auth-form-card">
|
||||
<ForgotPasswordContainer
|
||||
email={routeState.email}
|
||||
orgId={routeState.orgId}
|
||||
orgs={routeState.orgs}
|
||||
/>
|
||||
</div>
|
||||
</AuthPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default ForgotPassword;
|
||||
@@ -6,6 +6,8 @@ const DOCLINKS = {
|
||||
'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=traces-explorer-trace-tab#traces-view',
|
||||
METRICS_EXPLORER_EMPTY_STATE:
|
||||
'https://signoz.io/docs/userguide/send-metrics-cloud/',
|
||||
EXTERNAL_API_MONITORING:
|
||||
'https://signoz.io/docs/external-api-monitoring/overview/',
|
||||
};
|
||||
|
||||
export default DOCLINKS;
|
||||
|
||||
@@ -68,6 +68,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
ALERT_HISTORY: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ALERT_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LOGIN: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
FORGOT_PASSWORD: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR'],
|
||||
PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SERVICE_METRICS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
|
||||
@@ -5038,7 +5038,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/design-tokens/-/design-tokens-2.1.1.tgz#9c36d433fd264410713cc0c5ebdd75ce0ebecba3"
|
||||
integrity sha512-SdziCHg5Lwj+6oY6IRUPplaKZ+kTHjbrlhNj//UoAJ8aQLnRdR2F/miPzfSi4vrYw88LtXxNA9J9iJyacCp37A==
|
||||
|
||||
"@signozhq/icons@^0.1.0":
|
||||
"@signozhq/icons@0.1.0", "@signozhq/icons@^0.1.0":
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/icons/-/icons-0.1.0.tgz#00dfb430dbac423bfff715876f91a7b8a72509e4"
|
||||
integrity sha512-kGWDhCpQkFWaNwyWfy88AIbg902wBbgTFTBAtmo6DkHyLGoqWAf0Jcq8BX+7brFqJF9PnLoSJDj1lvCpUsI/Ig==
|
||||
|
||||
@@ -10,6 +10,27 @@ type Config struct {
|
||||
|
||||
type Templates struct {
|
||||
Directory string `mapstructure:"directory"`
|
||||
Format Format `mapstructure:"format"`
|
||||
}
|
||||
|
||||
type Format struct {
|
||||
Header Header `mapstructure:"header" json:"header"`
|
||||
Help Help `mapstructure:"help" json:"help"`
|
||||
Footer Footer `mapstructure:"footer" json:"footer"`
|
||||
}
|
||||
|
||||
type Header struct {
|
||||
Enabled bool `mapstructure:"enabled" json:"enabled"`
|
||||
LogoURL string `mapstructure:"logo_url" json:"logo_url"`
|
||||
}
|
||||
|
||||
type Help struct {
|
||||
Enabled bool `mapstructure:"enabled" json:"enabled"`
|
||||
Email string `mapstructure:"email" json:"email"`
|
||||
}
|
||||
|
||||
type Footer struct {
|
||||
Enabled bool `mapstructure:"enabled" json:"enabled"`
|
||||
}
|
||||
|
||||
type SMTP struct {
|
||||
@@ -45,6 +66,19 @@ func newConfig() factory.Config {
|
||||
Enabled: false,
|
||||
Templates: Templates{
|
||||
Directory: "/root/templates",
|
||||
Format: Format{
|
||||
Header: Header{
|
||||
Enabled: false,
|
||||
LogoURL: "",
|
||||
},
|
||||
Help: Help{
|
||||
Enabled: false,
|
||||
Email: "",
|
||||
},
|
||||
Footer: Footer{
|
||||
Enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
SMTP: SMTP{
|
||||
Address: "localhost:25",
|
||||
|
||||
@@ -15,6 +15,7 @@ type provider struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
store emailtypes.TemplateStore
|
||||
client *client.Client
|
||||
config emailing.Config
|
||||
}
|
||||
|
||||
func NewFactory() factory.ProviderFactory[emailing.Emailing, emailing.Config] {
|
||||
@@ -55,7 +56,7 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &provider{settings: settings, store: store, client: client}, nil
|
||||
return &provider{settings: settings, store: store, client: client, config: config}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) SendHTML(ctx context.Context, to string, subject string, templateName emailtypes.TemplateName, data map[string]any) error {
|
||||
@@ -69,6 +70,9 @@ func (provider *provider) SendHTML(ctx context.Context, to string, subject strin
|
||||
return err
|
||||
}
|
||||
|
||||
data["format"] = provider.config.Templates.Format
|
||||
data["to"] = to
|
||||
|
||||
content, err := emailtypes.NewContent(template, data)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
)
|
||||
|
||||
// Recovery is a middleware that recovers from panics, logs the panic,
|
||||
// and returns a 500 Internal Server Error.
|
||||
type Recovery struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewRecovery creates a new Recovery middleware.
|
||||
func NewRecovery(logger *slog.Logger) Wrapper {
|
||||
return &Recovery{
|
||||
logger: logger.With("pkg", "http-middleware-recovery"),
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap is the middleware handler.
|
||||
func (m *Recovery) Wrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
m.logger.ErrorContext(
|
||||
r.Context(),
|
||||
"panic recovered",
|
||||
"err", err, "stack", string(debug.Stack()),
|
||||
)
|
||||
|
||||
render.Error(w, errors.NewInternalf(
|
||||
errors.CodeInternal, "internal server error",
|
||||
))
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
root "github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -463,7 +462,7 @@ func (h *handler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if slices.Contains(integrationstypes.IntegrationUserEmails, createdByUser.Email) {
|
||||
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email.String())) {
|
||||
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
|
||||
return
|
||||
}
|
||||
@@ -508,7 +507,7 @@ func (h *handler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if slices.Contains(integrationstypes.IntegrationUserEmails, createdByUser.Email) {
|
||||
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(createdByUser.Email.String())) {
|
||||
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/emailtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/roletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/dustin/go-humanize"
|
||||
@@ -147,11 +146,9 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
|
||||
continue
|
||||
}
|
||||
|
||||
if err := m.emailing.SendHTML(ctx, invites[i].Email.String(), "You are invited to join a team in SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{
|
||||
"CustomerName": invites[i].Name,
|
||||
"InviterName": creator.DisplayName,
|
||||
"InviterEmail": creator.Email,
|
||||
"Link": fmt.Sprintf("%s/signup?token=%s", bulkInvites.Invites[i].FrontendBaseUrl, invites[i].Token),
|
||||
if err := m.emailing.SendHTML(ctx, invites[i].Email.String(), "You're Invited to Join SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{
|
||||
"inviter_email": creator.Email,
|
||||
"link": fmt.Sprintf("%s/signup?token=%s", bulkInvites.Invites[i].FrontendBaseUrl, invites[i].Token),
|
||||
}); err != nil {
|
||||
m.settings.Logger().ErrorContext(ctx, "failed to send email", "error", err)
|
||||
}
|
||||
@@ -172,7 +169,7 @@ func (m *Module) DeleteInvite(ctx context.Context, orgID string, id valuer.UUID)
|
||||
func (module *Module) CreateUser(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
|
||||
createUserOpts := root.NewCreateUserOptions(opts...)
|
||||
|
||||
// since assign is idempotent multiple calls to assign won't cause issues in case of retries.
|
||||
// since assign is idempotant multiple calls to assign won't cause issues in case of retries.
|
||||
err := module.authz.Grant(ctx, input.OrgID, roletypes.MustGetSigNozManagedRoleFromExistingRole(input.Role), authtypes.MustNewSubject(authtypes.TypeableUser, input.ID.StringValue(), input.OrgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -287,7 +284,7 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
|
||||
return err
|
||||
}
|
||||
|
||||
if slices.Contains(integrationstypes.IntegrationUserEmails, user.Email) {
|
||||
if slices.Contains(types.AllIntegrationUserEmails, types.IntegrationUserEmail(user.Email.String())) {
|
||||
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "integration user cannot be deleted")
|
||||
}
|
||||
|
||||
@@ -301,7 +298,7 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
|
||||
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot delete the last admin")
|
||||
}
|
||||
|
||||
// since revoke is idempotent multiple calls to revoke won't cause issues in case of retries
|
||||
// since revoke is idempotant multiple calls to revoke won't cause issues in case of retries
|
||||
err = module.authz.Revoke(ctx, orgID, roletypes.MustGetSigNozManagedRoleFromExistingRole(user.Role), authtypes.MustNewSubject(authtypes.TypeableUser, id, orgID, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -278,7 +278,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
var metricTemporality map[string]metrictypes.Temporality
|
||||
if len(metricNames) > 0 {
|
||||
var err error
|
||||
metricTemporality, err = q.metadataStore.FetchTemporalityMulti(ctx, metricNames...)
|
||||
metricTemporality, err = q.metadataStore.FetchTemporalityMulti(ctx, req.Start, req.End, metricNames...)
|
||||
if err != nil {
|
||||
q.logger.WarnContext(ctx, "failed to fetch metric temporality", "error", err, "metrics", metricNames)
|
||||
// Continue without temporality - statement builder will handle unspecified
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package store
|
||||
package cloudintegrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -7,50 +7,49 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/integrationstypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var (
|
||||
CodeCloudIntegrationAccountNotFound errors.Code = errors.MustNewCode("cloud_integration_account_not_found")
|
||||
)
|
||||
type cloudProviderAccountsRepository interface {
|
||||
listConnected(ctx context.Context, orgId string, provider string) ([]types.CloudIntegration, *model.ApiError)
|
||||
|
||||
type CloudProviderAccountsRepository interface {
|
||||
ListConnected(ctx context.Context, orgId string, provider string) ([]integrationstypes.CloudIntegration, error)
|
||||
get(ctx context.Context, orgId string, provider string, id string) (*types.CloudIntegration, *model.ApiError)
|
||||
|
||||
Get(ctx context.Context, orgId string, provider string, id string) (*integrationstypes.CloudIntegration, error)
|
||||
|
||||
GetConnectedCloudAccount(ctx context.Context, orgId string, provider string, accountID string) (*integrationstypes.CloudIntegration, error)
|
||||
getConnectedCloudAccount(ctx context.Context, orgId string, provider string, accountID string) (*types.CloudIntegration, *model.ApiError)
|
||||
|
||||
// Insert an account or update it by (cloudProvider, id)
|
||||
// for specified non-empty fields
|
||||
Upsert(
|
||||
upsert(
|
||||
ctx context.Context,
|
||||
orgId string,
|
||||
provider string,
|
||||
id *string,
|
||||
config []byte,
|
||||
config *types.AccountConfig,
|
||||
accountId *string,
|
||||
agentReport *integrationstypes.AgentReport,
|
||||
agentReport *types.AgentReport,
|
||||
removedAt *time.Time,
|
||||
) (*integrationstypes.CloudIntegration, error)
|
||||
) (*types.CloudIntegration, *model.ApiError)
|
||||
}
|
||||
|
||||
func NewCloudProviderAccountsRepository(store sqlstore.SQLStore) CloudProviderAccountsRepository {
|
||||
return &cloudProviderAccountsSQLRepository{store: store}
|
||||
func newCloudProviderAccountsRepository(store sqlstore.SQLStore) (
|
||||
*cloudProviderAccountsSQLRepository, error,
|
||||
) {
|
||||
return &cloudProviderAccountsSQLRepository{
|
||||
store: store,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type cloudProviderAccountsSQLRepository struct {
|
||||
store sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func (r *cloudProviderAccountsSQLRepository) ListConnected(
|
||||
func (r *cloudProviderAccountsSQLRepository) listConnected(
|
||||
ctx context.Context, orgId string, cloudProvider string,
|
||||
) ([]integrationstypes.CloudIntegration, error) {
|
||||
accounts := []integrationstypes.CloudIntegration{}
|
||||
) ([]types.CloudIntegration, *model.ApiError) {
|
||||
accounts := []types.CloudIntegration{}
|
||||
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&accounts).
|
||||
@@ -63,16 +62,18 @@ func (r *cloudProviderAccountsSQLRepository) ListConnected(
|
||||
Scan(ctx)
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "could not query connected cloud accounts")
|
||||
return nil, model.InternalError(fmt.Errorf(
|
||||
"could not query connected cloud accounts: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func (r *cloudProviderAccountsSQLRepository) Get(
|
||||
func (r *cloudProviderAccountsSQLRepository) get(
|
||||
ctx context.Context, orgId string, provider string, id string,
|
||||
) (*integrationstypes.CloudIntegration, error) {
|
||||
var result integrationstypes.CloudIntegration
|
||||
) (*types.CloudIntegration, *model.ApiError) {
|
||||
var result types.CloudIntegration
|
||||
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&result).
|
||||
@@ -81,25 +82,23 @@ func (r *cloudProviderAccountsSQLRepository) Get(
|
||||
Where("id = ?", id).
|
||||
Scan(ctx)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.WrapNotFoundf(
|
||||
err,
|
||||
CodeCloudIntegrationAccountNotFound,
|
||||
"couldn't find account with Id %s", id,
|
||||
)
|
||||
}
|
||||
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't query cloud provider account")
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, model.NotFoundError(fmt.Errorf(
|
||||
"couldn't find account with Id %s", id,
|
||||
))
|
||||
} else if err != nil {
|
||||
return nil, model.InternalError(fmt.Errorf(
|
||||
"couldn't query cloud provider accounts: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (r *cloudProviderAccountsSQLRepository) GetConnectedCloudAccount(
|
||||
func (r *cloudProviderAccountsSQLRepository) getConnectedCloudAccount(
|
||||
ctx context.Context, orgId string, provider string, accountId string,
|
||||
) (*integrationstypes.CloudIntegration, error) {
|
||||
var result integrationstypes.CloudIntegration
|
||||
) (*types.CloudIntegration, *model.ApiError) {
|
||||
var result types.CloudIntegration
|
||||
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&result).
|
||||
@@ -110,25 +109,29 @@ func (r *cloudProviderAccountsSQLRepository) GetConnectedCloudAccount(
|
||||
Where("removed_at is NULL").
|
||||
Scan(ctx)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.WrapNotFoundf(err, CodeCloudIntegrationAccountNotFound, "couldn't find connected cloud account %s", accountId)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, model.NotFoundError(fmt.Errorf(
|
||||
"couldn't find connected cloud account %s", accountId,
|
||||
))
|
||||
} else if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't query cloud provider account")
|
||||
return nil, model.InternalError(fmt.Errorf(
|
||||
"couldn't query cloud provider accounts: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (r *cloudProviderAccountsSQLRepository) Upsert(
|
||||
func (r *cloudProviderAccountsSQLRepository) upsert(
|
||||
ctx context.Context,
|
||||
orgId string,
|
||||
provider string,
|
||||
id *string,
|
||||
config []byte,
|
||||
config *types.AccountConfig,
|
||||
accountId *string,
|
||||
agentReport *integrationstypes.AgentReport,
|
||||
agentReport *types.AgentReport,
|
||||
removedAt *time.Time,
|
||||
) (*integrationstypes.CloudIntegration, error) {
|
||||
) (*types.CloudIntegration, *model.ApiError) {
|
||||
// Insert
|
||||
if id == nil {
|
||||
temp := valuer.GenerateUUID().StringValue()
|
||||
@@ -178,7 +181,7 @@ func (r *cloudProviderAccountsSQLRepository) Upsert(
|
||||
)
|
||||
}
|
||||
|
||||
integration := integrationstypes.CloudIntegration{
|
||||
integration := types.CloudIntegration{
|
||||
OrgID: orgId,
|
||||
Provider: provider,
|
||||
Identifiable: types.Identifiable{ID: valuer.MustNewUUID(*id)},
|
||||
@@ -192,18 +195,22 @@ func (r *cloudProviderAccountsSQLRepository) Upsert(
|
||||
RemovedAt: removedAt,
|
||||
}
|
||||
|
||||
_, err := r.store.BunDB().NewInsert().
|
||||
_, dbErr := r.store.BunDB().NewInsert().
|
||||
Model(&integration).
|
||||
On(onConflictClause).
|
||||
Exec(ctx)
|
||||
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't upsert cloud integration account")
|
||||
if dbErr != nil {
|
||||
return nil, model.InternalError(fmt.Errorf(
|
||||
"could not upsert cloud account record: %w", dbErr,
|
||||
))
|
||||
}
|
||||
|
||||
upsertedAccount, err := r.Get(ctx, orgId, provider, *id)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't get upserted cloud integration account")
|
||||
upsertedAccount, apiErr := r.get(ctx, orgId, provider, *id)
|
||||
if apiErr != nil {
|
||||
return nil, model.InternalError(fmt.Errorf(
|
||||
"couldn't fetch upserted account by id: %w", apiErr.ToError(),
|
||||
))
|
||||
}
|
||||
|
||||
return upsertedAccount, nil
|
||||
@@ -1,4 +1,4 @@
|
||||
package integrationstypes
|
||||
package cloudintegrations
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -41,62 +41,3 @@ var ValidAWSRegions = map[string]bool{
|
||||
"us-west-1": true, // US West (N. California).
|
||||
"us-west-2": true, // US West (Oregon).
|
||||
}
|
||||
|
||||
var ValidAzureRegions = map[string]bool{
|
||||
"australiacentral": true,
|
||||
"australiacentral2": true,
|
||||
"australiaeast": true,
|
||||
"australiasoutheast": true,
|
||||
"austriaeast": true,
|
||||
"belgiumcentral": true,
|
||||
"brazilsouth": true,
|
||||
"brazilsoutheast": true,
|
||||
"canadacentral": true,
|
||||
"canadaeast": true,
|
||||
"centralindia": true,
|
||||
"centralus": true,
|
||||
"chilecentral": true,
|
||||
"denmarkeast": true,
|
||||
"eastasia": true,
|
||||
"eastus": true,
|
||||
"eastus2": true,
|
||||
"francecentral": true,
|
||||
"francesouth": true,
|
||||
"germanynorth": true,
|
||||
"germanywestcentral": true,
|
||||
"indonesiacentral": true,
|
||||
"israelcentral": true,
|
||||
"italynorth": true,
|
||||
"japaneast": true,
|
||||
"japanwest": true,
|
||||
"koreacentral": true,
|
||||
"koreasouth": true,
|
||||
"malaysiawest": true,
|
||||
"mexicocentral": true,
|
||||
"newzealandnorth": true,
|
||||
"northcentralus": true,
|
||||
"northeurope": true,
|
||||
"norwayeast": true,
|
||||
"norwaywest": true,
|
||||
"polandcentral": true,
|
||||
"qatarcentral": true,
|
||||
"southafricanorth": true,
|
||||
"southafricawest": true,
|
||||
"southcentralus": true,
|
||||
"southindia": true,
|
||||
"southeastasia": true,
|
||||
"spaincentral": true,
|
||||
"swedencentral": true,
|
||||
"switzerlandnorth": true,
|
||||
"switzerlandwest": true,
|
||||
"uaecentral": true,
|
||||
"uaenorth": true,
|
||||
"uksouth": true,
|
||||
"ukwest": true,
|
||||
"westcentralus": true,
|
||||
"westeurope": true,
|
||||
"westindia": true,
|
||||
"westus": true,
|
||||
"westus2": true,
|
||||
"westus3": true,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user