Compare commits

..

4 Commits

Author SHA1 Message Date
Abhishek Kumar Singh
662bfa5b7b chore: skip nan and inf values from evaluating series 2026-02-05 20:57:49 +05:30
Abhishek Kumar Singh
ecbf8b21be Merge branch 'main' into chore/avoid_series_copy_on_threshold_eval 2026-02-05 17:18:37 +05:30
Srikanth Chekuri
a31038d23c Merge branch 'main' into chore/avoid_series_copy_on_threshold_eval 2026-01-19 22:59:35 +05:30
Abhishek Kumar Singh
444beeca34 chore: accept series pointer in threshold eval to avoid repeated series copy 2026-01-19 21:29:02 +05:30
240 changed files with 8978 additions and 15238 deletions

View File

@@ -42,7 +42,7 @@ services:
timeout: 5s
retries: 3
schema-migrator-sync:
image: signoz/signoz-schema-migrator:v0.142.0
image: signoz/signoz-schema-migrator:v0.129.13
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.142.0
image: signoz/signoz-schema-migrator:v0.129.13
container_name: schema-migrator-async
command:
- async

View File

@@ -4,6 +4,7 @@ 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:

7
.github/CODEOWNERS vendored
View File

@@ -133,8 +133,5 @@
/frontend/src/pages/PublicDashboard/ @SigNoz/pulse-frontend
/frontend/src/container/PublicDashboardContainer/ @SigNoz/pulse-frontend
## Dashboard Libs + Components
/frontend/src/lib/uPlotV2/ @SigNoz/pulse-frontend
/frontend/src/lib/dashboard/ @SigNoz/pulse-frontend
/frontend/src/lib/dashboardVariables/ @SigNoz/pulse-frontend
/frontend/src/components/NewSelect/ @SigNoz/pulse-frontend
## UplotV2
/frontend/src/lib/uPlotV2/ @SigNoz/pulse-frontend

View File

@@ -93,13 +93,3 @@ 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)

View File

@@ -18,6 +18,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/role"
"github.com/SigNoz/signoz/pkg/modules/role/implrole"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/queryparser"
@@ -76,15 +78,18 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ dashboard.Module) factory.ProviderFactory[authz.AuthZ, authz.Config] {
func(ctx context.Context, sqlstore sqlstore.SQLStore) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx))
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, _ role.Setter, _ role.Granter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser)
},
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()
},
func(store sqlstore.SQLStore, authz authz.AuthZ, licensing licensing.Licensing, _ []role.RegisterTypeable) role.Setter {
return implrole.NewSetter(implrole.NewStore(store), authz)
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err)

View File

@@ -14,6 +14,7 @@ import (
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/ee/modules/role/implrole"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
"github.com/SigNoz/signoz/ee/sqlschema/postgressqlschema"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
@@ -28,6 +29,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/role"
pkgimplrole "github.com/SigNoz/signoz/pkg/modules/role/implrole"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/signoz"
@@ -115,15 +118,18 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return authNs, nil
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, licensing licensing.Licensing, dashboardModule dashboard.Module) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), licensing, dashboardModule)
func(ctx context.Context, sqlstore sqlstore.SQLStore) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx))
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, roleSetter role.Setter, granter role.Granter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, roleSetter, granter, queryParser, querier, licensing)
},
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)
},
func(store sqlstore.SQLStore, authz authz.AuthZ, licensing licensing.Licensing, registry []role.RegisterTypeable) role.Setter {
return implrole.NewSetter(pkgimplrole.NewStore(store), authz, licensing, registry)
},
)
if err != nil {

View File

@@ -176,7 +176,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.111.0
image: signoz/signoz:v0.110.1
command:
- --config=/root/config/prometheus.yml
ports:
@@ -209,11 +209,12 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.142.0
image: signoz/signoz-otel-collector:v0.129.13
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
@@ -232,7 +233,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.142.0
image: signoz/signoz-schema-migrator:v0.129.13
deploy:
restart_policy:
condition: on-failure

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.111.0
image: signoz/signoz:v0.110.1
command:
- --config=/root/config/prometheus.yml
ports:
@@ -150,11 +150,12 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.142.0
image: signoz/signoz-otel-collector:v0.129.13
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
@@ -175,7 +176,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.142.0
image: signoz/signoz-schema-migrator:v0.129.13
deploy:
restart_policy:
condition: on-failure

View File

@@ -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.111.0}
image: signoz/signoz:${VERSION:-v0.110.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -213,12 +213,13 @@ services:
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.13}
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
@@ -238,7 +239,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
container_name: schema-migrator-sync
command:
- sync
@@ -249,7 +250,7 @@ services:
condition: service_healthy
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
container_name: schema-migrator-async
command:
- async

View File

@@ -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.111.0}
image: signoz/signoz:${VERSION:-v0.110.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -144,12 +144,13 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.13}
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
@@ -165,7 +166,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
container_name: schema-migrator-sync
command:
- sync
@@ -177,7 +178,7 @@ services:
restart: on-failure
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
container_name: schema-migrator-async
command:
- async

View File

@@ -607,178 +607,6 @@ paths:
summary: Update auth domain
tags:
- authdomains
/api/v1/fields/keys:
get:
deprecated: false
description: This endpoint returns field keys
operationId: GetFieldsKeys
parameters:
- in: query
name: signal
schema:
type: string
- in: query
name: source
schema:
type: string
- in: query
name: limit
schema:
type: integer
- in: query
name: startUnixMilli
schema:
format: int64
type: integer
- in: query
name: endUnixMilli
schema:
format: int64
type: integer
- in: query
name: fieldContext
schema:
type: string
- in: query
name: fieldDataType
schema:
type: string
- in: query
name: metricName
schema:
type: string
- in: query
name: searchText
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TelemetrytypesGettableFieldKeys'
status:
type: string
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get field keys
tags:
- fields
/api/v1/fields/values:
get:
deprecated: false
description: This endpoint returns field values
operationId: GetFieldsValues
parameters:
- in: query
name: signal
schema:
type: string
- in: query
name: source
schema:
type: string
- in: query
name: limit
schema:
type: integer
- in: query
name: startUnixMilli
schema:
format: int64
type: integer
- in: query
name: endUnixMilli
schema:
format: int64
type: integer
- in: query
name: fieldContext
schema:
type: string
- in: query
name: fieldDataType
schema:
type: string
- in: query
name: metricName
schema:
type: string
- in: query
name: searchText
schema:
type: string
- in: query
name: name
schema:
type: string
- in: query
name: existingQuery
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TelemetrytypesGettableFieldValues'
status:
type: string
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get field values
tags:
- fields
/api/v1/getResetPasswordToken/{id}:
get:
deprecated: false
@@ -2398,12 +2226,6 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unprocessable Entity
"500":
content:
application/json:
@@ -2843,7 +2665,6 @@ paths:
parameters:
- in: query
name: metricName
required: true
schema:
type: string
responses:
@@ -2898,7 +2719,6 @@ paths:
parameters:
- in: query
name: metricName
required: true
schema:
type: string
responses:
@@ -2954,7 +2774,6 @@ paths:
parameters:
- in: query
name: metricName
required: true
schema:
type: string
responses:
@@ -3121,7 +2940,6 @@ paths:
parameters:
- in: query
name: metricName
required: true
schema:
type: string
responses:
@@ -3989,9 +3807,6 @@ components:
type: string
alertName:
type: string
required:
- alertName
- alertId
type: object
MetricsexplorertypesMetricAlertsResponse:
properties:
@@ -4000,8 +3815,6 @@ components:
$ref: '#/components/schemas/MetricsexplorertypesMetricAlert'
nullable: true
type: array
required:
- alerts
type: object
MetricsexplorertypesMetricAttribute:
properties:
@@ -4015,10 +3828,6 @@ components:
type: string
nullable: true
type: array
required:
- key
- values
- valueCount
type: object
MetricsexplorertypesMetricAttributesRequest:
properties:
@@ -4030,8 +3839,6 @@ components:
start:
nullable: true
type: integer
required:
- metricName
type: object
MetricsexplorertypesMetricAttributesResponse:
properties:
@@ -4043,9 +3850,6 @@ components:
totalKeys:
format: int64
type: integer
required:
- attributes
- totalKeys
type: object
MetricsexplorertypesMetricDashboard:
properties:
@@ -4057,11 +3861,6 @@ components:
type: string
widgetName:
type: string
required:
- dashboardName
- dashboardId
- widgetId
- widgetName
type: object
MetricsexplorertypesMetricDashboardsResponse:
properties:
@@ -4070,8 +3869,6 @@ components:
$ref: '#/components/schemas/MetricsexplorertypesMetricDashboard'
nullable: true
type: array
required:
- dashboards
type: object
MetricsexplorertypesMetricHighlightsResponse:
properties:
@@ -4087,11 +3884,6 @@ components:
totalTimeSeries:
minimum: 0
type: integer
required:
- dataPoints
- lastReceived
- totalTimeSeries
- activeTimeSeries
type: object
MetricsexplorertypesMetricMetadata:
properties:
@@ -4100,17 +3892,11 @@ components:
isMonotonic:
type: boolean
temporality:
$ref: '#/components/schemas/MetrictypesTemporality'
type: string
type:
$ref: '#/components/schemas/MetrictypesType'
type: string
unit:
type: string
required:
- description
- type
- unit
- temporality
- isMonotonic
type: object
MetricsexplorertypesStat:
properties:
@@ -4125,16 +3911,9 @@ components:
minimum: 0
type: integer
type:
$ref: '#/components/schemas/MetrictypesType'
type: string
unit:
type: string
required:
- metricName
- description
- type
- unit
- timeseries
- samples
type: object
MetricsexplorertypesStatsRequest:
properties:
@@ -4152,10 +3931,6 @@ components:
start:
format: int64
type: integer
required:
- start
- end
- limit
type: object
MetricsexplorertypesStatsResponse:
properties:
@@ -4167,9 +3942,6 @@ components:
total:
minimum: 0
type: integer
required:
- metrics
- total
type: object
MetricsexplorertypesTreemapEntry:
properties:
@@ -4181,16 +3953,7 @@ components:
totalValue:
minimum: 0
type: integer
required:
- metricName
- percentage
- totalValue
type: object
MetricsexplorertypesTreemapMode:
enum:
- timeseries
- samples
type: string
MetricsexplorertypesTreemapRequest:
properties:
end:
@@ -4201,15 +3964,10 @@ components:
limit:
type: integer
mode:
$ref: '#/components/schemas/MetricsexplorertypesTreemapMode'
type: string
start:
format: int64
type: integer
required:
- start
- end
- limit
- mode
type: object
MetricsexplorertypesTreemapResponse:
properties:
@@ -4223,9 +3981,6 @@ components:
$ref: '#/components/schemas/MetricsexplorertypesTreemapEntry'
nullable: true
type: array
required:
- timeseries
- samples
type: object
MetricsexplorertypesUpdateMetricMetadataRequest:
properties:
@@ -4236,33 +3991,12 @@ components:
metricName:
type: string
temporality:
$ref: '#/components/schemas/MetrictypesTemporality'
type: string
type:
$ref: '#/components/schemas/MetrictypesType'
type: string
unit:
type: string
required:
- metricName
- type
- description
- unit
- temporality
- isMonotonic
type: object
MetrictypesTemporality:
enum:
- delta
- cumulative
- unspecified
type: string
MetrictypesType:
enum:
- gauge
- sum
- histogram
- summary
- exponentialhistogram
type: string
PreferencetypesPreference:
properties:
allowedScopes:
@@ -4355,8 +4089,6 @@ components:
type: string
unit:
type: string
required:
- name
type: object
Querybuildertypesv5QueryData:
properties:
@@ -4418,68 +4150,6 @@ components:
format: date-time
type: string
type: object
TelemetrytypesGettableFieldKeys:
properties:
complete:
type: boolean
keys:
additionalProperties:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
nullable: true
type: object
required:
- keys
- complete
type: object
TelemetrytypesGettableFieldValues:
properties:
complete:
type: boolean
values:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldValues'
required:
- values
- complete
type: object
TelemetrytypesTelemetryFieldKey:
properties:
description:
type: string
fieldContext:
type: string
fieldDataType:
type: string
name:
type: string
signal:
type: string
unit:
type: string
required:
- name
type: object
TelemetrytypesTelemetryFieldValues:
properties:
boolValues:
items:
type: boolean
type: array
numberValues:
items:
format: double
type: number
type: array
relatedValues:
items:
type: string
type: array
stringValues:
items:
type: string
type: array
type: object
TypesChangePasswordRequest:
properties:
newPassword:
@@ -4608,9 +4278,6 @@ components:
type: string
orgId:
type: string
required:
- orgId
- email
type: object
TypesPostableInvite:
properties:

View File

@@ -2,18 +2,12 @@ package openfgaauthz
import (
"context"
"slices"
"github.com/SigNoz/signoz/ee/authz/openfgaserver"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/authz/authzstore/sqlauthzstore"
pkgopenfgaauthz "github.com/SigNoz/signoz/pkg/authz/openfgaauthz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer"
@@ -21,224 +15,50 @@ import (
type provider struct {
pkgAuthzService authz.AuthZ
openfgaServer *openfgaserver.Server
licensing licensing.Licensing
store roletypes.Store
registry []authz.RegisterTypeable
}
func NewProviderFactory(sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, licensing licensing.Licensing, registry ...authz.RegisterTypeable) factory.ProviderFactory[authz.AuthZ, authz.Config] {
func NewProviderFactory(sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return factory.NewProviderFactory(factory.MustNewName("openfga"), func(ctx context.Context, ps factory.ProviderSettings, config authz.Config) (authz.AuthZ, error) {
return newOpenfgaProvider(ctx, ps, config, sqlstore, openfgaSchema, licensing, registry)
return newOpenfgaProvider(ctx, ps, config, sqlstore, openfgaSchema)
})
}
func newOpenfgaProvider(ctx context.Context, settings factory.ProviderSettings, config authz.Config, sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, licensing licensing.Licensing, registry []authz.RegisterTypeable) (authz.AuthZ, error) {
func newOpenfgaProvider(ctx context.Context, settings factory.ProviderSettings, config authz.Config, sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile) (authz.AuthZ, error) {
pkgOpenfgaAuthzProvider := pkgopenfgaauthz.NewProviderFactory(sqlstore, openfgaSchema)
pkgAuthzService, err := pkgOpenfgaAuthzProvider.New(ctx, settings, config)
if err != nil {
return nil, err
}
openfgaServer, err := openfgaserver.NewOpenfgaServer(ctx, pkgAuthzService)
if err != nil {
return nil, err
}
return &provider{
pkgAuthzService: pkgAuthzService,
openfgaServer: openfgaServer,
licensing: licensing,
store: sqlauthzstore.NewSqlAuthzStore(sqlstore),
registry: registry,
}, nil
}
func (provider *provider) Start(ctx context.Context) error {
return provider.openfgaServer.Start(ctx)
return provider.pkgAuthzService.Start(ctx)
}
func (provider *provider) Stop(ctx context.Context) error {
return provider.openfgaServer.Stop(ctx)
return provider.pkgAuthzService.Stop(ctx)
}
func (provider *provider) Check(ctx context.Context, tuple *openfgav1.TupleKey) error {
return provider.openfgaServer.Check(ctx, tuple)
return provider.pkgAuthzService.Check(ctx, tuple)
}
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, roleSelectors []authtypes.Selector) error {
return provider.openfgaServer.CheckWithTupleCreation(ctx, claims, orgID, relation, typeable, selectors, roleSelectors)
}
func (provider *provider) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, roleSelectors []authtypes.Selector) error {
return provider.openfgaServer.CheckWithTupleCreationWithoutClaims(ctx, orgID, relation, typeable, selectors, roleSelectors)
}
func (provider *provider) BatchCheck(ctx context.Context, tuples []*openfgav1.TupleKey) error {
return provider.openfgaServer.BatchCheck(ctx, tuples)
}
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {
return provider.openfgaServer.ListObjects(ctx, subject, relation, typeable)
}
func (provider *provider) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
return provider.openfgaServer.Write(ctx, additions, deletions)
}
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*roletypes.Role, error) {
return provider.pkgAuthzService.Get(ctx, orgID, id)
}
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*roletypes.Role, error) {
return provider.pkgAuthzService.GetByOrgIDAndName(ctx, orgID, name)
}
func (provider *provider) List(ctx context.Context, orgID valuer.UUID) ([]*roletypes.Role, error) {
return provider.pkgAuthzService.List(ctx, orgID)
}
func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*roletypes.Role, error) {
return provider.pkgAuthzService.ListByOrgIDAndNames(ctx, orgID, names)
}
func (provider *provider) Grant(ctx context.Context, orgID valuer.UUID, name string, subject string) error {
return provider.pkgAuthzService.Grant(ctx, orgID, name, subject)
}
func (provider *provider) ModifyGrant(ctx context.Context, orgID valuer.UUID, existingRoleName string, updatedRoleName string, subject string) error {
return provider.pkgAuthzService.ModifyGrant(ctx, orgID, existingRoleName, updatedRoleName, subject)
}
func (provider *provider) Revoke(ctx context.Context, orgID valuer.UUID, name string, subject string) error {
return provider.pkgAuthzService.Revoke(ctx, orgID, name, subject)
}
func (provider *provider) CreateManagedRoles(ctx context.Context, orgID valuer.UUID, managedRoles []*roletypes.Role) error {
return provider.pkgAuthzService.CreateManagedRoles(ctx, orgID, managedRoles)
}
func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
tuples := make([]*openfgav1.TupleKey, 0)
grantTuples, err := provider.getManagedRoleGrantTuples(orgID, userID)
if err != nil {
return err
}
tuples = append(tuples, grantTuples...)
managedRoleTuples, err := provider.getManagedRoleTransactionTuples(orgID)
if err != nil {
return err
}
tuples = append(tuples, managedRoleTuples...)
return provider.Write(ctx, tuples, nil)
}
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
return provider.store.Create(ctx, roletypes.NewStorableRoleFromRole(role))
}
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) (*roletypes.Role, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existingRole, err := provider.store.GetByOrgIDAndName(ctx, role.OrgID, role.Name)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
}
if existingRole != nil {
return roletypes.NewRoleFromStorableRole(existingRole), nil
}
err = provider.store.Create(ctx, roletypes.NewStorableRoleFromRole(role))
if err != nil {
return nil, err
}
return role, nil
}
func (provider *provider) GetResources(_ context.Context) []*authtypes.Resource {
typeables := make([]authtypes.Typeable, 0)
for _, register := range provider.registry {
typeables = append(typeables, register.MustGetTypeables()...)
}
// role module cannot self register itself!
typeables = append(typeables, provider.MustGetTypeables()...)
resources := make([]*authtypes.Resource, 0)
for _, typeable := range typeables {
resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()})
}
return resources
}
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*authtypes.Object, error) {
storableRole, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
objects := make([]*authtypes.Object, 0)
for _, resource := range provider.GetResources(ctx) {
if slices.Contains(authtypes.TypeableRelations[resource.Type], relation) {
resourceObjects, err := provider.
ListObjects(
ctx,
authtypes.MustNewSubject(authtypes.TypeableRole, storableRole.ID.String(), orgID, &authtypes.RelationAssignee),
relation,
authtypes.MustNewTypeableFromType(resource.Type, resource.Name),
)
if err != nil {
return nil, err
}
objects = append(objects, resourceObjects...)
}
}
return objects, nil
}
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
return provider.store.Update(ctx, orgID, roletypes.NewStorableRoleFromRole(role))
}
func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
additionTuples, err := roletypes.GetAdditionTuples(name, orgID, relation, additions)
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
deletionTuples, err := roletypes.GetDeletionTuples(name, orgID, relation, deletions)
tuples, err := typeable.Tuples(subject, relation, selectors, orgID)
if err != nil {
return err
}
err = provider.Write(ctx, additionTuples, deletionTuples)
err = provider.BatchCheck(ctx, tuples)
if err != nil {
return err
}
@@ -246,95 +66,33 @@ func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, n
return nil
}
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
storableRole, err := provider.store.Get(ctx, orgID, id)
func (provider *provider) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.String(), orgID, nil)
if err != nil {
return err
}
role := roletypes.NewRoleFromStorableRole(storableRole)
err = role.CanEditDelete()
tuples, err := typeable.Tuples(subject, relation, selectors, orgID)
if err != nil {
return err
}
return provider.store.Delete(ctx, orgID, id)
}
func (provider *provider) MustGetTypeables() []authtypes.Typeable {
return []authtypes.Typeable{authtypes.TypeableRole, roletypes.TypeableResourcesRoles}
}
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) ([]*openfgav1.TupleKey, error) {
tuples := []*openfgav1.TupleKey{}
// Grant the admin role to the user
adminSubject := authtypes.MustNewSubject(authtypes.TypeableUser, userID.String(), orgID, nil)
adminTuple, err := authtypes.TypeableRole.Tuples(
adminSubject,
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAdminRoleName),
},
orgID,
)
err = provider.BatchCheck(ctx, tuples)
if err != nil {
return nil, err
return err
}
tuples = append(tuples, adminTuple...)
// Grant the admin role to the anonymous user
anonymousSubject := authtypes.MustNewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.String(), orgID, nil)
anonymousTuple, err := authtypes.TypeableRole.Tuples(
anonymousSubject,
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAnonymousRoleName),
},
orgID,
)
if err != nil {
return nil, err
}
tuples = append(tuples, anonymousTuple...)
return tuples, nil
return nil
}
func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
transactionsByRole := make(map[string][]*authtypes.Transaction)
for _, register := range provider.registry {
for roleName, txns := range register.MustGetManagedRoleTransactions() {
transactionsByRole[roleName] = append(transactionsByRole[roleName], txns...)
}
}
tuples := make([]*openfgav1.TupleKey, 0)
for roleName, transactions := range transactionsByRole {
for _, txn := range transactions {
typeable := authtypes.MustNewTypeableFromType(txn.Object.Resource.Type, txn.Object.Resource.Name)
txnTuples, err := typeable.Tuples(
authtypes.MustNewSubject(
authtypes.TypeableRole,
roleName,
orgID,
&authtypes.RelationAssignee,
),
txn.Relation,
[]authtypes.Selector{txn.Object.Selector},
orgID,
)
if err != nil {
return nil, err
}
tuples = append(tuples, txnTuples...)
}
}
return tuples, nil
func (provider *provider) BatchCheck(ctx context.Context, tuples []*openfgav1.TupleKey) error {
return provider.pkgAuthzService.BatchCheck(ctx, tuples)
}
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {
return provider.pkgAuthzService.ListObjects(ctx, subject, relation, typeable)
}
func (provider *provider) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
return provider.pkgAuthzService.Write(ctx, additions, deletions)
}

View File

@@ -1,83 +0,0 @@
package openfgaserver
import (
"context"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
type Server struct {
pkgAuthzService authz.AuthZ
}
func NewOpenfgaServer(ctx context.Context, pkgAuthzService authz.AuthZ) (*Server, error) {
return &Server{
pkgAuthzService: pkgAuthzService,
}, nil
}
func (server *Server) Start(ctx context.Context) error {
return server.pkgAuthzService.Start(ctx)
}
func (server *Server) Stop(ctx context.Context) error {
return server.pkgAuthzService.Stop(ctx)
}
func (server *Server) Check(ctx context.Context, tuple *openfgav1.TupleKey) error {
return server.pkgAuthzService.Check(ctx, tuple)
}
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
tuples, err := typeable.Tuples(subject, relation, selectors, orgID)
if err != nil {
return err
}
err = server.BatchCheck(ctx, tuples)
if err != nil {
return err
}
return nil
}
func (server *Server) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.String(), orgID, nil)
if err != nil {
return err
}
tuples, err := typeable.Tuples(subject, relation, selectors, orgID)
if err != nil {
return err
}
err = server.BatchCheck(ctx, tuples)
if err != nil {
return err
}
return nil
}
func (server *Server) BatchCheck(ctx context.Context, tuples []*openfgav1.TupleKey) error {
return server.pkgAuthzService.BatchCheck(ctx, tuples)
}
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {
return server.pkgAuthzService.ListObjects(ctx, subject, relation, typeable)
}
func (server *Server) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
return server.pkgAuthzService.Write(ctx, additions, deletions)
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/role"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/types"
@@ -25,11 +26,13 @@ type module struct {
pkgDashboardModule dashboard.Module
store dashboardtypes.Store
settings factory.ScopedProviderSettings
roleSetter role.Setter
granter role.Granter
querier querier.Querier
licensing licensing.Licensing
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, roleSetter role.Setter, granter role.Granter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser)
@@ -37,6 +40,8 @@ func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, an
pkgDashboardModule: pkgDashboardModule,
store: store,
settings: scopedProviderSettings,
roleSetter: roleSetter,
granter: granter,
querier: querier,
licensing: licensing,
}
@@ -56,6 +61,29 @@ func (module *module) CreatePublic(ctx context.Context, orgID valuer.UUID, publi
return errors.Newf(errors.TypeAlreadyExists, dashboardtypes.ErrCodePublicDashboardAlreadyExists, "dashboard with id %s is already public", storablePublicDashboard.DashboardID)
}
role, err := module.roleSetter.GetOrCreate(ctx, orgID, roletypes.NewRole(roletypes.SigNozAnonymousRoleName, roletypes.SigNozAnonymousRoleDescription, roletypes.RoleTypeManaged, orgID))
if err != nil {
return err
}
err = module.granter.Grant(ctx, orgID, roletypes.SigNozAnonymousRoleName, authtypes.MustNewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.StringValue(), orgID, nil))
if err != nil {
return err
}
additionObject := authtypes.MustNewObject(
authtypes.Resource{
Name: dashboardtypes.TypeableMetaResourcePublicDashboard.Name(),
Type: authtypes.TypeMetaResource,
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
)
err = module.roleSetter.PatchObjects(ctx, orgID, role.Name, authtypes.RelationRead, []*authtypes.Object{additionObject}, nil)
if err != nil {
return err
}
err = module.store.CreatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(publicDashboard))
if err != nil {
return err
@@ -100,7 +128,6 @@ func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id
return []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeMetaResource, id.StringValue()),
authtypes.MustNewSelector(authtypes.TypeMetaResource, authtypes.WildCardSelectorString),
}, storableDashboard.OrgID, nil
}
@@ -163,6 +190,29 @@ func (module *module) DeletePublic(ctx context.Context, orgID valuer.UUID, dashb
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
publicDashboard, err := module.GetPublic(ctx, orgID, dashboardID)
if err != nil {
return err
}
role, err := module.roleSetter.GetOrCreate(ctx, orgID, roletypes.NewRole(roletypes.SigNozAnonymousRoleName, roletypes.SigNozAnonymousRoleDescription, roletypes.RoleTypeManaged, orgID))
if err != nil {
return err
}
deletionObject := authtypes.MustNewObject(
authtypes.Resource{
Name: dashboardtypes.TypeableMetaResourcePublicDashboard.Name(),
Type: authtypes.TypeMetaResource,
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
)
err = module.roleSetter.PatchObjects(ctx, orgID, role.Name, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
if err != nil {
return err
}
err = module.store.DeletePublic(ctx, dashboardID.StringValue())
if err != nil {
return err
@@ -200,6 +250,10 @@ func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, m
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
}
func (module *module) MustGetTypeables() []authtypes.Typeable {
return module.pkgDashboardModule.MustGetTypeables()
}
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.List(ctx, orgID)
}
@@ -212,27 +266,34 @@ func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valu
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, role, lock)
}
func (module *module) MustGetTypeables() []authtypes.Typeable {
return module.pkgDashboardModule.MustGetTypeables()
}
func (module *module) MustGetManagedRoleTransactions() map[string][]*authtypes.Transaction {
return map[string][]*authtypes.Transaction{
roletypes.SigNozAnonymousRoleName: {
{
Relation: authtypes.RelationRead,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: dashboardtypes.TypeableMetaResourcePublicDashboard.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
},
func (module *module) deletePublic(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error {
publicDashboard, err := module.store.GetPublic(ctx, dashboardID.String())
if err != nil {
return err
}
}
func (module *module) deletePublic(ctx context.Context, _ valuer.UUID, dashboardID valuer.UUID) error {
return module.store.DeletePublic(ctx, dashboardID.StringValue())
role, err := module.roleSetter.GetOrCreate(ctx, orgID, roletypes.NewRole(roletypes.SigNozAnonymousRoleName, roletypes.SigNozAnonymousRoleDescription, roletypes.RoleTypeManaged, orgID))
if err != nil {
return err
}
deletionObject := authtypes.MustNewObject(
authtypes.Resource{
Name: dashboardtypes.TypeableMetaResourcePublicDashboard.Name(),
Type: authtypes.TypeMetaResource,
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
)
err = module.roleSetter.PatchObjects(ctx, orgID, role.Name, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
if err != nil {
return err
}
err = module.store.DeletePublic(ctx, dashboardID.StringValue())
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,165 @@
package implrole
import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/role"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type setter struct {
store roletypes.Store
authz authz.AuthZ
licensing licensing.Licensing
registry []role.RegisterTypeable
}
func NewSetter(store roletypes.Store, authz authz.AuthZ, licensing licensing.Licensing, registry []role.RegisterTypeable) role.Setter {
return &setter{
store: store,
authz: authz,
licensing: licensing,
registry: registry,
}
}
func (setter *setter) Create(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) error {
_, err := setter.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
return setter.store.Create(ctx, roletypes.NewStorableRoleFromRole(role))
}
func (setter *setter) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) (*roletypes.Role, error) {
_, err := setter.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existingRole, err := setter.store.GetByOrgIDAndName(ctx, role.OrgID, role.Name)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
}
if existingRole != nil {
return roletypes.NewRoleFromStorableRole(existingRole), nil
}
err = setter.store.Create(ctx, roletypes.NewStorableRoleFromRole(role))
if err != nil {
return nil, err
}
return role, nil
}
func (setter *setter) GetResources(_ context.Context) []*authtypes.Resource {
typeables := make([]authtypes.Typeable, 0)
for _, register := range setter.registry {
typeables = append(typeables, register.MustGetTypeables()...)
}
// role module cannot self register itself!
typeables = append(typeables, setter.MustGetTypeables()...)
resources := make([]*authtypes.Resource, 0)
for _, typeable := range typeables {
resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()})
}
return resources
}
func (setter *setter) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*authtypes.Object, error) {
storableRole, err := setter.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
objects := make([]*authtypes.Object, 0)
for _, resource := range setter.GetResources(ctx) {
if slices.Contains(authtypes.TypeableRelations[resource.Type], relation) {
resourceObjects, err := setter.
authz.
ListObjects(
ctx,
authtypes.MustNewSubject(authtypes.TypeableRole, storableRole.ID.String(), orgID, &authtypes.RelationAssignee),
relation,
authtypes.MustNewTypeableFromType(resource.Type, resource.Name),
)
if err != nil {
return nil, err
}
objects = append(objects, resourceObjects...)
}
}
return objects, nil
}
func (setter *setter) Patch(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) error {
_, err := setter.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
return setter.store.Update(ctx, orgID, roletypes.NewStorableRoleFromRole(role))
}
func (setter *setter) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
_, err := setter.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
additionTuples, err := roletypes.GetAdditionTuples(name, orgID, relation, additions)
if err != nil {
return err
}
deletionTuples, err := roletypes.GetDeletionTuples(name, orgID, relation, deletions)
if err != nil {
return err
}
err = setter.authz.Write(ctx, additionTuples, deletionTuples)
if err != nil {
return err
}
return nil
}
func (setter *setter) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
_, err := setter.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
storableRole, err := setter.store.Get(ctx, orgID, id)
if err != nil {
return err
}
role := roletypes.NewRoleFromStorableRole(storableRole)
err = role.CanEditDelete()
if err != nil {
return err
}
return setter.store.Delete(ctx, orgID, id)
}
func (setter *setter) MustGetTypeables() []authtypes.Typeable {
return []authtypes.Typeable{authtypes.TypeableRole, roletypes.TypeableResourcesRoles}
}

View File

@@ -1,7 +1,6 @@
package api
import (
"log/slog"
"net/http"
"net/http/httputil"
"time"
@@ -10,10 +9,12 @@ import (
"github.com/SigNoz/signoz/ee/query-service/integrations/gateway"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/apis/fields"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/middleware"
querierAPI "github.com/SigNoz/signoz/pkg/querier"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"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"
@@ -30,13 +31,13 @@ type APIHandlerOptions struct {
RulesManager *rules.Manager
UsageManager *usage.Manager
IntegrationsController *integrations.Controller
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 {
@@ -50,14 +51,15 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
Reader: opts.DataConnector,
RuleManager: opts.RulesManager,
IntegrationsController: opts.IntegrationsController,
CloudIntegrationsController: opts.CloudIntegrationsController,
LogsParsingPipelineController: opts.LogsParsingPipelineController,
FluxInterval: opts.FluxInterval,
AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager),
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
FieldsAPI: fields.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.TelemetryStore),
Signoz: signoz,
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
Logger: opts.Logger,
})
if err != nil {
@@ -118,12 +120,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) {

View File

@@ -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

View File

@@ -38,6 +38,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
"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/app/opamp"
@@ -126,6 +127,13 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
)
}
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(
signoz.SQLStore,
@@ -159,12 +167,12 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
RulesManager: rm,
UsageManager: usageManager,
IntegrationsController: integrationsController,
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)
@@ -203,7 +211,7 @@ func (s Server) HealthCheckStatus() chan healthcheck.Status {
func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*http.Server, error) {
r := baseapp.NewRouter()
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz, s.signoz.Modules.RoleGetter)
r.Use(otelmux.Middleware(
"apiserver",
@@ -229,6 +237,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
apiHandler.RegisterLogsRoutes(r, am)
apiHandler.RegisterIntegrationRoutes(r, am)
apiHandler.RegisterCloudIntegrationsRoutes(r, am)
apiHandler.RegisterFieldsRoutes(r, am)
apiHandler.RegisterQueryRangeV3Routes(r, am)
apiHandler.RegisterInfraMetricsRoutes(r, am)
apiHandler.RegisterQueryRangeV4Routes(r, am)

View File

@@ -15,7 +15,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/common"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
querierV2 "github.com/SigNoz/signoz/pkg/query-service/app/querier/v2"
@@ -63,8 +63,6 @@ type AnomalyRule struct {
seasonality anomaly.Seasonality
}
var _ baserules.Rule = (*AnomalyRule)(nil)
func NewAnomalyRule(
id string,
orgID valuer.UUID,
@@ -251,7 +249,7 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
continue
}
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
results, err := r.Threshold.Eval(series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
@@ -319,7 +317,7 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
r.logger.InfoContext(ctx, "not enough data points to evaluate series, skipping", "ruleid", r.ID(), "numPoints", len(series.Points), "requiredPoints", r.Condition().RequiredNumPoints)
continue
}
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
results, err := r.Threshold.Eval(series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
@@ -492,7 +490,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
continue
}
if a.State == model.StatePending && ts.Sub(a.ActiveAt) >= r.HoldDuration().Duration() {
if a.State == model.StatePending && ts.Sub(a.ActiveAt) >= r.HoldDuration() {
a.State = model.StateFiring
a.FiredAt = ts
state := model.StateFiring
@@ -555,7 +553,7 @@ func (r *AnomalyRule) String() string {
ar := ruletypes.PostableRule{
AlertName: r.Name(),
RuleCondition: r.Condition(),
EvalWindow: r.EvalWindow(),
EvalWindow: ruletypes.Duration(r.EvalWindow()),
Labels: r.Labels().Map(),
Annotations: r.Annotations().Map(),
PreferredChannels: r.PreferredChannels(),

View File

@@ -40,7 +40,7 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
// Test basic AlertOnAbsent functionality (without AbsentFor grace period)
baseTime := time.Unix(1700000000, 0)
evalWindow := valuer.MustParseTextDuration("5m")
evalWindow := 5 * time.Minute
evalTime := baseTime.Add(5 * time.Minute)
target := 500.0
@@ -50,8 +50,8 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
AlertType: ruletypes.AlertTypeMetric,
RuleType: RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: evalWindow,
Frequency: valuer.MustParseTextDuration("1m"),
EvalWindow: ruletypes.Duration(evalWindow),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOp: ruletypes.ValueIsAbove,
@@ -147,7 +147,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
// 3. Alert fires only if t2 - t1 > AbsentFor
baseTime := time.Unix(1700000000, 0)
evalWindow := valuer.MustParseTextDuration("5m")
evalWindow := 5 * time.Minute
// Set target higher than test data so regular threshold alerts don't fire
target := 500.0
@@ -157,8 +157,8 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
AlertType: ruletypes.AlertTypeMetric,
RuleType: RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: evalWindow,
Frequency: valuer.MustParseTextDuration("1m"),
EvalWindow: ruletypes.Duration(evalWindow),
Frequency: ruletypes.Duration(time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOp: ruletypes.ValueIsAbove,

View File

@@ -48,7 +48,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, tr)
// create ch rule task for evaluation
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
@@ -72,7 +72,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, pr)
// create promql rule task for evaluation
task = newTask(baserules.TaskTypeProm, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
@@ -96,7 +96,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, ar)
// create anomaly rule task for evaluation
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else {
return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
@@ -213,7 +213,8 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
return alertsFound, nil
}
// newTask returns an appropriate group for the rule type
// newTask returns an appropriate group for
// rule type
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) baserules.Task {
if taskType == baserules.TaskTypeCh {
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)

View File

@@ -61,8 +61,6 @@ module.exports = {
curly: 'error', // Requires curly braces for all control statements
eqeqeq: ['error', 'always', { null: 'ignore' }], // Enforces === and !== (allows == null for null/undefined check)
'no-console': ['error', { allow: ['warn', 'error'] }], // Warns on console.log, allows console.warn/error
// TODO: Change this to error in May 2026
'max-params': ['warn', 3], // a function can have max 3 params after which it should become an object
// TypeScript rules
'@typescript-eslint/explicit-function-return-type': 'error', // Requires explicit return types on functions
@@ -118,7 +116,7 @@ module.exports = {
},
],
'import/no-extraneous-dependencies': ['error', { devDependencies: true }], // Prevents importing packages not in package.json
'import/no-cycle': 'warn', // Warns about circular dependencies
// 'import/no-cycle': 'warn', // TODO: Enable later to detect circular dependencies
// Import sorting rules
'simple-import-sort/imports': [
@@ -148,19 +146,6 @@ module.exports = {
'sonarjs/no-duplicate-string': 'off', // Disabled - can be noisy (enable periodically to check)
},
overrides: [
{
files: [
'**/*.test.{js,jsx,ts,tsx}',
'**/*.spec.{js,jsx,ts,tsx}',
'**/__tests__/**/*.{js,jsx,ts,tsx}',
],
rules: {
// Tests often have intentional duplication and complexity - disable SonarJS rules
'sonarjs/cognitive-complexity': 'off', // Tests can be complex
'sonarjs/no-identical-functions': 'off', // Similar test patterns are OK
'sonarjs/no-small-switch': 'off', // Small switches are OK in tests
},
},
{
files: ['src/api/generated/**/*.ts'],
rules: {
@@ -168,6 +153,7 @@ module.exports = {
'@typescript-eslint/explicit-module-boundary-types': 'off',
'no-nested-ternary': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'sonarjs/no-duplicate-string': 'off',
},
},
],

View File

@@ -2,11 +2,6 @@
Embrace the spirit of collaboration and contribute to the success of our open-source project by adhering to these frontend development guidelines with precision and passion.
### Export Style
- **React components** (`src/components/`, `src/container/`, `src/pages/`): Prefer **default exports** for the main component in each file
- **Utilities, hooks, APIs, types, constants** (`src/utils/`, `src/hooks/`, `src/api/`, `src/lib/`, `src/types/`, `src/constants/`): Prefer **named exports** for better tree-shaking and explicit imports
### React and Components
- Strive to create small and modular components, ensuring they are divided into individual pieces for improved maintainability and reusability.

View File

@@ -17,8 +17,6 @@ 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'],

View File

@@ -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"
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh && prettier --write src/api/generated && (eslint --fix src/api/generated || true)"
},
"engines": {
"node": ">=16.15.0"
@@ -52,7 +52,6 @@
"@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",

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="184" height="188" fill="none" viewBox="0 0 184 188"><path fill="#f3b01c" d="M108.092 130.021c18.166-2.018 35.293-11.698 44.723-27.854-4.466 39.961-48.162 65.218-83.83 49.711-3.286-1.425-6.115-3.796-8.056-6.844-8.016-12.586-10.65-28.601-6.865-43.135 10.817 18.668 32.81 30.111 54.028 28.122"/><path fill="#8d2676" d="M53.401 90.174c-7.364 17.017-7.682 36.94 1.345 53.336-31.77-23.902-31.423-75.052-.388-98.715 2.87-2.187 6.282-3.485 9.86-3.683 14.713-.776 29.662 4.91 40.146 15.507-21.3.212-42.046 13.857-50.963 33.555"/><path fill="#ee342f" d="M114.637 61.855C103.89 46.87 87.069 36.668 68.639 36.358c35.625-16.17 79.446 10.047 84.217 48.807.444 3.598-.139 7.267-1.734 10.512-6.656 13.518-18.998 24.002-33.42 27.882 10.567-19.599 9.263-43.544-3.065-61.704"/></svg>

Before

Width:  |  Height:  |  Size: 811 B

View File

@@ -1,7 +1,6 @@
{
"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",

View File

@@ -1,6 +1,5 @@
#!/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
@@ -12,33 +11,4 @@ find src/api/generated/services -mindepth 1 -maxdepth 1 -type d | while read -r
fi
done
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!"
echo "Tag files renamed to index.ts"

View File

@@ -194,10 +194,6 @@ 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'),
);

View File

@@ -17,7 +17,6 @@ import {
DashboardWidget,
EditRulesPage,
ErrorDetails,
ForgotPassword,
Home,
InfrastructureMonitoring,
InstalledIntegrations,
@@ -340,13 +339,6 @@ 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,

View File

@@ -1,222 +0,0 @@
/**
* ! 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;
};

View File

@@ -28,10 +28,8 @@ import type {
GatewaytypesPostableIngestionKeyLimitDTO,
GatewaytypesUpdatableIngestionKeyLimitDTO,
GetIngestionKeys200,
GetIngestionKeysParams,
RenderErrorResponseDTO,
SearchIngestionKeys200,
SearchIngestionKeysParams,
UpdateIngestionKeyLimitPathParameters,
UpdateIngestionKeyPathParameters,
} from '../sigNoz.schemas';
@@ -44,44 +42,35 @@ type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
* This endpoint returns the ingestion keys for a workspace
* @summary Get ingestion keys for workspace
*/
export const getIngestionKeys = (
params?: GetIngestionKeysParams,
signal?: AbortSignal,
) => {
export const getIngestionKeys = (signal?: AbortSignal) => {
return GeneratedAPIInstance<GetIngestionKeys200>({
url: `/api/v2/gateway/ingestion_keys`,
method: 'GET',
params,
signal,
});
};
export const getGetIngestionKeysQueryKey = (
params?: GetIngestionKeysParams,
) => {
return ['getIngestionKeys', ...(params ? [params] : [])] as const;
export const getGetIngestionKeysQueryKey = () => {
return ['getIngestionKeys'] as const;
};
export const getGetIngestionKeysQueryOptions = <
TData = Awaited<ReturnType<typeof getIngestionKeys>>,
TError = RenderErrorResponseDTO
>(
params?: GetIngestionKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKeys>>,
TError,
TData
>;
},
) => {
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKeys>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetIngestionKeysQueryKey(params);
const queryKey = queryOptions?.queryKey ?? getGetIngestionKeysQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getIngestionKeys>>> = ({
signal,
}) => getIngestionKeys(params, signal);
}) => getIngestionKeys(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKeys>>,
@@ -102,17 +91,14 @@ export type GetIngestionKeysQueryError = RenderErrorResponseDTO;
export function useGetIngestionKeys<
TData = Awaited<ReturnType<typeof getIngestionKeys>>,
TError = RenderErrorResponseDTO
>(
params?: GetIngestionKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKeys>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetIngestionKeysQueryOptions(params, options);
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKeys>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetIngestionKeysQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -128,11 +114,10 @@ export function useGetIngestionKeys<
*/
export const invalidateGetIngestionKeys = async (
queryClient: QueryClient,
params?: GetIngestionKeysParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetIngestionKeysQueryKey(params) },
{ queryKey: getGetIngestionKeysQueryKey() },
options,
);
@@ -677,45 +662,35 @@ export const useUpdateIngestionKeyLimit = <
* This endpoint returns the ingestion keys for a workspace
* @summary Search ingestion keys for workspace
*/
export const searchIngestionKeys = (
params?: SearchIngestionKeysParams,
signal?: AbortSignal,
) => {
export const searchIngestionKeys = (signal?: AbortSignal) => {
return GeneratedAPIInstance<SearchIngestionKeys200>({
url: `/api/v2/gateway/ingestion_keys/search`,
method: 'GET',
params,
signal,
});
};
export const getSearchIngestionKeysQueryKey = (
params?: SearchIngestionKeysParams,
) => {
return ['searchIngestionKeys', ...(params ? [params] : [])] as const;
export const getSearchIngestionKeysQueryKey = () => {
return ['searchIngestionKeys'] as const;
};
export const getSearchIngestionKeysQueryOptions = <
TData = Awaited<ReturnType<typeof searchIngestionKeys>>,
TError = RenderErrorResponseDTO
>(
params?: SearchIngestionKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof searchIngestionKeys>>,
TError,
TData
>;
},
) => {
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof searchIngestionKeys>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getSearchIngestionKeysQueryKey(params);
const queryKey = queryOptions?.queryKey ?? getSearchIngestionKeysQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof searchIngestionKeys>>
> = ({ signal }) => searchIngestionKeys(params, signal);
> = ({ signal }) => searchIngestionKeys(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof searchIngestionKeys>>,
@@ -736,17 +711,14 @@ export type SearchIngestionKeysQueryError = RenderErrorResponseDTO;
export function useSearchIngestionKeys<
TData = Awaited<ReturnType<typeof searchIngestionKeys>>,
TError = RenderErrorResponseDTO
>(
params?: SearchIngestionKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof searchIngestionKeys>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getSearchIngestionKeysQueryOptions(params, options);
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof searchIngestionKeys>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getSearchIngestionKeysQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -762,11 +734,10 @@ export function useSearchIngestionKeys<
*/
export const invalidateSearchIngestionKeys = async (
queryClient: QueryClient,
params?: SearchIngestionKeysParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getSearchIngestionKeysQueryKey(params) },
{ queryKey: getSearchIngestionKeysQueryKey() },
options,
);

View File

@@ -47,7 +47,7 @@ type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
* @summary Get metric alerts
*/
export const getMetricAlerts = (
params: GetMetricAlertsParams,
params?: GetMetricAlertsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAlerts200>({
@@ -66,7 +66,7 @@ export const getGetMetricAlertsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricAlertsParams,
params?: GetMetricAlertsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
@@ -103,7 +103,7 @@ export function useGetMetricAlerts<
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricAlertsParams,
params?: GetMetricAlertsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
@@ -128,7 +128,7 @@ export function useGetMetricAlerts<
*/
export const invalidateGetMetricAlerts = async (
queryClient: QueryClient,
params: GetMetricAlertsParams,
params?: GetMetricAlertsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
@@ -144,7 +144,7 @@ export const invalidateGetMetricAlerts = async (
* @summary Get metric dashboards
*/
export const getMetricDashboards = (
params: GetMetricDashboardsParams,
params?: GetMetricDashboardsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricDashboards200>({
@@ -165,7 +165,7 @@ export const getGetMetricDashboardsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricDashboardsParams,
params?: GetMetricDashboardsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
@@ -203,7 +203,7 @@ export function useGetMetricDashboards<
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricDashboardsParams,
params?: GetMetricDashboardsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
@@ -228,7 +228,7 @@ export function useGetMetricDashboards<
*/
export const invalidateGetMetricDashboards = async (
queryClient: QueryClient,
params: GetMetricDashboardsParams,
params?: GetMetricDashboardsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
@@ -244,7 +244,7 @@ export const invalidateGetMetricDashboards = async (
* @summary Get metric highlights
*/
export const getMetricHighlights = (
params: GetMetricHighlightsParams,
params?: GetMetricHighlightsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricHighlights200>({
@@ -265,7 +265,7 @@ export const getGetMetricHighlightsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricHighlightsParams,
params?: GetMetricHighlightsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
@@ -303,7 +303,7 @@ export function useGetMetricHighlights<
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricHighlightsParams,
params?: GetMetricHighlightsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
@@ -328,7 +328,7 @@ export function useGetMetricHighlights<
*/
export const invalidateGetMetricHighlights = async (
queryClient: QueryClient,
params: GetMetricHighlightsParams,
params?: GetMetricHighlightsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
@@ -526,7 +526,7 @@ export const useGetMetricAttributes = <
* @summary Get metric metadata
*/
export const getMetricMetadata = (
params: GetMetricMetadataParams,
params?: GetMetricMetadataParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricMetadata200>({
@@ -547,7 +547,7 @@ export const getGetMetricMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricMetadataParams,
params?: GetMetricMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
@@ -585,7 +585,7 @@ export function useGetMetricMetadata<
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricMetadataParams,
params?: GetMetricMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
@@ -610,7 +610,7 @@ export function useGetMetricMetadata<
*/
export const invalidateGetMetricMetadata = async (
queryClient: QueryClient,
params: GetMetricMetadataParams,
params?: GetMetricMetadataParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(

View File

@@ -650,11 +650,11 @@ export interface MetricsexplorertypesMetricAlertDTO {
/**
* @type string
*/
alertId: string;
alertId?: string;
/**
* @type string
*/
alertName: string;
alertName?: string;
}
export interface MetricsexplorertypesMetricAlertsResponseDTO {
@@ -662,24 +662,24 @@ export interface MetricsexplorertypesMetricAlertsResponseDTO {
* @type array
* @nullable true
*/
alerts: MetricsexplorertypesMetricAlertDTO[] | null;
alerts?: MetricsexplorertypesMetricAlertDTO[] | null;
}
export interface MetricsexplorertypesMetricAttributeDTO {
/**
* @type string
*/
key: string;
key?: string;
/**
* @type integer
* @minimum 0
*/
valueCount: number;
valueCount?: number;
/**
* @type array
* @nullable true
*/
values: string[] | null;
values?: string[] | null;
}
export interface MetricsexplorertypesMetricAttributesRequestDTO {
@@ -691,7 +691,7 @@ export interface MetricsexplorertypesMetricAttributesRequestDTO {
/**
* @type string
*/
metricName: string;
metricName?: string;
/**
* @type integer
* @nullable true
@@ -704,31 +704,31 @@ export interface MetricsexplorertypesMetricAttributesResponseDTO {
* @type array
* @nullable true
*/
attributes: MetricsexplorertypesMetricAttributeDTO[] | null;
attributes?: MetricsexplorertypesMetricAttributeDTO[] | null;
/**
* @type integer
* @format int64
*/
totalKeys: number;
totalKeys?: number;
}
export interface MetricsexplorertypesMetricDashboardDTO {
/**
* @type string
*/
dashboardId: string;
dashboardId?: string;
/**
* @type string
*/
dashboardName: string;
dashboardName?: string;
/**
* @type string
*/
widgetId: string;
widgetId?: string;
/**
* @type string
*/
widgetName: string;
widgetName?: string;
}
export interface MetricsexplorertypesMetricDashboardsResponseDTO {
@@ -736,7 +736,7 @@ export interface MetricsexplorertypesMetricDashboardsResponseDTO {
* @type array
* @nullable true
*/
dashboards: MetricsexplorertypesMetricDashboardDTO[] | null;
dashboards?: MetricsexplorertypesMetricDashboardDTO[] | null;
}
export interface MetricsexplorertypesMetricHighlightsResponseDTO {
@@ -744,65 +744,74 @@ export interface MetricsexplorertypesMetricHighlightsResponseDTO {
* @type integer
* @minimum 0
*/
activeTimeSeries: number;
activeTimeSeries?: number;
/**
* @type integer
* @minimum 0
*/
dataPoints: number;
dataPoints?: number;
/**
* @type integer
* @minimum 0
*/
lastReceived: number;
lastReceived?: number;
/**
* @type integer
* @minimum 0
*/
totalTimeSeries: number;
totalTimeSeries?: number;
}
export interface MetricsexplorertypesMetricMetadataDTO {
/**
* @type string
*/
description: string;
description?: string;
/**
* @type boolean
*/
isMonotonic: boolean;
temporality: MetrictypesTemporalityDTO;
type: MetrictypesTypeDTO;
isMonotonic?: boolean;
/**
* @type string
*/
unit: string;
temporality?: string;
/**
* @type string
*/
type?: string;
/**
* @type string
*/
unit?: string;
}
export interface MetricsexplorertypesStatDTO {
/**
* @type string
*/
description: string;
description?: string;
/**
* @type string
*/
metricName: string;
metricName?: string;
/**
* @type integer
* @minimum 0
*/
samples: number;
samples?: number;
/**
* @type integer
* @minimum 0
*/
timeseries: number;
type: MetrictypesTypeDTO;
timeseries?: number;
/**
* @type string
*/
unit: string;
type?: string;
/**
* @type string
*/
unit?: string;
}
export interface MetricsexplorertypesStatsRequestDTO {
@@ -810,12 +819,12 @@ export interface MetricsexplorertypesStatsRequestDTO {
* @type integer
* @format int64
*/
end: number;
end?: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type integer
*/
limit: number;
limit?: number;
/**
* @type integer
*/
@@ -825,7 +834,7 @@ export interface MetricsexplorertypesStatsRequestDTO {
* @type integer
* @format int64
*/
start: number;
start?: number;
}
export interface MetricsexplorertypesStatsResponseDTO {
@@ -833,52 +842,51 @@ export interface MetricsexplorertypesStatsResponseDTO {
* @type array
* @nullable true
*/
metrics: MetricsexplorertypesStatDTO[] | null;
metrics?: MetricsexplorertypesStatDTO[] | null;
/**
* @type integer
* @minimum 0
*/
total: number;
total?: number;
}
export interface MetricsexplorertypesTreemapEntryDTO {
/**
* @type string
*/
metricName: string;
metricName?: string;
/**
* @type number
* @format double
*/
percentage: number;
percentage?: number;
/**
* @type integer
* @minimum 0
*/
totalValue: number;
totalValue?: number;
}
export enum MetricsexplorertypesTreemapModeDTO {
timeseries = 'timeseries',
samples = 'samples',
}
export interface MetricsexplorertypesTreemapRequestDTO {
/**
* @type integer
* @format int64
*/
end: number;
end?: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type integer
*/
limit: number;
mode: MetricsexplorertypesTreemapModeDTO;
limit?: number;
/**
* @type string
*/
mode?: string;
/**
* @type integer
* @format int64
*/
start: number;
start?: number;
}
export interface MetricsexplorertypesTreemapResponseDTO {
@@ -886,47 +894,41 @@ export interface MetricsexplorertypesTreemapResponseDTO {
* @type array
* @nullable true
*/
samples: MetricsexplorertypesTreemapEntryDTO[] | null;
samples?: MetricsexplorertypesTreemapEntryDTO[] | null;
/**
* @type array
* @nullable true
*/
timeseries: MetricsexplorertypesTreemapEntryDTO[] | null;
timeseries?: MetricsexplorertypesTreemapEntryDTO[] | null;
}
export interface MetricsexplorertypesUpdateMetricMetadataRequestDTO {
/**
* @type string
*/
description: string;
description?: string;
/**
* @type boolean
*/
isMonotonic: boolean;
isMonotonic?: boolean;
/**
* @type string
*/
metricName: string;
temporality: MetrictypesTemporalityDTO;
type: MetrictypesTypeDTO;
metricName?: string;
/**
* @type string
*/
unit: string;
temporality?: string;
/**
* @type string
*/
type?: string;
/**
* @type string
*/
unit?: string;
}
export enum MetrictypesTemporalityDTO {
delta = 'delta',
cumulative = 'cumulative',
unspecified = 'unspecified',
}
export enum MetrictypesTypeDTO {
gauge = 'gauge',
sum = 'sum',
histogram = 'histogram',
summary = 'summary',
exponentialhistogram = 'exponentialhistogram',
}
export interface PreferencetypesPreferenceDTO {
/**
* @type array
@@ -1049,7 +1051,7 @@ export interface Querybuildertypesv5OrderByKeyDTO {
/**
* @type string
*/
name: string;
name?: string;
/**
* @type string
*/
@@ -1141,79 +1143,6 @@ 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
@@ -1418,7 +1347,7 @@ export interface TypesPostableForgotPasswordDTO {
/**
* @type string
*/
email: string;
email?: string;
/**
* @type string
*/
@@ -1426,7 +1355,7 @@ export interface TypesPostableForgotPasswordDTO {
/**
* @type string
*/
orgId: string;
orgId?: string;
}
export interface TypesPostableInviteDTO {
@@ -1661,132 +1590,6 @@ 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;
};
@@ -2048,19 +1851,6 @@ export type GetFeatures200 = {
status?: string;
};
export type GetIngestionKeysParams = {
/**
* @type integer
* @description undefined
*/
page?: number;
/**
* @type integer
* @description undefined
*/
per_page?: number;
};
export type GetIngestionKeys200 = {
data?: GatewaytypesGettableIngestionKeysDTO;
/**
@@ -2100,24 +1890,6 @@ export type DeleteIngestionKeyLimitPathParameters = {
export type UpdateIngestionKeyLimitPathParameters = {
limitId: string;
};
export type SearchIngestionKeysParams = {
/**
* @type string
* @description undefined
*/
name?: string;
/**
* @type integer
* @description undefined
*/
page?: number;
/**
* @type integer
* @description undefined
*/
per_page?: number;
};
export type SearchIngestionKeys200 = {
data?: GatewaytypesGettableIngestionKeysDTO;
/**
@@ -2131,7 +1903,7 @@ export type GetMetricAlertsParams = {
* @type string
* @description undefined
*/
metricName: string;
metricName?: string;
};
export type GetMetricAlerts200 = {
@@ -2147,7 +1919,7 @@ export type GetMetricDashboardsParams = {
* @type string
* @description undefined
*/
metricName: string;
metricName?: string;
};
export type GetMetricDashboards200 = {
@@ -2163,7 +1935,7 @@ export type GetMetricHighlightsParams = {
* @type string
* @description undefined
*/
metricName: string;
metricName?: string;
};
export type GetMetricHighlights200 = {
@@ -2190,7 +1962,7 @@ export type GetMetricMetadataParams = {
* @type string
* @description undefined
*/
metricName: string;
metricName?: string;
};
export type GetMetricMetadata200 = {

View File

@@ -18,7 +18,6 @@ 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';

View File

@@ -140,7 +140,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}, [selectedValues, allAvailableValues, enableAllSelection]);
// Define allOptionShown earlier in the code
const allOptionShown = value === ALL_SELECTED_VALUE;
const allOptionShown = useMemo(
() => value === ALL_SELECTED_VALUE || value === 'ALL',
[value],
);
// Value passed to the underlying Ant Select component
const displayValue = useMemo(

View File

@@ -648,13 +648,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
) : (
<Typography.Text
className="value-string"
ellipsis={{
tooltip: {
placement: 'top',
mouseEnterDelay: 0.2,
mouseLeaveDelay: 0,
},
}}
ellipsis={{ tooltip: { placement: 'top' } }}
>
{String(value)}
</Typography.Text>

View File

@@ -1,7 +1,6 @@
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',

View File

@@ -34,230 +34,159 @@ const themeColors = {
cyan: '#00FFFF',
},
chartcolors: {
// Blues (3)
dodgerBlue: '#2F80ED',
royalBlue: '#3366E6',
steelBlue: '#4682B4',
// Teals / Cyans (3)
turquoise: '#00CEC9',
lagoon: '#1ABC9C',
cyanBright: '#22A6F2',
// Greens (3)
emeraldGreen: '#27AE60',
mediumSeaGreen: '#3CB371',
limeGreen: '#A3E635',
// Yellows / Golds (3)
festivalYellow: '#F2C94C',
sunflower: '#FFD93D',
warmAmber: '#FFCA28',
// Oranges (3)
festivalOrange: '#F2994A',
coralOrange: '#E17055',
pumpkin: '#FF7F50',
// Reds (3)
radicalRed: '#FF1A66',
crimsonRed: '#EB5757',
fireRed: '#E10600',
// Pinks (3)
hotPink: '#E84393',
rosePink: '#FD79A8',
blush: '#FF7EB6',
// Purples / Violets (3)
mediumPurple: '#BB6BD9',
royalPurple: '#9B51E0',
orchid: '#DA77F2',
// Accent / Neon / Unique Colors (3)
neonViolet: '#C77DFF',
electricPurple: '#6C5CE7',
arcticBlue: '#48DBFB',
// Extended palette — systematic variations to reach 100 colors
blue1: '#1F63E0',
blue2: '#3A7AED',
blue3: '#5A9DF5',
cyan1: '#00B0AA',
cyan2: '#33D6C2',
cyan3: '#66E9DA',
green1: '#1E8449',
green2: '#2ECC71',
green3: '#58D68D',
yellow1: '#F1C40F',
yellow2: '#F7DC6F',
yellow3: '#F9E79F',
orange1: '#D35400',
orange2: '#E67E22',
orange3: '#F5B041',
red1: '#C0392B',
red2: '#E74C3C',
red3: '#EC7063',
pink1: '#D81B60',
pink2: '#E91E63',
pink3: '#F06292',
purple1: '#8E44AD',
purple2: '#9B59B6',
purple3: '#BB8FCE',
teal1: '#009688',
teal2: '#1ABC9C',
teal3: '#48C9B0',
lime1: '#A3E635',
lime2: '#B9F18D',
lime3: '#D4FFB0',
gold1: '#F39C12',
gold2: '#F1C40F',
gold3: '#F7DC6F',
coral1: '#E67E22',
coral2: '#F39C12',
coral3: '#F5B041',
crimson1: '#C0392B',
crimson2: '#E74C3C',
crimson3: '#EC7063',
violet1: '#8E44AD',
violet2: '#9B59B6',
violet3: '#BB8FCE',
aqua1: '#00BFFF',
aqua2: '#1E90FF',
aqua3: '#63B8FF',
forest1: '#27AE60',
forest2: '#2ECC71',
forest3: '#58D68D',
blush1: '#FF6F91',
blush2: '#FF85A2',
blush3: '#FFA0B3',
lavender1: '#9B59B6',
lavender2: '#AF7AC5',
lavender3: '#C39BD3',
tomato1: '#E74C3C',
tomato2: '#EC7063',
tomato3: '#F1948A',
salmon1: '#FF6B6B',
salmon2: '#FF8787',
salmon3: '#FFA1A1',
mustard1: '#F1C40F',
mustard2: '#F7DC6F',
mustard3: '#F9E79F',
teal4: '#1ABC9C',
teal5: '#48C9B0',
teal6: '#76D7C4',
magenta1: '#D6336C',
magenta2: '#E84393',
magenta3: '#F06292',
violet4: '#7D3C98',
violet5: '#8E44AD',
violet6: '#9B59B6',
green4: '#229954',
green5: '#27AE60',
green6: '#52BE80',
blue4: '#2874A6',
blue5: '#2E86C1',
blue6: '#3498DB',
red4: '#C0392B',
red5: '#E74C3C',
red6: '#EC7063',
orange4: '#D35400',
orange5: '#E67E22',
orange6: '#EB984E',
pink4: '#C2185B',
pink5: '#D81B60',
pink6: '#E91E63',
gold4: '#B7950B',
gold5: '#F1C40F',
gold6: '#F4D03F',
dodgerBlue: '#2F80ED',
mediumOrchid: '#BB6BD9',
seaBuckthorn: '#F2994A',
seaGreen: '#219653',
turquoiseBlue: '#56CCF2',
festivalOrange: '#F2C94C',
silver: '#BDBDBD',
outrageousOrange: '#FF6633',
roseBud: '#FFB399',
canary: '#FFFF99',
deepSkyBlue: '#00B3E6',
goldTips: '#E6B333',
royalBlue: '#3366E6',
avocado: '#999966',
mintGreen: '#99FF99',
chestnut: '#B34D4D',
lima: '#80B300',
olive: '#809900',
beautyBush: '#E6B3B3',
danube: '#6680B3',
oliveDrab: '#66991A',
lavenderRose: '#FF99E6',
electricLime: '#CCFF1A',
robin: '#3F5ECC',
harleyOrange: '#E6331A',
turquoise: '#33FFCC',
gladeGreen: '#66994D',
hemlock: '#66664D',
vidaLoca: '#4D8000',
rust: '#B33300',
red: '#FF0000', // Adding more colors, we need to get better colors from design team
blue: '#0000FF',
green: '#00FF00',
yellow: '#FFFF00',
purple: '#800080',
cyan: '#00FFFF',
magenta: '#FF00FF',
orange: '#FFA500',
pink: '#FFC0CB',
brown: '#A52A2A',
teal: '#008080',
lime: '#00FF00',
maroon: '#800000',
navy: '#000080',
aquamarine: '#7FFFD4',
darkSeaGreen: '#8FBC8F',
gray: '#808080',
skyBlue: '#87CEEB',
indigo: '#4B0082',
slateGray: '#708090',
chocolate: '#D2691E',
tomato: '#FF6347',
steelBlue: '#4682B4',
peru: '#CD853F',
darkOliveGreen: '#556B2F',
indianRed: '#CD5C5C',
mediumSlateBlue: '#7B68EE',
rosyBrown: '#BC8F8F',
darkSlateGray: '#2F4F4F',
mediumAquamarine: '#66CDAA',
lavender: '#E6E6FA',
thistle: '#D8BFD8',
salmon: '#FA8072',
darkSalmon: '#E9967A',
paleVioletRed: '#DB7093',
mediumPurple: '#9370DB',
darkOrchid: '#9932CC',
lawnGreen: '#7CFC00',
mediumSeaGreen: '#3CB371',
lightCoral: '#F08080',
gold: '#FFD700',
sandyBrown: '#F4A460',
darkKhaki: '#BDB76B',
cornflowerBlue: '#6495ED',
mediumVioletRed: '#C71585',
paleGreen: '#98FB98',
},
lightModeColor: {
radicalRed: '#D81B60',
dodgerBlueDark: '#1E5BD9',
steelgrey: '#344B6B',
steelpurple: '#5E548E',
steelindigo: '#8E4A7C',
steelpink: '#B63A6F',
steelcoral: '#E14B5A',
steelorange: '#E76F2F',
steelgold: '#E09B00',
steelrust: '#C93A50',
steelgreen: '#2F7D69',
mediumOrchidDark: '#8E24AA',
seaBuckthornDark: '#C75A00',
seaGreen: '#1E7F5A',
turquoiseBlueDark: '#007EA7',
silverDark: '#5F5F5F',
outrageousOrangeDark: '#E64A19',
roseBudDark: '#D84315',
deepSkyBlueDark: '#0277BD',
royalBlue: '#2A4FDB',
avocadoDark: '#6B6B1E',
mintGreenDark: '#2E9E55',
chestnut: '#8B3A3A',
limaDark: '#5C7F00',
olive: '#6E7F00',
beautyBushDark: '#C93C3C',
danube: '#4F6FB3',
oliveDrab: '#4F7F1A',
lavenderRoseDark: '#B0178F',
electricLimeDark: '#6B8F00',
robin: '#2F4FCC',
harleyOrange: '#CC2E12',
gladeGreen: '#4F7F46',
hemlock: '#5C5C45',
vidaLoca: '#3D6B00',
rust: '#993300',
red: '#C62828',
blue: '#1A237E',
green: '#1B7F3A',
purple: '#6A1B9A',
magentaDark: '#B000B5',
pinkDark: '#C2185B',
brown: '#7A3A1E',
teal: '#006D6F',
limeDark: '#4C8C2B',
maroon: '#6D1B1B',
navy: '#0D1B5E',
gray: '#616161',
skyBlueDark: '#0288D1',
indigo: '#303F9F',
slateGray: '#556B7C',
chocolate: '#9C4A1A',
tomato: '#E53935',
steelBlue: '#3A6EA5',
peruDark: '#B35E00',
darkOliveGreen: '#445B1F',
indianRed: '#B04040',
mediumSlateBlue: '#5C6BC0',
rosyBrownDark: '#A94444',
darkSlateGray: '#2E4A4A',
fuchsia: '#C511C5',
salmonDark: '#E64A3C',
darkSalmonDark: '#C85A3A',
paleVioletRedDark: '#C2186A',
mediumPurple: '#7E57C2',
darkOrchid: '#7B1FA2',
mediumSeaGreenDark: '#2E8B57',
lightCoralDark: '#E57373',
gold: '#D4AF37',
sandyBrownDark: '#C76A15',
darkKhakiDark: '#8A7F00',
cornflowerBlueDark: '#355FCC',
mediumVioletRed: '#AD1457',
paleGreenDark: '#2E7D32',
radicalRed: '#FF1A66',
dodgerBlueDark: '#0C6EED',
steelgrey: '#2f4b7c',
steelpurple: '#665191',
steelindigo: '#a05195',
steelpink: '#d45087',
steelcoral: '#f95d6a',
steelorange: '#ff7c43',
steelgold: '#ffa600',
steelrust: '#de425b',
steelgreen: '#41967e',
mediumOrchidDark: '#C326FD',
seaBuckthornDark: '#E66E05',
seaGreen: '#219653',
turquoiseBlueDark: '#0099CC',
silverDark: '#757575',
outrageousOrangeDark: '#F9521A',
roseBudDark: '#EB6437',
deepSkyBlueDark: '#0595BD',
royalBlue: '#3366E6',
avocadoDark: '#8E8E29',
mintGreenDark: '#00C700',
chestnut: '#B34D4D',
limaDark: '#6E9900',
olive: '#809900',
beautyBushDark: '#E25555',
danube: '#6680B3',
oliveDrab: '#66991A',
lavenderRoseDark: '#F024BD',
electricLimeDark: '#84A800',
robin: '#3F5ECC',
harleyOrange: '#E6331A',
gladeGreen: '#66994D',
hemlock: '#66664D',
vidaLoca: '#4D8000',
rust: '#B33300',
red: '#FF0000', // Adding more colors, we need to get better colors from design team
blue: '#0000FF',
green: '#00FF00',
purple: '#800080',
magentaDark: '#EB00EB',
pinkDark: '#FF3D5E',
brown: '#A52A2A',
teal: '#008080',
limeDark: '#07A207',
maroon: '#800000',
navy: '#000080',
gray: '#808080',
skyBlueDark: '#0CA7E4',
indigo: '#4B0082',
slateGray: '#708090',
chocolate: '#D2691E',
tomato: '#FF6347',
steelBlue: '#4682B4',
peruDark: '#D16E0A',
darkOliveGreen: '#556B2F',
indianRed: '#CD5C5C',
mediumSlateBlue: '#7B68EE',
rosyBrownDark: '#CB4848',
darkSlateGray: '#2F4F4F',
fuchsia: '#FF0AFF',
salmonDark: '#FF432E',
darkSalmonDark: '#D26541',
paleVioletRedDark: '#E83089',
mediumPurple: '#9370DB',
darkOrchid: '#9932CC',
mediumSeaGreenDark: '#109E50',
lightCoralDark: '#F85959',
gold: '#FFD700',
sandyBrownDark: '#D97117',
darkKhakiDark: '#99900A',
cornflowerBlueDark: '#3371E6',
mediumVioletRed: '#C71585',
paleGreenDark: '#0D910D',
},
errorColor: '#d32f2f',
royalGrey: '#888888',

View File

@@ -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 } from 'antd';
import { Spin, Table, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
@@ -14,13 +14,11 @@ 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';
@@ -127,67 +125,51 @@ function DomainList(): JSX.Element {
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
/>
</div>
{!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"
/>
<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"
/>
<div className="no-filtered-domains-message">
<div className="no-domain-title">
No External API calls detected with applied filters.
<Typography.Text className="no-filtered-domains-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
<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'
}
/>
)}
),
}}
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]}

View File

@@ -180,59 +180,10 @@
.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 {

View File

@@ -18,8 +18,8 @@ import { useWidgetsByDynamicVariableId } from 'hooks/dashboard/useWidgetsByDynam
import { getWidgetsHavingDynamicVariableAttribute } from 'hooks/dashboard/utils';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { isEmpty, map } from 'lodash-es';
import {
ArrowLeft,

View File

@@ -1,69 +0,0 @@
import { memo, useEffect, useMemo } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import { VariableItemProps } from './VariableItem';
import { customVariableSelectStrategy } from './variableSelectStrategy/customVariableSelectStrategy';
type CustomVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'onValueUpdate'
>;
function CustomVariableInput({
variableData,
onValueUpdate,
}: CustomVariableInputProps): JSX.Element {
const optionsData: (string | number | boolean)[] = useMemo(() => {
return sortValues(
commaValuesParser(variableData.customValue || ''),
variableData.sort,
) as (string | number | boolean)[];
}, [variableData.customValue, variableData.sort]);
const {
value,
defaultValue,
enableSelectAll,
onChange,
onDropdownVisibleChange,
handleClear,
applyDefaultIfNeeded,
} = useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy: customVariableSelectStrategy,
});
// Apply default on mount — options are available synchronously for custom variables
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(applyDefaultIfNeeded, []);
const selectOptions = useMemo(
() =>
optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
})),
[optionsData],
);
return (
<SelectVariableInput
variableId={variableData.id}
options={selectOptions}
value={value}
onChange={onChange}
onDropdownVisibleChange={onDropdownVisibleChange}
onClear={handleClear}
enableSelectAll={enableSelectAll}
defaultValue={defaultValue}
isMultiSelect={variableData.multiSelect}
/>
);
}
export default memo(CustomVariableInput);

View File

@@ -25,6 +25,12 @@
}
}
&.focused {
.variable-value {
outline: 1px solid var(--bg-robin-400);
}
}
.variable-value {
display: flex;
min-width: 120px;
@@ -42,11 +48,6 @@
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
&:hover,
&:focus-within {
outline: 1px solid var(--bg-robin-400);
}
}
.variable-select {
@@ -98,6 +99,12 @@
.lightMode {
.variable-item {
&.focused {
.variable-value {
border: 1px solid var(--bg-robin-400);
}
}
.variable-name {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
@@ -108,11 +115,6 @@
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
color: var(--bg-ink-400);
&:hover,
&:focus-within {
outline: 1px solid var(--bg-robin-400);
}
}
}
}
@@ -122,9 +124,3 @@
padding: 4px 12px;
font-size: 12px;
}
.dashboard-variables-selection-container {
display: flex;
flex-wrap: wrap;
gap: 12px;
}

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo } from 'react';
import { memo, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { Row } from 'antd';
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
@@ -7,12 +7,14 @@ import {
useDashboardVariablesSelector,
} from 'hooks/dashboard/useDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { isEmpty } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import DynamicVariableSelection from './DynamicVariableSelection';
import { onUpdateVariableNode } from './util';
import VariableItem from './VariableItem';
@@ -20,6 +22,7 @@ import './DashboardVariableSelection.styles.scss';
function DashboardVariableSelection(): JSX.Element | null {
const {
selectedDashboard,
setSelectedDashboard,
updateLocalStorageDashboardVariables,
variablesToGetUpdated,
@@ -49,36 +52,34 @@ function DashboardVariableSelection(): JSX.Element | null {
);
}, [getUrlVariables, updateUrlVariable, dashboardVariables]);
// Memoize the order key to avoid unnecessary triggers
const dependencyOrderKey = useMemo(
() => dependencyData?.order?.join(',') ?? '',
[dependencyData?.order],
);
// Trigger refetch when dependency order changes or global time changes
useEffect(() => {
if (dependencyData?.order && dependencyData.order.length > 0) {
setVariablesToGetUpdated(dependencyData?.order || []);
}
// this handles the case where the dependency order changes i.e. variable list updated via creation or deletion etc. and we need to refetch the variables
// also trigger when the global time changes
useEffect(
() => {
if (!isEmpty(dependencyData?.order)) {
setVariablesToGetUpdated(dependencyData?.order || []);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dependencyOrderKey, minTime, maxTime]);
[JSON.stringify(dependencyData?.order), minTime, maxTime],
);
// Performance optimization: For dynamic variables with allSelected=true, we don't store
// individual values in localStorage since we can always derive them from available options.
// This makes localStorage much lighter and more efficient.
const onValueUpdate = useCallback(
(
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
// eslint-disable-next-line sonarjs/cognitive-complexity
): void => {
const onValueUpdate = (
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
// eslint-disable-next-line sonarjs/cognitive-complexity
): void => {
if (id) {
// For dynamic variables, only store in localStorage when NOT allSelected
// This makes localStorage much lighter by avoiding storing all individual values
const variable = dashboardVariables[id] || dashboardVariables[name];
const isDynamic = variable.type === 'DYNAMIC';
const variable = dashboardVariables?.[id] || dashboardVariables?.[name];
const isDynamic = variable?.type === 'DYNAMIC';
updateLocalStorageDashboardVariables(name, value, allSelected, isDynamic);
if (allSelected) {
@@ -87,39 +88,41 @@ function DashboardVariableSelection(): JSX.Element | null {
updateUrlVariable(name || id, value);
}
setSelectedDashboard((prev) => {
if (prev) {
const oldVariables = { ...prev?.data.variables };
// this is added to handle case where we have two different
// schemas for variable response
if (oldVariables?.[id]) {
oldVariables[id] = {
...oldVariables[id],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
if (oldVariables?.[name]) {
oldVariables[name] = {
...oldVariables[name],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
return {
...prev,
data: {
...prev?.data,
variables: {
...oldVariables,
if (selectedDashboard) {
setSelectedDashboard((prev) => {
if (prev) {
const oldVariables = prev?.data.variables;
// this is added to handle case where we have two different
// schemas for variable response
if (oldVariables?.[id]) {
oldVariables[id] = {
...oldVariables[id],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
if (oldVariables?.[name]) {
oldVariables[name] = {
...oldVariables[name],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
return {
...prev,
data: {
...prev?.data,
variables: {
...oldVariables,
},
},
},
};
}
return prev;
});
};
}
return prev;
});
}
if (dependencyData) {
const updatedVariables: string[] = [];
@@ -135,24 +138,22 @@ function DashboardVariableSelection(): JSX.Element | null {
} else {
setVariablesToGetUpdated((prev) => prev.filter((v) => v !== name));
}
},
[
// This can be removed
dashboardVariables,
updateLocalStorageDashboardVariables,
dependencyData,
updateUrlVariable,
setSelectedDashboard,
setVariablesToGetUpdated,
],
);
}
};
return (
<Row className="dashboard-variables-selection-container">
<Row style={{ display: 'flex', gap: '12px' }}>
{sortedVariablesArray.map((variable) => {
const key = `${variable.name}${variable.id}${variable.order}`;
return (
return variable.type === 'DYNAMIC' ? (
<DynamicVariableSelection
key={key}
existingVariables={dashboardVariables}
variableData={variable}
onValueUpdate={onValueUpdate}
/>
) : (
<VariableItem
key={key}
existingVariables={dashboardVariables}

View File

@@ -1,343 +0,0 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useDebounce from 'hooks/useDebounce';
import { isEmpty } from 'lodash-es';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import { getOptionsForDynamicVariable } from './util';
import { VariableItemProps } from './VariableItem';
import { dynamicVariableSelectStrategy } from './variableSelectStrategy/dynamicVariableSelectStrategy';
import './DashboardVariableSelection.styles.scss';
type DynamicVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'onValueUpdate' | 'existingVariables'
>;
// eslint-disable-next-line sonarjs/cognitive-complexity
function DynamicVariableInput({
variableData,
onValueUpdate,
existingVariables,
}: DynamicVariableInputProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const [isRetryableError, setIsRetryableError] = useState<boolean>(true);
const [isComplete, setIsComplete] = useState<boolean>(false);
const [filteredOptionsData, setFilteredOptionsData] = useState<
(string | number | boolean)[]
>([]);
const [relatedValues, setRelatedValues] = useState<string[]>([]);
const [originalRelatedValues, setOriginalRelatedValues] = useState<string[]>(
[],
);
// Track dropdown open state for auto-checking new values
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
const [apiSearchText, setApiSearchText] = useState<string>('');
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
// Build a memoized list of all currently available option strings (normalized + related)
const allAvailableOptionStrings = useMemo(
() => [
...new Set([
...optionsData.map((v) => v.toString()),
...relatedValues.map((v) => v.toString()),
]),
],
[optionsData, relatedValues],
);
const {
value,
tempSelection,
setTempSelection,
handleClear,
enableSelectAll,
defaultValue,
applyDefaultIfNeeded,
onChange,
onDropdownVisibleChange,
} = useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy: dynamicVariableSelectStrategy,
allAvailableOptionStrings,
});
// Create a dependency key from all dynamic variables
const dynamicVariablesKey = useMemo(() => {
if (!existingVariables) {
return 'no_variables';
}
const dynamicVars = Object.values(existingVariables)
.filter((v) => v.type === 'DYNAMIC')
.map(
(v) => `${v.name || 'unnamed'}:${JSON.stringify(v.selectedValue || null)}`,
)
.join('|');
return dynamicVars || 'no_dynamic_variables';
}, [existingVariables]);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// existing query is the query made from the other dynamic variables around this one with there current values
// for e.g. k8s.namespace.name IN ["zeus", "gene"] AND doc_op_type IN ["test"]
// eslint-disable-next-line sonarjs/cognitive-complexity
const existingQuery = useMemo(() => {
if (!existingVariables || !variableData.dynamicVariablesAttribute) {
return '';
}
const queryParts: string[] = [];
Object.entries(existingVariables).forEach(([, variable]) => {
// Skip the current variable being processed
if (variable.id === variableData.id) {
return;
}
// Only include dynamic variables that have selected values and are not selected as ALL
if (
variable.type === 'DYNAMIC' &&
variable.dynamicVariablesAttribute &&
variable.selectedValue &&
!isEmpty(variable.selectedValue) &&
(variable.showALLOption ? !variable.allSelected : true)
) {
const attribute = variable.dynamicVariablesAttribute;
const values = Array.isArray(variable.selectedValue)
? variable.selectedValue
: [variable.selectedValue];
// Filter out empty values and convert to strings
const validValues = values
.filter((val) => val !== null && val !== undefined && val !== '')
.map((val) => val.toString());
if (validValues.length > 0) {
// Format values for query - wrap strings in quotes, keep numbers as is
const formattedValues = validValues.map((val) => {
// Check if value is a number
const numValue = Number(val);
if (!Number.isNaN(numValue) && Number.isFinite(numValue)) {
return val; // Keep as number
}
// Escape single quotes and wrap in quotes
return `'${val.replace(/'/g, "\\'")}'`;
});
if (formattedValues.length === 1) {
queryParts.push(`${attribute} = ${formattedValues[0]}`);
} else {
queryParts.push(`${attribute} IN [${formattedValues.join(', ')}]`);
}
}
}
});
return queryParts.join(' AND ');
}, [
existingVariables,
variableData.id,
variableData.dynamicVariablesAttribute,
]);
// Wrap the hook's onDropdownVisibleChange to also track isDropdownOpen and handle cleanup
const handleSelectDropdownVisibilityChange = useCallback(
(visible: boolean): void => {
setIsDropdownOpen(visible);
onDropdownVisibleChange(visible);
if (!visible) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
setApiSearchText('');
}
},
[onDropdownVisibleChange, optionsData, originalRelatedValues],
);
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || `variable_${variableData.id}`,
dynamicVariablesKey,
minTime,
maxTime,
debouncedApiSearchText,
variableData.dynamicVariablesSource,
variableData.dynamicVariablesAttribute,
],
{
enabled:
variableData.type === 'DYNAMIC' &&
!!variableData.dynamicVariablesSource &&
!!variableData.dynamicVariablesAttribute,
queryFn: () =>
getFieldValues(
variableData.dynamicVariablesSource?.toLowerCase() === 'all telemetry'
? undefined
: (variableData.dynamicVariablesSource?.toLowerCase() as
| 'traces'
| 'logs'
| 'metrics'),
variableData.dynamicVariablesAttribute,
debouncedApiSearchText,
minTime,
maxTime,
existingQuery,
),
onSuccess: (data) => {
const newNormalizedValues = data.data?.normalizedValues || [];
const newRelatedValues = data.data?.relatedValues || [];
if (!debouncedApiSearchText) {
setOptionsData(newNormalizedValues);
setIsComplete(data.data?.complete || false);
}
setFilteredOptionsData(newNormalizedValues);
setRelatedValues(newRelatedValues);
setOriginalRelatedValues(newRelatedValues);
// Only run auto-check logic when necessary to avoid performance issues
if (variableData.allSelected && isDropdownOpen) {
// Build the latest full list from API (normalized + related)
const latestValues = [
...new Set([
...newNormalizedValues.map((v) => v.toString()),
...newRelatedValues.map((v) => v.toString()),
]),
];
// Update temp selection to exactly reflect latest API values when ALL is active
const currentStrings = Array.isArray(tempSelection)
? tempSelection.map((v) => v.toString())
: tempSelection
? [tempSelection.toString()]
: [];
const areSame =
currentStrings.length === latestValues.length &&
latestValues.every((v) => currentStrings.includes(v));
if (!areSame) {
setTempSelection(latestValues);
}
}
// Apply default if no value is selected (e.g., new variable, first load)
if (!debouncedApiSearchText) {
const allNewOptions = [
...new Set([
...newNormalizedValues.map((v) => v.toString()),
...newRelatedValues.map((v) => v.toString()),
]),
];
applyDefaultIfNeeded(allNewOptions);
}
},
onError: (error: any) => {
if (error) {
let message = SOMETHING_WENT_WRONG;
if (error?.message) {
message = error?.message;
} else {
message =
'Please make sure configuration is valid and you have required setup and permissions';
}
setErrorMessage(message);
// Check if error is retryable (5xx) or not (4xx)
const isRetryable = checkIfRetryableError(error);
setIsRetryableError(isRetryable);
}
},
},
);
const handleRetry = useCallback((): void => {
setErrorMessage(null);
setIsRetryableError(true);
refetch();
}, [refetch]);
const handleSearch = useCallback(
(text: string) => {
if (isComplete) {
if (!text) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
return;
}
const lowerText = text.toLowerCase();
setFilteredOptionsData(
optionsData.filter((option) =>
option.toString().toLowerCase().includes(lowerText),
),
);
setRelatedValues(
originalRelatedValues.filter((val) =>
val.toLowerCase().includes(lowerText),
),
);
} else {
setApiSearchText(text);
}
},
[isComplete, optionsData, originalRelatedValues],
);
const selectOptions = useMemo(
() =>
getOptionsForDynamicVariable(filteredOptionsData || [], relatedValues || []),
[filteredOptionsData, relatedValues],
);
return (
<SelectVariableInput
variableId={variableData.id}
options={selectOptions}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleSelectDropdownVisibilityChange}
onClear={handleClear}
enableSelectAll={enableSelectAll}
defaultValue={defaultValue}
isMultiSelect={variableData.multiSelect}
// dynamic variable specific + API related props
loading={isLoading}
errorMessage={errorMessage}
onRetry={handleRetry}
isDynamicVariable
showRetryButton={isRetryableError}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
onSearch={handleSearch}
/>
);
}
export default memo(DynamicVariableInput);

View File

@@ -0,0 +1,602 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable no-nested-ternary */
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useDebounce from 'hooks/useDebounce';
import { isEmpty, isUndefined } from 'lodash-es';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
import { popupContainer } from 'utils/selectPopupContainer';
import { ALL_SELECT_VALUE } from '../utils';
import { SelectItemStyle } from './styles';
import {
areArraysEqual,
getOptionsForDynamicVariable,
uniqueValues,
} from './util';
import { getSelectValue } from './VariableItem';
import './DashboardVariableSelection.styles.scss';
interface DynamicVariableSelectionProps {
variableData: IDashboardVariable;
existingVariables: Record<string, IDashboardVariable>;
onValueUpdate: (
name: string,
id: string,
arg1: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
}
function DynamicVariableSelection({
variableData,
onValueUpdate,
existingVariables,
}: DynamicVariableSelectionProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const [isRetryableError, setIsRetryableError] = useState<boolean>(true);
const [isComplete, setIsComplete] = useState<boolean>(false);
const [filteredOptionsData, setFilteredOptionsData] = useState<
(string | number | boolean)[]
>([]);
const [relatedValues, setRelatedValues] = useState<string[]>([]);
const [originalRelatedValues, setOriginalRelatedValues] = useState<string[]>(
[],
);
const [tempSelection, setTempSelection] = useState<
string | string[] | undefined
>(undefined);
// Track dropdown open state for auto-checking new values
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
// Create a dependency key from all dynamic variables
const dynamicVariablesKey = useMemo(() => {
if (!existingVariables) {
return 'no_variables';
}
const dynamicVars = Object.values(existingVariables)
.filter((v) => v.type === 'DYNAMIC')
.map(
(v) => `${v.name || 'unnamed'}:${JSON.stringify(v.selectedValue || null)}`,
)
.join('|');
return dynamicVars || 'no_dynamic_variables';
}, [existingVariables]);
const [apiSearchText, setApiSearchText] = useState<string>('');
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// existing query is the query made from the other dynamic variables around this one with there current values
// for e.g. k8s.namespace.name IN ["zeus", "gene"] AND doc_op_type IN ["test"]
const existingQuery = useMemo(() => {
if (!existingVariables || !variableData.dynamicVariablesAttribute) {
return '';
}
const queryParts: string[] = [];
Object.entries(existingVariables).forEach(([, variable]) => {
// Skip the current variable being processed
if (variable.id === variableData.id) {
return;
}
// Only include dynamic variables that have selected values and are not selected as ALL
if (
variable.type === 'DYNAMIC' &&
variable.dynamicVariablesAttribute &&
variable.selectedValue &&
!isEmpty(variable.selectedValue) &&
(variable.showALLOption ? !variable.allSelected : true)
) {
const attribute = variable.dynamicVariablesAttribute;
const values = Array.isArray(variable.selectedValue)
? variable.selectedValue
: [variable.selectedValue];
// Filter out empty values and convert to strings
const validValues = values
.filter((value) => value !== null && value !== undefined && value !== '')
.map((value) => value.toString());
if (validValues.length > 0) {
// Format values for query - wrap strings in quotes, keep numbers as is
const formattedValues = validValues.map((value) => {
// Check if value is a number
const numValue = Number(value);
if (!Number.isNaN(numValue) && Number.isFinite(numValue)) {
return value; // Keep as number
}
// Escape single quotes and wrap in quotes
return `'${value.replace(/'/g, "\\'")}'`;
});
if (formattedValues.length === 1) {
queryParts.push(`${attribute} = ${formattedValues[0]}`);
} else {
queryParts.push(`${attribute} IN [${formattedValues.join(', ')}]`);
}
}
}
});
return queryParts.join(' AND ');
}, [
existingVariables,
variableData.id,
variableData.dynamicVariablesAttribute,
]);
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || `variable_${variableData.id}`,
dynamicVariablesKey,
minTime,
maxTime,
debouncedApiSearchText,
],
{
enabled: variableData.type === 'DYNAMIC',
queryFn: () =>
getFieldValues(
variableData.dynamicVariablesSource?.toLowerCase() === 'all telemetry'
? undefined
: (variableData.dynamicVariablesSource?.toLowerCase() as
| 'traces'
| 'logs'
| 'metrics'),
variableData.dynamicVariablesAttribute,
debouncedApiSearchText,
minTime,
maxTime,
existingQuery,
),
onSuccess: (data) => {
const newNormalizedValues = data.data?.normalizedValues || [];
const newRelatedValues = data.data?.relatedValues || [];
if (!debouncedApiSearchText) {
setOptionsData(newNormalizedValues);
setIsComplete(data.data?.complete || false);
}
setFilteredOptionsData(newNormalizedValues);
setRelatedValues(newRelatedValues);
setOriginalRelatedValues(newRelatedValues);
// Only run auto-check logic when necessary to avoid performance issues
if (variableData.allSelected && isDropdownOpen) {
// Build the latest full list from API (normalized + related)
const latestValues = [
...new Set([
...newNormalizedValues.map((v) => v.toString()),
...newRelatedValues.map((v) => v.toString()),
]),
];
// Update temp selection to exactly reflect latest API values when ALL is active
const currentStrings = Array.isArray(tempSelection)
? tempSelection.map((v) => v.toString())
: tempSelection
? [tempSelection.toString()]
: [];
const areSame =
currentStrings.length === latestValues.length &&
latestValues.every((v) => currentStrings.includes(v));
if (!areSame) {
setTempSelection(latestValues);
}
}
},
onError: (error: any) => {
if (error) {
let message = SOMETHING_WENT_WRONG;
if (error?.message) {
message = error?.message;
} else {
message =
'Please make sure configuration is valid and you have required setup and permissions';
}
setErrorMessage(message);
// Check if error is retryable (5xx) or not (4xx)
const isRetryable = checkIfRetryableError(error);
setIsRetryableError(isRetryable);
}
},
},
);
const handleChange = useCallback(
(inputValue: string | string[]): void => {
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
if (
value === variableData.selectedValue ||
(Array.isArray(value) &&
Array.isArray(variableData.selectedValue) &&
areArraysEqual(value, variableData.selectedValue))
) {
return;
}
if (variableData.name) {
if (
value === ALL_SELECT_VALUE ||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
) {
// For ALL selection in dynamic variables, pass null to avoid storing values
// The parent component will handle this appropriately
onValueUpdate(variableData.name, variableData.id, null, true);
} else {
// Build union of available options shown in dropdown (normalized + related)
const allAvailableOptionStrings = [
...new Set([
...optionsData.map((v) => v.toString()),
...relatedValues.map((v) => v.toString()),
]),
];
const haveCustomValuesSelected =
Array.isArray(value) &&
!value.every((v) => allAvailableOptionStrings.includes(v.toString()));
onValueUpdate(
variableData.name,
variableData.id,
value,
allAvailableOptionStrings.every((v) => value.includes(v.toString())),
haveCustomValuesSelected,
);
}
}
},
[variableData, onValueUpdate, optionsData, relatedValues],
);
useEffect(() => {
if (
variableData.dynamicVariablesSource &&
variableData.dynamicVariablesAttribute
) {
refetch();
}
}, [
refetch,
variableData.dynamicVariablesSource,
variableData.dynamicVariablesAttribute,
debouncedApiSearchText,
]);
// Build a memoized list of all currently available option strings (normalized + related)
const allAvailableOptionStrings = useMemo(
() => [
...new Set([
...optionsData.map((v) => v.toString()),
...relatedValues.map((v) => v.toString()),
]),
],
[optionsData, relatedValues],
);
const handleSearch = useCallback(
(text: string) => {
if (isComplete) {
if (!text) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
return;
}
const localFilteredOptionsData: (string | number | boolean)[] = [];
optionsData.forEach((option) => {
if (option.toString().toLowerCase().includes(text.toLowerCase())) {
localFilteredOptionsData.push(option);
}
});
setFilteredOptionsData(localFilteredOptionsData);
setRelatedValues(
originalRelatedValues.filter((value) =>
value.toLowerCase().includes(text.toLowerCase()),
),
);
} else {
setApiSearchText(text);
}
},
[isComplete, optionsData, originalRelatedValues],
);
const { selectedValue } = variableData;
const selectedValueStringified = useMemo(
() => getSelectValue(selectedValue, variableData),
[selectedValue, variableData],
);
const enableSelectAll = variableData.multiSelect && variableData.showALLOption;
const selectValue =
variableData.allSelected && enableSelectAll
? ALL_SELECT_VALUE
: selectedValueStringified;
// Add a handler for tracking temporary selection changes
const handleTempChange = useCallback(
(inputValue: string | string[]): void => {
// Store the selection in temporary state while dropdown is open
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
const sanitizedValue = uniqueValues(value);
setTempSelection(sanitizedValue);
},
[variableData.multiSelect],
);
// Handle dropdown visibility changes
const handleDropdownVisibleChange = (visible: boolean): void => {
// Update dropdown open state for auto-checking
setIsDropdownOpen(visible);
// Initialize temp selection when opening dropdown
if (visible) {
if (isUndefined(tempSelection) && selectValue === ALL_SELECT_VALUE) {
// When ALL is selected, set selection to exactly the latest available values
const latestAll = [...allAvailableOptionStrings];
setTempSelection(latestAll);
} else {
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
}
}
// Apply changes when closing dropdown
else if (!visible && tempSelection !== undefined) {
// Only call handleChange if there's actually a change in the selection
const currentValue = variableData.selectedValue;
// Helper function to check if arrays have the same elements regardless of order
const areArraysEqualIgnoreOrder = (a: any[], b: any[]): boolean => {
if (a.length !== b.length) {
return false;
}
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return areArraysEqual(sortedA, sortedB);
};
// If ALL was selected before and remains ALL after, skip updating
const wasAllSelected = enableSelectAll && variableData.allSelected;
const isAllSelectedAfter =
enableSelectAll &&
Array.isArray(tempSelection) &&
tempSelection.length === allAvailableOptionStrings.length &&
allAvailableOptionStrings.every((v) => tempSelection.includes(v));
if (wasAllSelected && isAllSelectedAfter) {
setTempSelection(undefined);
return;
}
const hasChanged =
tempSelection !== currentValue &&
!(
Array.isArray(tempSelection) &&
Array.isArray(currentValue) &&
areArraysEqualIgnoreOrder(tempSelection, currentValue)
);
if (hasChanged) {
handleChange(tempSelection);
}
setTempSelection(undefined);
}
// Always reset filtered data when dropdown closes, regardless of tempSelection state
if (!visible) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
setApiSearchText('');
}
};
useEffect(
() => (): void => {
// Cleanup on unmount
setTempSelection(undefined);
setFilteredOptionsData([]);
setRelatedValues([]);
setApiSearchText('');
},
[],
);
// eslint-disable-next-line sonarjs/cognitive-complexity
const finalSelectedValues = useMemo(() => {
if (variableData.multiSelect) {
let value = tempSelection || selectedValue;
if (isEmpty(value)) {
if (variableData.showALLOption) {
if (variableData.defaultValue) {
value = variableData.defaultValue;
} else if (variableData.allSelected) {
// If ALL is selected but no stored values, derive from available options
// This handles the case where we don't store values in localStorage for ALL
value = allAvailableOptionStrings;
} else {
value = optionsData;
}
} else if (variableData.defaultValue) {
value = variableData.defaultValue;
} else {
value = optionsData?.[0];
}
}
return value;
}
if (isEmpty(selectedValue)) {
if (variableData.defaultValue) {
return variableData.defaultValue;
}
return optionsData[0]?.toString();
}
return selectedValue;
}, [
variableData.multiSelect,
variableData.showALLOption,
variableData.defaultValue,
variableData.allSelected,
selectedValue,
tempSelection,
optionsData,
allAvailableOptionStrings,
]);
useEffect(() => {
if (
(variableData.multiSelect && !(tempSelection || selectValue)) ||
isEmpty(selectValue)
) {
handleChange(finalSelectedValues as string[] | string);
}
}, [
finalSelectedValues,
handleChange,
selectValue,
tempSelection,
variableData.multiSelect,
]);
return (
<div className="variable-item">
<Typography.Text className="variable-name" ellipsis>
${variableData.name}
{variableData.description && (
<Tooltip title={variableData.description}>
<InfoCircleOutlined className="info-icon" />
</Tooltip>
)}
</Typography.Text>
<div className="variable-value">
{variableData.multiSelect ? (
<CustomMultiSelect
key={variableData.id}
options={getOptionsForDynamicVariable(
filteredOptionsData || [],
relatedValues || [],
)}
defaultValue={variableData.defaultValue}
onChange={handleTempChange}
bordered={false}
placeholder="Select value"
placement="bottomLeft"
style={SelectItemStyle}
loading={isLoading}
showSearch
data-testid="variable-select"
className="variable-select"
popupClassName="dropdown-styles"
maxTagCount={2}
getPopupContainer={popupContainer}
value={
(tempSelection || selectValue) === ALL_SELECT_VALUE
? 'ALL'
: tempSelection || selectValue
}
onDropdownVisibleChange={handleDropdownVisibleChange}
errorMessage={errorMessage}
// eslint-disable-next-line react/no-unstable-nested-components
maxTagPlaceholder={(omittedValues): JSX.Element => {
const maxDisplayValues = 10;
const valuesToShow = omittedValues.slice(0, maxDisplayValues);
const hasMore = omittedValues.length > maxDisplayValues;
const tooltipText =
valuesToShow.map(({ value }) => value).join(', ') +
(hasMore ? ` + ${omittedValues.length - maxDisplayValues} more` : '');
return (
<Tooltip title={tooltipText}>
<span>+ {omittedValues.length} </span>
</Tooltip>
);
}}
onClear={(): void => {
handleChange([]);
}}
enableAllSelection={enableSelectAll}
maxTagTextLength={30}
onSearch={handleSearch}
onRetry={(): void => {
setErrorMessage(null);
setIsRetryableError(true);
refetch();
}}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
isDynamicVariable
showRetryButton={isRetryableError}
/>
) : (
<CustomSelect
key={variableData.id}
onChange={handleChange}
bordered={false}
placeholder="Select value"
style={SelectItemStyle}
loading={isLoading}
showSearch
data-testid="variable-select"
className="variable-select"
popupClassName="dropdown-styles"
getPopupContainer={popupContainer}
options={getOptionsForDynamicVariable(
filteredOptionsData || [],
relatedValues || [],
)}
value={selectValue}
defaultValue={variableData.defaultValue}
errorMessage={errorMessage}
onSearch={handleSearch}
// eslint-disable-next-line sonarjs/no-identical-functions
onRetry={(): void => {
setErrorMessage(null);
setIsRetryableError(true);
refetch();
}}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
isDynamicVariable
showRetryButton={isRetryableError}
/>
)}
</div>
</div>
);
}
export default DynamicVariableSelection;

View File

@@ -1,249 +0,0 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import { isArray, isString } from 'lodash-es';
import { AppState } from 'store/reducers';
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { GlobalReducer } from 'types/reducer/globalTime';
import { variablePropsToPayloadVariables } from '../utils';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import { areArraysEqual, checkAPIInvocation } from './util';
import { VariableItemProps } from './VariableItem';
import { queryVariableSelectStrategy } from './variableSelectStrategy/queryVariableSelectStrategy';
type QueryVariableInputProps = Pick<
VariableItemProps,
| 'variableData'
| 'existingVariables'
| 'onValueUpdate'
| 'variablesToGetUpdated'
| 'setVariablesToGetUpdated'
| 'dependencyData'
>;
function QueryVariableInput({
variableData,
existingVariables,
variablesToGetUpdated,
setVariablesToGetUpdated,
dependencyData,
onValueUpdate,
}: QueryVariableInputProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const {
tempSelection,
setTempSelection,
value,
defaultValue,
enableSelectAll,
onChange,
onDropdownVisibleChange,
handleClear,
applyDefaultIfNeeded,
} = useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy: queryVariableSelectStrategy,
});
const validVariableUpdate = useCallback((): boolean => {
if (!variableData.name) {
return false;
}
return Boolean(
variablesToGetUpdated.length &&
variablesToGetUpdated[0] === variableData.name,
);
}, [variableData.name, variablesToGetUpdated]);
const getOptions = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
(variablesRes: VariableResponseProps | null): void => {
try {
setErrorMessage(null);
if (
variablesRes?.variableValues &&
Array.isArray(variablesRes?.variableValues)
) {
const newOptionsData = sortValues(
variablesRes?.variableValues,
variableData.sort,
);
const oldOptionsData = sortValues(optionsData, variableData.sort) as never;
if (!areArraysEqual(newOptionsData, oldOptionsData)) {
let valueNotInList = false;
if (isArray(variableData.selectedValue)) {
variableData.selectedValue.forEach((val) => {
if (!newOptionsData.includes(val)) {
valueNotInList = true;
}
});
} else if (
isString(variableData.selectedValue) &&
!newOptionsData.includes(variableData.selectedValue)
) {
valueNotInList = true;
}
// variablesData.allSelected is added for the case where on change of options we need to update the
// local storage
if (
variableData.name &&
(validVariableUpdate() || valueNotInList || variableData.allSelected)
) {
if (
variableData.allSelected &&
variableData.multiSelect &&
variableData.showALLOption
) {
onValueUpdate(variableData.name, variableData.id, newOptionsData, true);
// Update tempSelection to maintain ALL state when dropdown is open
if (tempSelection !== undefined) {
setTempSelection(newOptionsData.map((option) => option.toString()));
}
} else {
const value = variableData.selectedValue;
let allSelected = false;
if (variableData.multiSelect) {
const { selectedValue } = variableData;
allSelected =
newOptionsData.length > 0 &&
Array.isArray(selectedValue) &&
newOptionsData.every((option) => selectedValue.includes(option));
}
if (variableData.name && variableData.id) {
onValueUpdate(variableData.name, variableData.id, value, allSelected);
}
}
}
setOptionsData(newOptionsData);
// Apply default if no value is selected (e.g., new variable, first load)
applyDefaultIfNeeded(newOptionsData);
} else {
setVariablesToGetUpdated((prev) =>
prev.filter((name) => name !== variableData.name),
);
}
}
} catch (e) {
console.error(e);
}
},
[
variableData,
optionsData,
onValueUpdate,
tempSelection,
setTempSelection,
validVariableUpdate,
setVariablesToGetUpdated,
applyDefaultIfNeeded,
],
);
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || '',
`${minTime}`,
`${maxTime}`,
JSON.stringify(dependencyData?.order),
],
{
enabled:
variableData &&
checkAPIInvocation(
variablesToGetUpdated,
variableData,
dependencyData?.parentDependencyGraph,
),
queryFn: () =>
dashboardVariablesQuery({
query: variableData.queryValue || '',
variables: variablePropsToPayloadVariables(existingVariables),
}),
refetchOnWindowFocus: false,
onSuccess: (response) => {
getOptions(response.payload);
setVariablesToGetUpdated((prev) =>
prev.filter((v) => v !== variableData.name),
);
},
onError: (error: {
details: {
error: string;
};
}) => {
const { details } = error;
if (details.error) {
let message = details.error;
if ((details.error ?? '').toString().includes('Syntax error:')) {
message =
'Please make sure query is valid and dependent variables are selected';
}
setErrorMessage(message);
}
setVariablesToGetUpdated((prev) =>
prev.filter((v) => v !== variableData.name),
);
},
},
);
const handleRetry = useCallback((): void => {
setErrorMessage(null);
refetch();
}, [refetch]);
const selectOptions = useMemo(
() =>
optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
})),
[optionsData],
);
return (
<SelectVariableInput
variableId={variableData.id}
options={selectOptions}
value={value}
onChange={onChange}
onDropdownVisibleChange={onDropdownVisibleChange}
onClear={handleClear}
enableSelectAll={enableSelectAll}
defaultValue={defaultValue}
isMultiSelect={variableData.multiSelect}
// query variable specific, API related props
loading={isLoading}
errorMessage={errorMessage}
onRetry={handleRetry}
/>
);
}
export default memo(QueryVariableInput);

View File

@@ -1,144 +0,0 @@
import { memo, useMemo } from 'react';
import { orange } from '@ant-design/colors';
import { WarningOutlined } from '@ant-design/icons';
import { Popover, Tooltip, Typography } from 'antd';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import { OptionData } from 'components/NewSelect/types';
import { popupContainer } from 'utils/selectPopupContainer';
import { ALL_SELECT_VALUE } from '../utils';
import { SelectItemStyle } from './styles';
const errorIconStyle = { margin: '0 0.5rem' };
interface SelectVariableInputProps {
variableId: string;
options: OptionData[];
value: string | string[] | undefined;
enableSelectAll: boolean;
isMultiSelect: boolean;
onChange: (value: string | string[]) => void;
onClear: () => void;
defaultValue?: string | string[];
onDropdownVisibleChange?: (visible: boolean) => void;
loading?: boolean;
errorMessage?: string | null;
onRetry?: () => void;
isDynamicVariable?: boolean;
showRetryButton?: boolean;
showIncompleteDataMessage?: boolean;
onSearch?: (searchTerm: string) => void;
}
const MAX_TAG_DISPLAY_VALUES = 10;
export const renderMaxTagPlaceholder = (
omittedValues: { label?: React.ReactNode; value?: string | number }[],
): JSX.Element => {
const valuesToShow = omittedValues.slice(0, MAX_TAG_DISPLAY_VALUES);
const hasMore = omittedValues.length > MAX_TAG_DISPLAY_VALUES;
const tooltipText =
valuesToShow.map(({ value: v }) => v ?? '').join(', ') +
(hasMore ? ` + ${omittedValues.length - MAX_TAG_DISPLAY_VALUES} more` : '');
return (
<Tooltip title={tooltipText}>
<span>+ {omittedValues.length} </span>
</Tooltip>
);
};
function SelectVariableInput({
variableId,
options,
value,
onChange,
onDropdownVisibleChange,
onClear,
loading,
errorMessage,
onRetry,
enableSelectAll,
isMultiSelect,
defaultValue,
isDynamicVariable,
showRetryButton,
showIncompleteDataMessage,
onSearch,
}: SelectVariableInputProps): JSX.Element {
const commonProps = useMemo(
() => ({
// main props
key: variableId,
value,
defaultValue,
// setup props
placeholder: 'Select value',
className: 'variable-select',
popupClassName: 'dropdown-styles',
getPopupContainer: popupContainer,
style: SelectItemStyle,
showSearch: true,
bordered: false,
// changing props
'data-testid': 'variable-select',
onChange,
loading,
options,
errorMessage,
onRetry,
// dynamic variable only props
isDynamicVariable,
showRetryButton,
showIncompleteDataMessage,
onSearch,
}),
[
variableId,
defaultValue,
onChange,
loading,
options,
value,
errorMessage,
onRetry,
isDynamicVariable,
showRetryButton,
showIncompleteDataMessage,
onSearch,
],
);
return (
<>
{isMultiSelect ? (
<CustomMultiSelect
{...commonProps}
placement="bottomLeft"
maxTagCount={2}
onDropdownVisibleChange={onDropdownVisibleChange}
maxTagPlaceholder={renderMaxTagPlaceholder}
onClear={onClear}
enableAllSelection={enableSelectAll}
maxTagTextLength={30}
allowClear={value !== ALL_SELECT_VALUE}
/>
) : (
<CustomSelect {...commonProps} />
)}
{errorMessage && (
<span style={errorIconStyle}>
<Popover placement="top" content={<Typography>{errorMessage}</Typography>}>
<WarningOutlined style={{ color: orange[5] }} />
</Popover>
</span>
)}
</>
);
}
export default memo(SelectVariableInput);

View File

@@ -1,85 +0,0 @@
import { memo, useCallback, useRef, useState } from 'react';
import { Input, InputRef } from 'antd';
import { VariableItemProps } from './VariableItem';
type TextboxVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'onValueUpdate'
>;
function TextboxVariableInput({
variableData,
onValueUpdate,
}: TextboxVariableInputProps): JSX.Element {
const handleChange = useCallback(
(inputValue: string | string[]): void => {
if (inputValue === variableData.selectedValue) {
return;
}
if (variableData.name) {
onValueUpdate(variableData.name, variableData.id, inputValue, false);
}
},
[
onValueUpdate,
variableData.id,
variableData.name,
variableData.selectedValue,
],
);
const textboxInputRef = useRef<InputRef>(null);
const [textboxInputValue, setTextboxInputValue] = useState<string>(
(variableData.selectedValue?.toString() ||
variableData.defaultValue?.toString()) ??
'',
);
const handleInputOnChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setTextboxInputValue(event.target.value);
},
[setTextboxInputValue],
);
const handleInputOnBlur = useCallback(
(event: React.FocusEvent<HTMLInputElement>): void => {
const value = event.target.value.trim();
// If empty, reset to default value
if (!value && variableData.defaultValue) {
setTextboxInputValue(variableData.defaultValue.toString());
handleChange(variableData.defaultValue.toString());
} else {
handleChange(value);
}
},
[handleChange, variableData.defaultValue],
);
const handleInputOnKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
textboxInputRef.current?.blur();
}
},
[],
);
return (
<Input
key={variableData.id}
ref={textboxInputRef}
placeholder="Enter value"
data-testid={`variable-textbox-${variableData.id}`}
bordered={false}
value={textboxInputValue}
title={textboxInputValue}
onChange={handleInputOnChange}
onBlur={handleInputOnBlur}
onKeyDown={handleInputOnKeyDown}
/>
);
}
export default memo(TextboxVariableInput);

View File

@@ -1,81 +1,530 @@
import { memo } from 'react';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable no-nested-ternary */
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { orange } from '@ant-design/colors';
import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons';
import { Input, InputRef, Popover, Tooltip, Typography } from 'antd';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { debounce, isArray, isEmpty, isString } from 'lodash-es';
import { IDependencyData } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { GlobalReducer } from 'types/reducer/globalTime';
import { popupContainer } from 'utils/selectPopupContainer';
import CustomVariableInput from './CustomVariableInput';
import DynamicVariableInput from './DynamicVariableInput';
import QueryVariableInput from './QueryVariableInput';
import TextboxVariableInput from './TextboxVariableInput';
import { ALL_SELECT_VALUE, variablePropsToPayloadVariables } from '../utils';
import { SelectItemStyle } from './styles';
import { areArraysEqual, checkAPIInvocation } from './util';
import './DashboardVariableSelection.styles.scss';
export interface VariableItemProps {
interface VariableItemProps {
variableData: IDashboardVariable;
existingVariables: Record<string, IDashboardVariable>;
onValueUpdate: (
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
arg1: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
variablesToGetUpdated: string[];
setVariablesToGetUpdated: React.Dispatch<React.SetStateAction<string[]>>;
dependencyData: IDependencyData | null;
}
export const getSelectValue = (
selectedValue: IDashboardVariable['selectedValue'],
variableData: IDashboardVariable,
): string | string[] | undefined => {
if (Array.isArray(selectedValue)) {
if (!variableData.multiSelect && selectedValue.length === 1) {
return selectedValue[0]?.toString();
}
return selectedValue.map((item) => item.toString());
}
return selectedValue?.toString();
};
// eslint-disable-next-line sonarjs/cognitive-complexity
function VariableItem({
variableData,
onValueUpdate,
existingVariables,
onValueUpdate,
variablesToGetUpdated,
setVariablesToGetUpdated,
dependencyData,
}: VariableItemProps): JSX.Element {
const { name, description, type: variableType } = variableData;
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [tempSelection, setTempSelection] = useState<
string | string[] | undefined
>(undefined);
// Local state for textbox input to ensure smooth editing experience
const [textboxInputValue, setTextboxInputValue] = useState<string>(
(variableData.selectedValue?.toString() ||
variableData.defaultValue?.toString()) ??
'',
);
const [isTextboxFocused, setIsTextboxFocused] = useState<boolean>(false);
const textboxInputRef = useRef<InputRef>(null);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const validVariableUpdate = (): boolean => {
if (!variableData.name) {
return false;
}
// variableData.name is present as the top element or next in the queue - variablesToGetUpdated
return Boolean(
variablesToGetUpdated.length &&
variablesToGetUpdated[0] === variableData.name,
);
};
const [errorMessage, setErrorMessage] = useState<null | string>(null);
// eslint-disable-next-line sonarjs/cognitive-complexity
const getOptions = (variablesRes: VariableResponseProps | null): void => {
if (variablesRes && variableData.type === 'QUERY') {
try {
setErrorMessage(null);
if (
variablesRes?.variableValues &&
Array.isArray(variablesRes?.variableValues)
) {
const newOptionsData = sortValues(
variablesRes?.variableValues,
variableData.sort,
);
const oldOptionsData = sortValues(optionsData, variableData.sort) as never;
if (!areArraysEqual(newOptionsData, oldOptionsData)) {
/* eslint-disable no-useless-escape */
let valueNotInList = false;
if (isArray(variableData.selectedValue)) {
variableData.selectedValue.forEach((val) => {
const isUsed = newOptionsData.includes(val);
if (!isUsed) {
valueNotInList = true;
}
});
} else if (isString(variableData.selectedValue)) {
const isUsed = newOptionsData.includes(variableData.selectedValue);
if (!isUsed) {
valueNotInList = true;
}
}
// variablesData.allSelected is added for the case where on change of options we need to update the
// local storage
if (
variableData.type === 'QUERY' &&
variableData.name &&
(validVariableUpdate() || valueNotInList || variableData.allSelected)
) {
if (
variableData.allSelected &&
variableData.multiSelect &&
variableData.showALLOption
) {
onValueUpdate(variableData.name, variableData.id, newOptionsData, true);
// Update tempSelection to maintain ALL state when dropdown is open
if (tempSelection !== undefined) {
setTempSelection(newOptionsData.map((option) => option.toString()));
}
} else {
const value = variableData.selectedValue;
let allSelected = false;
if (variableData.multiSelect) {
const { selectedValue } = variableData;
allSelected =
newOptionsData.length > 0 &&
Array.isArray(selectedValue) &&
newOptionsData.every((option) => selectedValue.includes(option));
}
if (variableData && variableData?.name && variableData?.id) {
onValueUpdate(variableData.name, variableData.id, value, allSelected);
}
}
}
setOptionsData(newOptionsData);
} else {
setVariablesToGetUpdated((prev) =>
prev.filter((name) => name !== variableData.name),
);
}
}
} catch (e) {
console.error(e);
}
} else if (variableData.type === 'CUSTOM') {
const optionsData = sortValues(
commaValuesParser(variableData.customValue || ''),
variableData.sort,
) as never;
setOptionsData(optionsData);
}
};
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || '',
`${minTime}`,
`${maxTime}`,
JSON.stringify(dependencyData?.order),
],
{
enabled:
variableData &&
variableData.type === 'QUERY' &&
checkAPIInvocation(
variablesToGetUpdated,
variableData,
dependencyData?.parentDependencyGraph,
),
queryFn: () =>
dashboardVariablesQuery({
query: variableData.queryValue || '',
variables: variablePropsToPayloadVariables(existingVariables),
}),
refetchOnWindowFocus: false,
onSuccess: (response) => {
getOptions(response.payload);
setVariablesToGetUpdated((prev) =>
prev.filter((v) => v !== variableData.name),
);
},
onError: (error: {
details: {
error: string;
};
}) => {
const { details } = error;
if (details.error) {
let message = details.error;
if ((details.error ?? '').toString().includes('Syntax error:')) {
message =
'Please make sure query is valid and dependent variables are selected';
}
setErrorMessage(message);
}
setVariablesToGetUpdated((prev) =>
prev.filter((v) => v !== variableData.name),
);
},
},
);
const handleChange = useCallback(
(inputValue: string | string[]): void => {
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
if (
value === variableData.selectedValue ||
(Array.isArray(value) &&
Array.isArray(variableData.selectedValue) &&
areArraysEqual(value, variableData.selectedValue))
) {
return;
}
if (variableData.name) {
// Check if ALL is effectively selected by comparing with available options
const isAllSelected =
Array.isArray(value) &&
value.length > 0 &&
optionsData.every((option) => value.includes(option.toString()));
if (isAllSelected && variableData.showALLOption) {
// For ALL selection, pass null to avoid storing values
onValueUpdate(variableData.name, variableData.id, optionsData, true);
} else {
onValueUpdate(variableData.name, variableData.id, value, false);
}
}
},
[
variableData.multiSelect,
variableData.selectedValue,
variableData.name,
variableData.id,
onValueUpdate,
optionsData,
variableData.showALLOption,
],
);
// Add a handler for tracking temporary selection changes
const handleTempChange = (inputValue: string | string[]): void => {
// Store the selection in temporary state while dropdown is open
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
setTempSelection(value);
};
// Handle dropdown visibility changes
const handleDropdownVisibleChange = (visible: boolean): void => {
// Initialize temp selection when opening dropdown
if (visible) {
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
}
// Apply changes when closing dropdown
else if (!visible && tempSelection !== undefined) {
// Call handleChange with the temporarily stored selection
handleChange(tempSelection);
setTempSelection(undefined);
}
};
// do not debounce the above function as we do not need debounce in select variables
const debouncedHandleChange = debounce(handleChange, 500);
const { selectedValue } = variableData;
const selectedValueStringified = useMemo(
() => getSelectValue(selectedValue, variableData),
[selectedValue, variableData],
);
const enableSelectAll = variableData.multiSelect && variableData.showALLOption;
const selectValue =
variableData.allSelected && enableSelectAll
? 'ALL'
: selectedValueStringified;
// Apply default value on first render if no selection exists
// eslint-disable-next-line sonarjs/cognitive-complexity
const finalSelectedValues = useMemo(() => {
if (variableData.multiSelect) {
let value = tempSelection || selectedValue;
if (isEmpty(value)) {
if (variableData.showALLOption) {
if (variableData.defaultValue) {
value = variableData.defaultValue;
} else {
value = optionsData;
}
} else if (variableData.defaultValue) {
value = variableData.defaultValue;
} else {
value = optionsData?.[0];
}
}
return value;
}
if (isEmpty(selectedValue)) {
if (variableData.defaultValue) {
return variableData.defaultValue;
}
return optionsData[0]?.toString();
}
return selectedValue;
}, [
variableData.multiSelect,
variableData.showALLOption,
variableData.defaultValue,
selectedValue,
tempSelection,
optionsData,
]);
useEffect(() => {
if (
(variableData.multiSelect && !(tempSelection || selectValue)) ||
isEmpty(selectValue)
) {
handleChange(finalSelectedValues as string[] | string);
}
}, [
finalSelectedValues,
handleChange,
selectValue,
tempSelection,
variableData.multiSelect,
]);
useEffect(() => {
// Fetch options for CUSTOM Type
if (variableData.type === 'CUSTOM') {
getOptions(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variableData.type, variableData.customValue]);
return (
<div className="variable-item">
<div className={`variable-item${isTextboxFocused ? ' focused' : ''}`}>
<Typography.Text className="variable-name" ellipsis>
${name}
{description && (
<Tooltip title={description}>
${variableData.name}
{variableData.description && (
<Tooltip title={variableData.description}>
<InfoCircleOutlined className="info-icon" />
</Tooltip>
)}
</Typography.Text>
<div className="variable-value">
{variableType === 'TEXTBOX' && (
<TextboxVariableInput
variableData={variableData}
onValueUpdate={onValueUpdate}
{variableData.type === 'TEXTBOX' ? (
<Input
ref={textboxInputRef}
placeholder="Enter value"
data-testid={`variable-textbox-${variableData.id}`}
bordered={false}
value={textboxInputValue}
title={textboxInputValue}
onChange={(e): void => {
setTextboxInputValue(e.target.value);
}}
onFocus={(): void => {
setIsTextboxFocused(true);
}}
onBlur={(e): void => {
setIsTextboxFocused(false);
const value = e.target.value.trim();
// If empty, reset to default value
if (!value && variableData.defaultValue) {
setTextboxInputValue(variableData.defaultValue.toString());
debouncedHandleChange(variableData.defaultValue.toString());
} else {
debouncedHandleChange(value);
}
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
const value = textboxInputValue.trim();
if (!value && variableData.defaultValue) {
setTextboxInputValue(variableData.defaultValue.toString());
debouncedHandleChange(variableData.defaultValue.toString());
} else {
debouncedHandleChange(value);
}
textboxInputRef.current?.blur();
}
}}
/>
) : (
optionsData &&
(variableData.multiSelect ? (
<CustomMultiSelect
key={
selectValue && Array.isArray(selectValue)
? selectValue.join(' ')
: selectValue || variableData.id
}
options={optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
}))}
defaultValue={variableData.defaultValue || selectValue}
onChange={handleTempChange}
bordered={false}
placeholder="Select value"
placement="bottomLeft"
style={SelectItemStyle}
loading={isLoading}
showSearch
data-testid="variable-select"
className="variable-select"
popupClassName="dropdown-styles"
maxTagCount={2}
getPopupContainer={popupContainer}
value={tempSelection || selectValue}
onDropdownVisibleChange={handleDropdownVisibleChange}
errorMessage={errorMessage}
// eslint-disable-next-line react/no-unstable-nested-components
maxTagPlaceholder={(omittedValues): JSX.Element => {
const maxDisplayValues = 10;
const valuesToShow = omittedValues.slice(0, maxDisplayValues);
const hasMore = omittedValues.length > maxDisplayValues;
const tooltipText =
valuesToShow.map(({ value }) => value).join(', ') +
(hasMore ? ` + ${omittedValues.length - maxDisplayValues} more` : '');
return (
<Tooltip title={tooltipText}>
<span>+ {omittedValues.length} </span>
</Tooltip>
);
}}
onClear={(): void => {
handleChange([]);
}}
enableAllSelection={enableSelectAll}
maxTagTextLength={30}
allowClear={selectValue !== ALL_SELECT_VALUE && selectValue !== 'ALL'}
onRetry={(): void => {
setErrorMessage(null);
refetch();
}}
/>
) : (
<CustomSelect
key={
selectValue && Array.isArray(selectValue)
? selectValue.join(' ')
: selectValue || variableData.id
}
defaultValue={variableData.defaultValue || selectValue}
onChange={handleChange}
bordered={false}
placeholder="Select value"
style={SelectItemStyle}
loading={isLoading}
showSearch
data-testid="variable-select"
className="variable-select"
popupClassName="dropdown-styles"
getPopupContainer={popupContainer}
options={optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
}))}
value={selectValue}
errorMessage={errorMessage}
onRetry={(): void => {
setErrorMessage(null);
refetch();
}}
/>
))
)}
{variableType === 'CUSTOM' && (
<CustomVariableInput
variableData={variableData}
onValueUpdate={onValueUpdate}
/>
)}
{variableType === 'QUERY' && (
<QueryVariableInput
variableData={variableData}
onValueUpdate={onValueUpdate}
existingVariables={existingVariables}
variablesToGetUpdated={variablesToGetUpdated}
setVariablesToGetUpdated={setVariablesToGetUpdated}
dependencyData={dependencyData}
/>
)}
{variableType === 'DYNAMIC' && (
<DynamicVariableInput
variableData={variableData}
onValueUpdate={onValueUpdate}
existingVariables={existingVariables}
/>
{variableData.type !== 'TEXTBOX' && errorMessage && (
<span style={{ margin: '0 0.5rem' }}>
<Popover
placement="top"
content={<Typography>{errorMessage}</Typography>}
>
<WarningOutlined style={{ color: orange[5] }} />
</Popover>
</span>
)}
</div>
</div>

View File

@@ -5,7 +5,7 @@ import * as ReactRedux from 'react-redux';
import { fireEvent, render, screen } from '@testing-library/react';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DynamicVariableInput from '../DynamicVariableInput';
import DynamicVariableSelection from '../DynamicVariableSelection';
// Don't mock the components - use real ones
@@ -54,7 +54,7 @@ const mockApiResponse = {
// Mock scrollIntoView since it's not available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
describe('DynamicVariableInput Component', () => {
describe('DynamicVariableSelection Component', () => {
const mockOnValueUpdate = jest.fn();
const mockDynamicVariableData: IDashboardVariable = {
@@ -108,13 +108,18 @@ describe('DynamicVariableInput Component', () => {
it('renders with single select variable correctly', () => {
render(
<DynamicVariableInput
<DynamicVariableSelection
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify component renders correctly
expect(
screen.getByText(`$${mockDynamicVariableData.name}`),
).toBeInTheDocument();
// Verify the selected value is displayed
const selectedItem = screen.getByRole('combobox');
expect(selectedItem).toBeInTheDocument();
@@ -131,13 +136,18 @@ describe('DynamicVariableInput Component', () => {
};
render(
<DynamicVariableInput
<DynamicVariableSelection
variableData={multiSelectWithAllSelected}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify variable name is rendered
expect(
screen.getByText(`$${multiSelectWithAllSelected.name}`),
).toBeInTheDocument();
// In ALL selected mode, there should be an "ALL" text element
expect(screen.getByText('ALL')).toBeInTheDocument();
});
@@ -154,13 +164,18 @@ describe('DynamicVariableInput Component', () => {
});
render(
<DynamicVariableInput
<DynamicVariableSelection
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify component renders in loading state
expect(
screen.getByText(`$${mockDynamicVariableData.name}`),
).toBeInTheDocument();
// Open dropdown to see loading text
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
@@ -184,13 +199,18 @@ describe('DynamicVariableInput Component', () => {
});
render(
<DynamicVariableInput
<DynamicVariableSelection
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify the component renders
expect(
screen.getByText(`$${mockDynamicVariableData.name}`),
).toBeInTheDocument();
// For error states, we should check that error handling is in place
// Without opening the dropdown as the error message might be handled differently
expect(ReactQuery.useQuery).toHaveBeenCalled();
@@ -199,7 +219,7 @@ describe('DynamicVariableInput Component', () => {
it('makes API call to fetch variable values', () => {
render(
<DynamicVariableInput
<DynamicVariableSelection
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
@@ -215,8 +235,6 @@ describe('DynamicVariableInput Component', () => {
'2023-01-01T00:00:00Z', // minTime from useSelector mock
'2023-01-02T00:00:00Z', // maxTime from useSelector mock
'',
'Traces',
'service.name',
],
expect.objectContaining({
enabled: true, // Type is 'DYNAMIC'
@@ -237,13 +255,16 @@ describe('DynamicVariableInput Component', () => {
};
render(
<DynamicVariableInput
<DynamicVariableSelection
variableData={customVariable}
existingVariables={{ ...mockExistingVariables, custom1: customVariable }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify the component correctly displays the selected value
expect(screen.getByText(`$${customVariable.name}`)).toBeInTheDocument();
// Find the selection item in the component using data-testid
const selectElement = screen.getByTestId('variable-select');
expect(selectElement).toBeInTheDocument();

View File

@@ -63,10 +63,10 @@ describe('VariableItem Default Value Selection Behavior', () => {
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
});
expect(await screen.findByText('option1')).toBeInTheDocument();
expect(screen.getByText('option1')).toBeInTheDocument();
});
test('should auto-select first option when no previous and no default', async () => {
test('should show placeholder when no previous and no default', async () => {
const variable: IDashboardVariable = {
id: TEST_VARIABLE_ID,
name: TEST_VARIABLE_NAME,
@@ -85,8 +85,7 @@ describe('VariableItem Default Value Selection Behavior', () => {
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
});
// With the new variable select strategy, the first option is auto-selected
expect(await screen.findByText('option1')).toBeInTheDocument();
expect(screen.getByText('Select value')).toBeInTheDocument();
});
});
@@ -111,7 +110,7 @@ describe('VariableItem Default Value Selection Behavior', () => {
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
});
expect(await screen.findByText('ALL')).toBeInTheDocument();
expect(screen.getByText('ALL')).toBeInTheDocument();
});
});
@@ -135,7 +134,7 @@ describe('VariableItem Default Value Selection Behavior', () => {
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
});
expect(await screen.findByText('Select value')).toBeInTheDocument();
expect(screen.getByText('Select value')).toBeInTheDocument();
});
});
});

View File

@@ -1,252 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { isEmpty } from 'lodash-es';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { ALL_SELECT_VALUE } from '../utils';
import { areArraysEqual, getSelectValue } from './util';
import { VariableSelectStrategy } from './variableSelectStrategy/variableSelectStrategyTypes';
import {
areArraysEqualIgnoreOrder,
uniqueValues,
} from './variableSelectStrategy/variableSelectStrategyUtils';
interface UseDashboardVariableSelectHelperParams {
variableData: IDashboardVariable;
optionsData: (string | number | boolean)[];
onValueUpdate: (
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
strategy: VariableSelectStrategy;
/** Override for all available option strings (default: optionsData.map(String)) */
allAvailableOptionStrings?: string[];
}
interface UseDashboardVariableSelectHelperReturn {
// State
tempSelection: string | string[] | undefined;
setTempSelection: React.Dispatch<
React.SetStateAction<string | string[] | undefined>
>;
value: string | string[] | undefined;
defaultValue: string | string[] | undefined;
// Derived values
enableSelectAll: boolean;
// Handlers
onChange: (value: string | string[]) => void;
onDropdownVisibleChange: (visible: boolean) => void;
handleClear: () => void;
// Default value helpers
applyDefaultIfNeeded: (
overrideOptions?: (string | number | boolean)[],
) => void;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export function useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy,
allAvailableOptionStrings,
}: UseDashboardVariableSelectHelperParams): UseDashboardVariableSelectHelperReturn {
const { selectedValue } = variableData;
const [tempSelection, setTempSelection] = useState<
string | string[] | undefined
>(undefined);
const selectedValueStringified = useMemo(
() => getSelectValue(selectedValue, variableData),
[selectedValue, variableData],
);
const enableSelectAll = variableData.multiSelect && variableData.showALLOption;
const effectiveAllAvailableOptionStrings = useMemo(
() => allAvailableOptionStrings ?? optionsData.map((v) => v.toString()),
[allAvailableOptionStrings, optionsData],
);
const selectValue =
variableData.allSelected && enableSelectAll
? ALL_SELECT_VALUE
: selectedValueStringified;
const getDefaultValue = useCallback(
(overrideOptions?: (string | number | boolean)[]) => {
const options = overrideOptions || optionsData;
if (variableData.multiSelect) {
if (variableData.showALLOption) {
return variableData.defaultValue || options.map((o) => o.toString());
}
return variableData.defaultValue || options?.[0]?.toString();
}
return variableData.defaultValue || options[0]?.toString();
},
[
variableData.multiSelect,
variableData.showALLOption,
variableData.defaultValue,
optionsData,
],
);
const defaultValue = useMemo(() => getDefaultValue(), [getDefaultValue]);
const handleChange = useCallback(
(inputValue: string | string[]): void => {
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
if (
value === variableData.selectedValue ||
(Array.isArray(value) &&
Array.isArray(variableData.selectedValue) &&
areArraysEqual(value, variableData.selectedValue))
) {
return;
}
strategy.handleChange({
value,
variableData,
optionsData,
allAvailableOptionStrings: effectiveAllAvailableOptionStrings,
onValueUpdate,
});
},
[
variableData,
optionsData,
effectiveAllAvailableOptionStrings,
onValueUpdate,
strategy,
],
);
const handleTempChange = useCallback(
(inputValue: string | string[]): void => {
// Store the selection in temporary state while dropdown is open
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
setTempSelection(uniqueValues(value));
},
[variableData.multiSelect],
);
// Single select onChange: apply default if value is empty
const handleSingleSelectChange = useCallback(
(inputValue: string | string[]): void => {
if (isEmpty(inputValue)) {
if (defaultValue !== undefined) {
handleChange(defaultValue as string | string[]);
}
return;
}
handleChange(inputValue);
},
[handleChange, defaultValue],
);
// Handle dropdown visibility changes
const onDropdownVisibleChange = useCallback(
(visible: boolean): void => {
// Initialize temp selection when opening dropdown
if (visible) {
if (variableData.allSelected && enableSelectAll) {
// When ALL is selected, show all available options as individually checked
setTempSelection([...effectiveAllAvailableOptionStrings]);
} else {
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
}
}
// Apply changes when closing dropdown
else if (!visible && tempSelection !== undefined) {
// If ALL was selected before AND all options remain selected, skip updating
const wasAllSelected = enableSelectAll && variableData.allSelected;
const isAllSelectedAfter =
enableSelectAll &&
Array.isArray(tempSelection) &&
tempSelection.length === effectiveAllAvailableOptionStrings.length &&
effectiveAllAvailableOptionStrings.every((v) => tempSelection.includes(v));
if (wasAllSelected && isAllSelectedAfter) {
setTempSelection(undefined);
return;
}
// Apply default if closing with empty selection
let valueToApply = tempSelection;
if (isEmpty(tempSelection) && defaultValue !== undefined) {
valueToApply = defaultValue as string | string[];
}
// Order-agnostic change detection
const currentValue = variableData.selectedValue;
const hasChanged =
valueToApply !== currentValue &&
!(
Array.isArray(valueToApply) &&
Array.isArray(currentValue) &&
areArraysEqualIgnoreOrder(valueToApply, currentValue)
);
if (hasChanged) {
handleChange(valueToApply);
}
setTempSelection(undefined);
}
},
[
variableData,
enableSelectAll,
effectiveAllAvailableOptionStrings,
tempSelection,
handleChange,
defaultValue,
],
);
// Explicit function for callers to apply default on mount / data load
// Pass overrideOptions when freshly-loaded options aren't in state yet (async callers)
const applyDefaultIfNeeded = useCallback(
(overrideOptions?: (string | number | boolean)[]): void => {
if (isEmpty(selectValue)) {
const defaultValueFromOptions = getDefaultValue(overrideOptions);
if (defaultValueFromOptions !== undefined) {
handleChange(defaultValueFromOptions as string | string[]);
}
}
},
[selectValue, handleChange, getDefaultValue],
);
const handleClear = useCallback((): void => {
handleChange([]);
}, [handleChange]);
const value = variableData.multiSelect
? tempSelection || selectValue
: selectValue;
const onChange = useMemo(() => {
return variableData.multiSelect ? handleTempChange : handleSingleSelectChange;
}, [variableData.multiSelect, handleTempChange, handleSingleSelectChange]);
return {
tempSelection,
setTempSelection,
enableSelectAll,
onDropdownVisibleChange,
handleClear,
value,
defaultValue,
onChange,
applyDefaultIfNeeded,
};
}

View File

@@ -363,15 +363,21 @@ export const uniqueOptions = (options: OptionData[]): OptionData[] => {
return uniqueOptions;
};
export const getSelectValue = (
selectedValue: IDashboardVariable['selectedValue'],
variableData: IDashboardVariable,
): string | string[] | undefined => {
if (Array.isArray(selectedValue)) {
if (!variableData.multiSelect && selectedValue.length === 1) {
return selectedValue[0]?.toString();
}
return selectedValue.map((item) => item.toString());
export const uniqueValues = (values: string[] | string): string[] | string => {
if (Array.isArray(values)) {
const uniqueValues: string[] = [];
const seenValues = new Set<string>();
values.forEach((value) => {
if (seenValues.has(value)) {
return;
}
seenValues.add(value);
uniqueValues.push(value);
});
return uniqueValues;
}
return selectedValue?.toString();
return values;
};

View File

@@ -1,4 +0,0 @@
import { defaultVariableSelectStrategy } from './defaultVariableSelectStrategy';
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
export const customVariableSelectStrategy: VariableSelectStrategy = defaultVariableSelectStrategy;

View File

@@ -1,20 +0,0 @@
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
export const defaultVariableSelectStrategy: VariableSelectStrategy = {
handleChange({ value, variableData, optionsData, onValueUpdate }) {
if (!variableData.name) {
return;
}
const isAllSelected =
Array.isArray(value) &&
value.length > 0 &&
optionsData.every((option) => value.includes(option.toString()));
if (isAllSelected && variableData.showALLOption) {
onValueUpdate(variableData.name, variableData.id, optionsData, true);
} else {
onValueUpdate(variableData.name, variableData.id, value, false);
}
},
};

View File

@@ -1,37 +0,0 @@
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
export const dynamicVariableSelectStrategy: VariableSelectStrategy = {
handleChange({
value,
variableData,
allAvailableOptionStrings,
onValueUpdate,
}) {
if (!variableData.name) {
return;
}
if (
value === ALL_SELECT_VALUE ||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
) {
onValueUpdate(variableData.name, variableData.id, null, true);
} else {
// For ALL selection in dynamic variables, pass null to avoid storing values
// The parent component will handle this appropriately
const haveCustomValuesSelected =
Array.isArray(value) &&
!value.every((v) => allAvailableOptionStrings.includes(v.toString()));
onValueUpdate(
variableData.name,
variableData.id,
value,
allAvailableOptionStrings.every((v) => value.includes(v.toString())),
haveCustomValuesSelected,
);
}
},
};

View File

@@ -1,4 +0,0 @@
import { defaultVariableSelectStrategy } from './defaultVariableSelectStrategy';
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
export const queryVariableSelectStrategy: VariableSelectStrategy = defaultVariableSelectStrategy;

View File

@@ -1,17 +0,0 @@
import { IDashboardVariable } from 'types/api/dashboard/getAll';
export interface VariableSelectStrategy {
handleChange(params: {
value: string | string[];
variableData: IDashboardVariable;
optionsData: (string | number | boolean)[];
allAvailableOptionStrings: string[];
onValueUpdate: (
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
}): void;
}

View File

@@ -1,32 +0,0 @@
import { isEqual } from 'lodash-es';
export const areArraysEqualIgnoreOrder = (
a: (string | number | boolean)[],
b: (string | number | boolean)[],
): boolean => {
if (a.length !== b.length) {
return false;
}
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return isEqual(sortedA, sortedB);
};
export const uniqueValues = (values: string[] | string): string[] | string => {
if (Array.isArray(values)) {
const uniqueValues: string[] = [];
const seenValues = new Set<string>();
values.forEach((value) => {
if (seenValues.has(value)) {
return;
}
seenValues.add(value);
uniqueValues.push(value);
});
return uniqueValues;
}
return values;
};

View File

@@ -2,7 +2,7 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable react/jsx-props-no-spreading */
import React from 'react';
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import { QueryClient, QueryClientProvider } from 'react-query';
import * as ReactRedux from 'react-redux';
import {
act,
@@ -15,22 +15,13 @@ import {
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DynamicVariableInput from '../DashboardVariablesSelection/DynamicVariableInput';
import DynamicVariableSelection from '../DashboardVariablesSelection/DynamicVariableSelection';
// Mock the getFieldValues API
jest.mock('api/dynamicVariables/getFieldValues', () => ({
getFieldValues: jest.fn(),
}));
// Mock useQuery from react-query
jest.mock('react-query', () => {
const originalModule = jest.requireActual('react-query');
return {
...originalModule,
useQuery: jest.fn(),
};
});
describe('Dynamic Variable Default Behavior', () => {
const mockOnValueUpdate = jest.fn();
const mockApiResponse = {
@@ -68,46 +59,6 @@ describe('Dynamic Variable Default Behavior', () => {
// Mock getFieldValues API to return our test data
(getFieldValues as jest.Mock).mockResolvedValue(mockApiResponse);
// Mock useQuery implementation to avoid infinite re-renders
// and ensure onSuccess is called once
(useQuery as jest.Mock).mockImplementation((key, options) => {
const { onSuccess, enabled, queryFn } = options || {};
const variableName = key[1];
const dynamicVarsKey = key[2];
React.useEffect(() => {
if (enabled !== false) {
if (onSuccess) {
// For 'services' tests:
// 1. "Default to ALL" expectations imply empty options -> [] behavior. This happens when selectedValue is undefined (dynamicVarsKey has 'null').
// 2. "ALL Option Special Value" needs full options to render the "ALL" item in dropdown. This happens when selectedValue is defined.
if (
variableName === 'services' &&
typeof dynamicVarsKey === 'string' &&
dynamicVarsKey.includes('null')
) {
onSuccess({
...mockApiResponse,
data: { ...mockApiResponse.data, normalizedValues: [] },
});
} else {
onSuccess(mockApiResponse);
}
}
if (queryFn) {
queryFn();
}
}
}, [enabled, variableName, dynamicVarsKey]); // Only depend on enabled/keys
return {
isLoading: false,
isError: false,
data: mockApiResponse,
refetch: jest.fn(),
};
});
jest.spyOn(ReactRedux, 'useSelector').mockReturnValue({
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-02T00:00:00Z',
@@ -133,7 +84,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableInput
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -169,7 +120,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableInput
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -213,7 +164,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableInput
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -221,6 +172,9 @@ describe('Dynamic Variable Default Behavior', () => {
);
});
// Component should render without errors
expect(screen.getByText('$service')).toBeInTheDocument();
// Check if the dropdown is present
const selectElement = screen.getByRole('combobox');
expect(selectElement).toBeInTheDocument();
@@ -278,7 +232,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableInput
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -313,7 +267,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableInput
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -339,7 +293,7 @@ describe('Dynamic Variable Default Behavior', () => {
expect(screen.queryByText('backend')).not.toBeInTheDocument();
});
it('sahould default to ALL when no default and no previous selection', async () => {
it('should default to ALL when no default and no previous selection', async () => {
const variableData: IDashboardVariable = {
id: 'var21',
name: 'services',
@@ -357,7 +311,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableInput
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -391,7 +345,7 @@ describe('Dynamic Variable Default Behavior', () => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'services',
'var21',
[],
[], // Empty array when allSelected is true
true, // allSelected = true
false,
);
@@ -417,7 +371,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableInput
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -454,7 +408,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableInput
<DynamicVariableSelection
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -462,6 +416,9 @@ describe('Dynamic Variable Default Behavior', () => {
);
});
// Component should render without errors
expect(screen.getByText('$services')).toBeInTheDocument();
// Check if ALL is displayed in the UI (in the main selection area)
const allTextElement = screen.getByText('ALL');
expect(allTextElement).toBeInTheDocument();

View File

@@ -1,104 +0,0 @@
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>
);
}

View File

@@ -1,46 +1,97 @@
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 { 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 {
TimeSeriesTooltipProps,
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 { TimeSeriesChartProps } from '../types';
import { ChartProps } from '../types';
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 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} />;
const legendComponent = useCallback(
(averageLegendWidth: number): React.ReactNode => {
return (
<Legend
config={config}
position={legendConfig.position}
averageLegendWidth={averageLegendWidth}
/>
);
},
[customRenderTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
[config, legendConfig.position],
);
return (
<ChartWrapper {...rest} renderTooltip={renderTooltip}>
{children}
</ChartWrapper>
<PlotContextProvider>
<ChartLayout
config={config}
containerWidth={containerWidth}
containerHeight={containerHeight}
legendConfig={legendConfig}
legendComponent={legendComponent}
layoutChildren={layoutChildren}
>
{({ chartWidth, chartHeight }): 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}
syncKey={syncKey}
render={(props: TooltipRenderArgs): React.ReactNode => (
<Tooltip
{...props}
timezone={timezone}
yAxisUnit={yAxisUnit}
decimalPrecision={decimalPrecision}
/>
)}
/>
)}
</UPlotChart>
)}
</ChartLayout>
</PlotContextProvider>
);
}

View File

@@ -1,39 +1,29 @@
import { PrecisionOption } from 'components/Graph/types';
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
import { LegendConfig } 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;
showTooltip?: boolean;
disableTooltip?: boolean;
timezone: string;
syncMode?: DashboardCursorSync;
syncKey?: string;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
renderTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
}
interface UPlotBasedChartProps {
interface TimeSeriesChartProps extends BaseChartProps {
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 interface TimeSeriesChartProps
extends BaseChartProps,
UPlotBasedChartProps {
legendConfig: LegendConfig;
}
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
legendConfig: LegendConfig;
isStackedBarChart?: boolean;
}
export type ChartProps = TimeSeriesChartProps | BarChartProps;
export type ChartProps = TimeSeriesChartProps;

View File

@@ -1,21 +0,0 @@
.chart-manager-container {
width: 100%;
max-height: calc(40% - 40px);
display: flex;
flex-direction: column;
gap: 16px;
.chart-manager-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.chart-manager-actions-container {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
}
}

View File

@@ -1,147 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Input } from 'antd';
import { ResizeTable } from 'components/ResizeTable';
import { getGraphManagerTableColumns } from 'container/GridCardLayout/GridCard/FullView/TableRender/GraphManagerColumns';
import { ExtendedChartDataset } from 'container/GridCardLayout/GridCard/FullView/types';
import { getDefaultTableDataSet } from 'container/GridCardLayout/GridCard/FullView/utils';
import { useNotifications } from 'hooks/useNotifications';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import './ChartManager.styles.scss';
interface ChartManagerProps {
config: UPlotConfigBuilder;
alignedData: uPlot.AlignedData;
yAxisUnit?: string;
onCancel?: () => void;
}
/**
* ChartManager provides a tabular view to manage the visibility of
* individual series on a uPlot chart.
*
* It syncs with the legend state coming from the plot context and
* allows users to:
* - filter series by label
* - toggle individual series on/off
* - persist the visibility configuration to local storage.
*
* @param config - `UPlotConfigBuilder` instance used to derive chart options.
* @param alignedData - uPlot aligned data used to build the initial table dataset.
* @param yAxisUnit - Optional unit label for Y-axis values shown in the table.
* @param onCancel - Optional callback invoked when the user cancels the dialog.
*/
export default function ChartManager({
config,
alignedData,
yAxisUnit,
onCancel,
}: ChartManagerProps): JSX.Element {
const { notifications } = useNotifications();
const { legendItemsMap } = useLegendsSync({
config,
subscribeToFocusChange: false,
});
const {
onToggleSeriesOnOff,
onToggleSeriesVisibility,
syncSeriesVisibilityToLocalStorage,
} = usePlotContext();
const { isDashboardLocked } = useDashboard();
const [tableDataSet, setTableDataSet] = useState<ExtendedChartDataset[]>(() =>
getDefaultTableDataSet(config.getConfig() as uPlot.Options, alignedData),
);
const graphVisibilityState = useMemo(
() =>
Object.entries(legendItemsMap).reduce<boolean[]>((acc, [key, item]) => {
acc[Number(key)] = item.show;
return acc;
}, []),
[legendItemsMap],
);
useEffect(() => {
setTableDataSet(
getDefaultTableDataSet(config.getConfig() as uPlot.Options, alignedData),
);
}, [alignedData, config]);
const filterHandler = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
const value = event.target.value.toString().toLowerCase();
const updatedDataSet = tableDataSet.map((item) => {
if (item.label?.toLocaleLowerCase().includes(value)) {
return { ...item, show: true };
}
return { ...item, show: false };
});
setTableDataSet(updatedDataSet);
},
[tableDataSet],
);
const dataSource = useMemo(
() =>
tableDataSet.filter(
(item, index) => index !== 0 && item.show, // skipping the first item as it is the x-axis
),
[tableDataSet],
);
const columns = useMemo(
() =>
getGraphManagerTableColumns({
tableDataSet,
checkBoxOnChangeHandler: (_e, index) => {
onToggleSeriesOnOff(index);
},
graphVisibilityState,
labelClickedHandler: onToggleSeriesVisibility,
yAxisUnit,
isGraphDisabled: isDashboardLocked,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tableDataSet, graphVisibilityState, yAxisUnit, isDashboardLocked],
);
const handleSave = useCallback((): void => {
syncSeriesVisibilityToLocalStorage();
notifications.success({
message: 'The updated graphs & legends are saved',
});
if (onCancel) {
onCancel();
}
}, [syncSeriesVisibilityToLocalStorage, notifications, onCancel]);
return (
<div className="chart-manager-container">
<div className="chart-manager-header">
<Input onChange={filterHandler} placeholder="Filter Series" />
<div className="chart-manager-actions-container">
<Button type="default" onClick={onCancel}>
Cancel
</Button>
<Button type="primary" onClick={handleSave}>
Save
</Button>
</div>
</div>
<div className="chart-manager-table-container">
<ResizeTable
columns={columns}
dataSource={dataSource}
virtual
rowKey="index"
scroll={{ y: 200 }}
pagination={false}
/>
</div>
</div>
);
}

View File

@@ -1,120 +0,0 @@
import { useCallback } from 'react';
import { UseQueryResult } from 'react-query';
import {
getTimeRangeFromStepInterval,
isApmMetric,
} from 'container/PanelWrapper/utils';
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
import {
PopoverPosition,
useCoordinates,
} from 'periscope/components/ContextMenu';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
interface UseTimeSeriesContextMenuParams {
widget: Widgets;
queryResponse: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
}
export const usePanelContextMenu = ({
widget,
queryResponse,
}: UseTimeSeriesContextMenuParams): {
coordinates: { x: number; y: number } | null;
popoverPosition: PopoverPosition | null;
onClose: () => void;
menuItemsConfig: {
header?: string | React.ReactNode;
items?: React.ReactNode;
};
clickHandlerWithContextMenu: (...args: any[]) => void;
} => {
const {
coordinates,
popoverPosition,
clickedData,
onClose,
subMenu,
onClick,
setSubMenu,
} = useCoordinates();
const { menuItemsConfig } = useGraphContextMenu({
widgetId: widget.id || '',
query: widget.query,
graphData: clickedData,
onClose,
coordinates,
subMenu,
setSubMenu,
contextLinks: widget.contextLinks,
panelType: widget.panelTypes,
queryRange: queryResponse,
});
const clickHandlerWithContextMenu = useCallback(
(...args: any[]) => {
const [
xValue,
_yvalue,
_mouseX,
_mouseY,
metric,
queryData,
absoluteMouseX,
absoluteMouseY,
axesData,
focusedSeries,
] = args;
const data = getUplotClickData({
metric,
queryData,
absoluteMouseX,
absoluteMouseY,
focusedSeries,
});
let timeRange;
if (axesData && queryData?.queryName) {
const compositeQuery = (queryResponse?.data?.params as any)?.compositeQuery;
if (compositeQuery?.queries) {
const specificQuery = compositeQuery.queries.find(
(query: any) => query.spec?.name === queryData.queryName,
);
const stepInterval = specificQuery?.spec?.stepInterval || 60;
timeRange = getTimeRangeFromStepInterval(
stepInterval,
metric?.clickedTimestamp || xValue,
specificQuery?.spec?.signal === DataSource.METRICS &&
isApmMetric(specificQuery?.spec?.aggregations[0]?.metricName),
);
}
}
if (data && data?.record?.queryName) {
onClick(data.coord, { ...data.record, label: data.label, timeRange });
}
},
[onClick, queryResponse],
);
return {
coordinates,
popoverPosition,
onClose,
menuItemsConfig,
clickHandlerWithContextMenu,
};
};

View File

@@ -11,7 +11,6 @@ export interface ChartLayoutProps {
children: (props: {
chartWidth: number;
chartHeight: number;
averageLegendWidth: number;
}) => React.ReactNode;
layoutChildren?: React.ReactNode;
containerWidth: number;
@@ -57,7 +56,6 @@ export default function ChartLayout({
{children({
chartWidth: chartDimensions.width,
chartHeight: chartDimensions.height,
averageLegendWidth: chartDimensions.averageLegendWidth,
})}
</div>
<div

View File

@@ -1,4 +0,0 @@
.panel-container {
height: 100%;
width: 100%;
}

View File

@@ -1,148 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
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 { ContextMenu } from 'periscope/components/ContextMenu';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useTimezone } from 'providers/Timezone';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
import '../Panel.styles.scss';
function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const {
panelMode,
queryResponse,
widget,
onDragSelect,
isFullViewMode,
onToggleModelHandler,
} = props;
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
const graphRef = useRef<HTMLDivElement>(null);
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
useEffect(() => {
if (toScrollWidgetId === widget.id) {
graphRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
graphRef.current?.focus();
setToScrollWidgetId('');
}
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
setMinTimeScale(startTime);
setMaxTimeScale(endTime);
}, [queryResponse]);
const {
coordinates,
popoverPosition,
onClose,
menuItemsConfig,
clickHandlerWithContextMenu,
} = usePanelContextMenu({
widget,
queryResponse,
});
const chartData = useMemo(() => {
if (!queryResponse?.data?.payload) {
return [];
}
return prepareChartData(queryResponse?.data?.payload);
}, [queryResponse?.data?.payload]);
const config = useMemo(() => {
return prepareUPlotConfig({
widget,
isDarkMode,
currentQuery: widget.query,
onClick: clickHandlerWithContextMenu,
onDragSelect,
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps,
timezone,
panelMode,
minTimeScale: minTimeScale,
maxTimeScale: maxTimeScale,
});
}, [
widget,
isDarkMode,
clickHandlerWithContextMenu,
onDragSelect,
queryResponse?.data?.payload,
panelMode,
minTimeScale,
maxTimeScale,
timezone,
]);
const layoutChildren = useMemo(() => {
if (!isFullViewMode) {
return null;
}
return (
<ChartManager
config={config}
alignedData={chartData}
yAxisUnit={widget.yAxisUnit}
onCancel={onToggleModelHandler}
/>
);
}, [
isFullViewMode,
config,
chartData,
widget.yAxisUnit,
onToggleModelHandler,
]);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<TimeSeries
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
}}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone.value}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
>
<ContextMenu
coordinates={coordinates}
popoverPosition={popoverPosition}
title={menuItemsConfig.header as string}
items={menuItemsConfig.items}
onClose={onClose}
/>
</TimeSeries>
)}
</div>
);
}
export default TimeSeriesPanel;

View File

@@ -1,97 +0,0 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
fillMissingXAxisTimestamps,
getXAxisTimestamps,
} from 'container/DashboardContainer/visualization/panels/utils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DrawStyle,
LineInterpolation,
LineStyle,
VisibilityMode,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
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,
): uPlot.AlignedData => {
const seriesList = apiResponse?.data?.result || [];
const timestampArr = getXAxisTimestamps(seriesList);
const yAxisValuesArr = fillMissingXAxisTimestamps(timestampArr, seriesList);
return [timestampArr, ...yAxisValuesArr];
};
export const prepareUPlotConfig = ({
widget,
isDarkMode,
currentQuery,
onClick,
onDragSelect,
apiResponse,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
}: {
widget: Widgets;
isDarkMode: boolean;
currentQuery: Query;
onClick: OnClickPluginOpts['onClick'];
onDragSelect: (startTime: number, endTime: number) => void;
apiResponse: MetricRangePayloadProps;
timezone: Timezone;
panelMode: PanelMode;
minTimeScale?: number;
maxTimeScale?: number;
}): UPlotConfigBuilder => {
const builder = buildBaseConfig({
widget,
isDarkMode,
onClick,
onDragSelect,
apiResponse,
timezone,
panelMode,
panelType: PANEL_TYPES.TIME_SERIES,
minTimeScale,
maxTimeScale,
});
apiResponse.data?.result?.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,
series.queryName || '', // query
series.legend || '',
);
const label = currentQuery
? getLegend(series, currentQuery, baseLabelName)
: baseLabelName;
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Line,
label: label,
colorMapping: widget.customLegendColors ?? {},
spanGaps: false,
lineStyle: LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline,
showPoints: VisibilityMode.Never,
pointSize: 5,
isDarkMode,
panelType: PANEL_TYPES.TIME_SERIES,
});
});
return builder;
};

View File

@@ -1,127 +0,0 @@
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,
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;
}

View File

@@ -1,54 +0,0 @@
import { normalizePlotValue } from 'lib/uPlotV2/utils/dataUtils';
import { QueryData } from 'types/api/widgets/getQuery';
export function getXAxisTimestamps(seriesList: QueryData[]): number[] {
const timestamps = new Set<number>();
seriesList.forEach((series: { values?: [number, string][] }) => {
if (series?.values) {
series.values.forEach((value) => {
timestamps.add(value[0]);
});
}
});
const timestampsArr = Array.from(timestamps);
timestampsArr.sort((a, b) => a - b);
return timestampsArr;
}
export function fillMissingXAxisTimestamps(
timestampArr: number[],
data: Array<{ values?: [number, string][] }>,
): (number | null)[][] {
// Ensure we work with a sorted, deduplicated list of x-axis timestamps
const canonicalTimestamps = Array.from(new Set(timestampArr)).sort(
(a, b) => a - b,
);
return data.map(({ values }) =>
buildSeriesYValues(canonicalTimestamps, values),
);
}
function buildSeriesYValues(
timestamps: number[],
values?: [number, string][],
): (number | null)[] {
if (!values?.length) {
return [];
}
const valueByTimestamp = new Map<number, number | null>();
for (let i = 0; i < values.length; i++) {
const [timestamp, rawValue] = values[i];
valueByTimestamp.set(timestamp, normalizePlotValue(rawValue));
}
return timestamps.map((timestamp) => {
const value = valueByTimestamp.get(timestamp);
return value !== undefined ? value : null;
});
}

View File

@@ -1,93 +0,0 @@
.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;
}
}

View File

@@ -1,41 +0,0 @@
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&apos;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;

View File

@@ -1,402 +0,0 @@
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();
});
});
});

View File

@@ -1,217 +0,0 @@
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;

View File

@@ -33,8 +33,8 @@ import { useChartMutable } from 'hooks/useChartMutable';
import useComponentPermission from 'hooks/useComponentPermission';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import GetMinMax from 'lib/getMinMax';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';

View File

@@ -8,8 +8,8 @@ import { populateMultipleResults } from 'container/NewWidget/LeftContainer/Widge
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import getTimeString from 'lib/getTimeString';
import { isEqual } from 'lodash-es';
import isEmpty from 'lodash-es/isEmpty';

View File

@@ -6,7 +6,7 @@ import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { AppState } from 'store/reducers';
import { Query } from 'types/api/queryBuilder/queryBuilderData';

View File

@@ -2,6 +2,7 @@
/* 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';
@@ -26,20 +27,12 @@ import {
} from 'antd';
import { NotificationInstance } from 'antd/es/notification/interface';
import { CollapseProps } from 'antd/lib';
import {
useCreateIngestionKey,
useCreateIngestionKeyLimit,
useDeleteIngestionKey,
useDeleteIngestionKeyLimit,
useGetIngestionKeys,
useSearchIngestionKeys,
useUpdateIngestionKey,
useUpdateIngestionKeyLimit,
} from 'api/generated/services/gateway';
import {
GatewaytypesIngestionKeyDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
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 { AxiosError } from 'axios';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import Tags from 'components/Tags/Tags';
@@ -51,6 +44,7 @@ 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';
@@ -72,12 +66,16 @@ 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 { PaginationProps } from 'types/api/ingestionKeys/types';
import {
IngestionKeyProps,
PaginationProps,
} from 'types/api/ingestionKeys/types';
import { MeterAggregateOperator } from 'types/common/queryBuilder';
import { USER_ROLES } from 'types/roles';
import { getDaysUntilExpiry } from 'utils/timeUtils';
@@ -88,10 +86,6 @@ 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,
@@ -117,8 +111,6 @@ export const showErrorNotification = (
): void => {
notifications.error({
message: err.message || SOMETHING_WENT_WRONG,
description: (err as AxiosError<RenderErrorResponseDTO>).response?.data?.error
?.message,
});
};
@@ -171,20 +163,15 @@ function MultiIngestionSettings(): JSX.Element {
const [updatedTags, setUpdatedTags] = useState<string[]>([]);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isEditAddLimitOpen, setIsEditAddLimitOpen] = useState(false);
const [
activeAPIKey,
setActiveAPIKey,
] = useState<GatewaytypesIngestionKeyDTO | null>(null);
const [activeAPIKey, setActiveAPIKey] = useState<IngestionKeyProps | null>();
const [activeSignal, setActiveSignal] = useState<LimitProps | null>(null);
const [searchValue, setSearchValue] = useState<string>('');
const [searchText, setSearchText] = useState<string>('');
const [dataSource, setDataSource] = useState<GatewaytypesIngestionKeyDTO[]>(
[],
);
const [dataSource, setDataSource] = useState<IngestionKeyProps[]>([]);
const [paginationParams, setPaginationParams] = useState<PaginationProps>({
page: FIRST_PAGE,
per_page: INITIAL_PAGE_SIZE,
page: 1,
per_page: 10,
});
const [totalIngestionKeys, setTotalIngestionKeys] = useState(0);
@@ -199,7 +186,7 @@ function MultiIngestionSettings(): JSX.Element {
const [
createLimitForIngestionKeyError,
setCreateLimitForIngestionKeyError,
] = useState<string | null>(null);
] = useState<ErrorResponse | null>(null);
const [
hasUpdateLimitForIngestionKeyError,
@@ -209,7 +196,7 @@ function MultiIngestionSettings(): JSX.Element {
const [
updateLimitForIngestionKeyError,
setUpdateLimitForIngestionKeyError,
] = useState<string | null>(null);
] = useState<ErrorResponse | null>(null);
const { t } = useTranslation(['ingestionKeys']);
@@ -229,11 +216,7 @@ function MultiIngestionSettings(): JSX.Element {
handleFormReset();
};
const showDeleteModal = (apiKey: GatewaytypesIngestionKeyDTO): void => {
setHasCreateLimitForIngestionKeyError(false);
setCreateLimitForIngestionKeyError(null);
setHasUpdateLimitForIngestionKeyError(false);
setUpdateLimitForIngestionKeyError(null);
const showDeleteModal = (apiKey: IngestionKeyProps): void => {
setActiveAPIKey(apiKey);
setIsDeleteModalOpen(true);
};
@@ -250,11 +233,7 @@ function MultiIngestionSettings(): JSX.Element {
setIsAddModalOpen(false);
};
const showEditModal = (apiKey: GatewaytypesIngestionKeyDTO): void => {
setHasCreateLimitForIngestionKeyError(false);
setCreateLimitForIngestionKeyError(null);
setHasUpdateLimitForIngestionKeyError(false);
setUpdateLimitForIngestionKeyError(null);
const showEditModal = (apiKey: IngestionKeyProps): void => {
setActiveAPIKey(apiKey);
handleFormReset();
setUpdatedTags(apiKey.tags || []);
@@ -269,10 +248,6 @@ function MultiIngestionSettings(): JSX.Element {
};
const showAddModal = (): void => {
setHasCreateLimitForIngestionKeyError(false);
setCreateLimitForIngestionKeyError(null);
setHasUpdateLimitForIngestionKeyError(false);
setUpdateLimitForIngestionKeyError(null);
setUpdatedTags([]);
setActiveAPIKey(null);
setIsAddModalOpen(true);
@@ -283,62 +258,27 @@ 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: 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;
data: IngestionKeys,
isLoading,
isRefetching,
refetch: refetchAPIKeys,
error,
isError,
} = useGetAllIngestionsKeys({
search: searchText,
...paginationParams,
});
useEffect(() => {
setDataSource(ingestionKeys?.data.data?.keys || []);
setTotalIngestionKeys(ingestionKeys?.data?.data?._pagination?.total || 0);
setActiveAPIKey(IngestionKeys?.data.data[0]);
}, [IngestionKeys]);
useEffect(() => {
setDataSource(IngestionKeys?.data.data || []);
setTotalIngestionKeys(IngestionKeys?.data?._pagination?.total || 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ingestionKeys?.data?.data]);
}, [IngestionKeys?.data?.data]);
useEffect(() => {
if (isError) {
@@ -357,7 +297,6 @@ function MultiIngestionSettings(): JSX.Element {
const clearSearch = (): void => {
setSearchValue('');
setSearchText('');
};
const {
@@ -370,54 +309,101 @@ function MultiIngestionSettings(): JSX.Element {
const {
mutate: createIngestionKey,
isLoading: isLoadingCreateAPIKey,
} = useCreateIngestionKey<AxiosError<RenderErrorResponseDTO>>();
} = useMutation(createIngestionKeyApi, {
onSuccess: (data) => {
setActiveAPIKey(data.payload);
setUpdatedTags([]);
hideAddViewModal();
refetchAPIKeys();
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
});
const {
mutate: updateAPIKey,
isLoading: isLoadingUpdateAPIKey,
} = useUpdateIngestionKey<AxiosError<RenderErrorResponseDTO>>();
const { mutate: updateAPIKey, isLoading: isLoadingUpdateAPIKey } = useMutation(
updateIngestionKey,
{
onSuccess: () => {
refetchAPIKeys();
setIsEditModalOpen(false);
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
const {
mutate: deleteAPIKey,
isLoading: isDeleteingAPIKey,
} = useDeleteIngestionKey<AxiosError<RenderErrorResponseDTO>>();
const { mutate: deleteAPIKey, isLoading: isDeleteingAPIKey } = useMutation(
deleteIngestionKey,
{
onSuccess: () => {
refetchAPIKeys();
setIsDeleteModalOpen(false);
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
const {
mutate: createLimitForIngestionKey,
isLoading: isLoadingLimitForKey,
} = useCreateIngestionKeyLimit<AxiosError<RenderErrorResponseDTO>>();
} = useMutation(createLimitForIngestionKeyApi, {
onSuccess: () => {
setActiveSignal(null);
setActiveAPIKey(null);
setIsEditAddLimitOpen(false);
setUpdatedTags([]);
hideAddViewModal();
refetchAPIKeys();
setHasCreateLimitForIngestionKeyError(false);
},
onError: (error: ErrorResponse) => {
setHasCreateLimitForIngestionKeyError(true);
setCreateLimitForIngestionKeyError(error);
},
});
const {
mutate: updateLimitForIngestionKey,
isLoading: isLoadingUpdatedLimitForKey,
} = useUpdateIngestionKeyLimit<AxiosError<RenderErrorResponseDTO>>();
} = useMutation(updateLimitForIngestionKeyApi, {
onSuccess: () => {
setActiveSignal(null);
setActiveAPIKey(null);
setIsEditAddLimitOpen(false);
setUpdatedTags([]);
hideAddViewModal();
refetchAPIKeys();
setHasUpdateLimitForIngestionKeyError(false);
},
onError: (error: ErrorResponse) => {
setHasUpdateLimitForIngestionKeyError(true);
setUpdateLimitForIngestionKeyError(error);
},
});
const {
mutate: deleteLimitForKey,
isLoading: isDeletingLimit,
} = useDeleteIngestionKeyLimit<AxiosError<RenderErrorResponseDTO>>();
const { mutate: deleteLimitForKey, isLoading: isDeletingLimit } = useMutation(
deleteLimitsForIngestionKey,
{
onSuccess: () => {
setIsDeleteModalOpen(false);
setIsDeleteLimitModalOpen(false);
refetchAPIKeys();
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
const onDeleteHandler = (): void => {
clearSearch();
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);
},
},
);
if (activeAPIKey) {
deleteAPIKey(activeAPIKey.id);
}
};
@@ -425,31 +411,15 @@ function MultiIngestionSettings(): JSX.Element {
editForm
.validateFields()
.then((values) => {
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(),
),
},
if (activeAPIKey) {
updateAPIKey({
id: activeAPIKey.id,
data: {
name: values.name,
tags: updatedTags,
expires_at: 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) => {
@@ -465,30 +435,10 @@ function MultiIngestionSettings(): JSX.Element {
const requestPayload = {
name: values.name,
tags: updatedTags,
expires_at: new Date(dayjs(values.expires_at).endOf('day').toISOString()),
expires_at: dayjs(values.expires_at).endOf('day').toISOString(),
};
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);
},
},
);
createIngestionKey(requestPayload);
}
})
.catch((errorInfo) => {
@@ -515,7 +465,7 @@ function MultiIngestionSettings(): JSX.Element {
formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.UTC_MONTH_COMPACT);
const showDeleteLimitModal = (
APIKey: GatewaytypesIngestionKeyDTO,
APIKey: IngestionKeyProps,
limit: LimitProps,
): void => {
setActiveAPIKey(APIKey);
@@ -539,17 +489,9 @@ function MultiIngestionSettings(): JSX.Element {
/* eslint-disable sonarjs/cognitive-complexity */
const handleAddLimit = (
APIKey: GatewaytypesIngestionKeyDTO,
APIKey: IngestionKeyProps,
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,
@@ -634,49 +576,13 @@ function MultiIngestionSettings(): JSX.Element {
return;
}
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',
);
},
},
);
createLimitForIngestionKey(payload);
};
const handleUpdateLimit = (
APIKey: GatewaytypesIngestionKeyDTO,
APIKey: IngestionKeyProps,
signal: LimitProps,
): void => {
if (!signal.id) {
notifications.error({
message: 'Invalid limit',
description: 'Cannot update limit without a valid ID',
});
return;
}
const {
dailyLimit,
secondsLimit,
@@ -738,34 +644,7 @@ function MultiIngestionSettings(): JSX.Element {
}
}
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',
);
},
},
);
updateLimitForIngestionKey(payload);
};
/* eslint-enable sonarjs/cognitive-complexity */
@@ -777,7 +656,7 @@ function MultiIngestionSettings(): JSX.Element {
};
const enableEditLimitMode = (
APIKey: GatewaytypesIngestionKeyDTO,
APIKey: IngestionKeyProps,
signal: LimitProps,
): void => {
const dayCount = signal?.config?.day?.count;
@@ -786,11 +665,6 @@ 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,
@@ -829,31 +703,14 @@ function MultiIngestionSettings(): JSX.Element {
const onDeleteLimitHandler = (): void => {
if (activeSignal && 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);
},
},
);
deleteLimitForKey(activeSignal.id);
}
};
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const handleCreateAlert = (
APIKey: GatewaytypesIngestionKeyDTO,
APIKey: IngestionKeyProps,
signal: LimitProps,
): void => {
let metricName = '';
@@ -914,61 +771,31 @@ function MultiIngestionSettings(): JSX.Element {
history.push(URL);
};
const columns: AntDTableProps<GatewaytypesIngestionKeyDTO>['columns'] = [
const columns: AntDTableProps<IngestionKeyProps>['columns'] = [
{
title: 'Ingestion Key',
key: 'ingestion-key',
// eslint-disable-next-line sonarjs/cognitive-complexity
render: (APIKey: GatewaytypesIngestionKeyDTO): JSX.Element => {
const createdOn = APIKey?.created_at
? getFormattedTime(
dayjs(APIKey.created_at).toISOString(),
formatTimezoneAdjustedTimestamp,
)
: '';
render: (APIKey: IngestionKeyProps): JSX.Element => {
const createdOn = getFormattedTime(
APIKey.created_at,
formatTimezoneAdjustedTimestamp,
);
const expiresOn =
!APIKey?.expires_at ||
dayjs(APIKey?.expires_at).toISOString() === '0001-01-01T00:00:00.000Z'
!APIKey?.expires_at || APIKey?.expires_at === '0001-01-01T00:00:00Z'
? 'No Expiry'
: getFormattedTime(
dayjs(APIKey?.expires_at).toISOString(),
formatTimezoneAdjustedTimestamp,
);
: getFormattedTime(APIKey?.expires_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);
};
const updatedOn = getFormattedTime(
APIKey?.updated_at,
formatTimezoneAdjustedTimestamp,
);
// Convert array of limits to a dictionary for quick access
const limitsDict: Record<string, LimitProps> = {};
APIKey.limits?.forEach((limitItem) => {
if (limitItem.signal && limitItem.id) {
limitsDict[limitItem.signal] = limitItem as LimitProps;
}
APIKey.limits?.forEach((limitItem: LimitProps) => {
limitsDict[limitItem.signal] = limitItem;
});
const hasLimits = (signalName: string): boolean => !!limitsDict[signalName];
@@ -985,25 +812,39 @@ function MultiIngestionSettings(): JSX.Element {
<div className="ingestion-key-value">
<Typography.Text>
{APIKey?.value?.substring(0, 2)}********
{APIKey?.value
?.substring(APIKey?.value?.length ? APIKey.value.length - 2 : 0)
?.trim()}
{APIKey?.value.substring(0, 2)}********
{APIKey?.value.substring(APIKey.value.length - 2).trim()}
</Typography.Text>
<Copy className="copy-key-btn" size={12} onClick={onCopyKey} />
<Copy
className="copy-key-btn"
size={12}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
handleCopyKey(APIKey.value);
}}
/>
</div>
</div>
<div className="action-btn">
<Button
className="periscope-btn ghost"
icon={<PenLine size={14} />}
onClick={onEditKey}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
showEditModal(APIKey);
}}
/>
<Button
className="periscope-btn ghost"
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
onClick={onDeleteKey}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
showDeleteModal(APIKey);
}}
/>
</div>
</div>
@@ -1013,7 +854,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>
@@ -1065,39 +906,6 @@ 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">
@@ -1108,18 +916,22 @@ function MultiIngestionSettings(): JSX.Element {
<Button
className="periscope-btn ghost"
icon={<PenLine size={14} />}
disabled={
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
}
onClick={onEditSignalLimit}
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
enableEditLimitMode(APIKey, limit);
}}
/>
<Button
className="periscope-btn ghost"
icon={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
disabled={
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
}
onClick={onDeleteSignalLimit}
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
showDeleteLimitModal(APIKey, limit);
}}
/>
</>
) : (
@@ -1128,8 +940,16 @@ function MultiIngestionSettings(): JSX.Element {
size="small"
shape="round"
icon={<PlusIcon size={14} />}
disabled={!!(activeAPIKey?.id === APIKey?.id && activeSignal)}
onClick={onAddSignalLimit}
disabled={!!(activeAPIKey?.id === APIKey.id && activeSignal)}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
enableEditLimitMode(APIKey, {
id: signalName,
signal: signalName,
config: {},
});
}}
>
Limits
</Button>
@@ -1138,7 +958,7 @@ function MultiIngestionSettings(): JSX.Element {
</div>
<div className="signal-limit-values">
{activeAPIKey?.id === APIKey?.id &&
{activeAPIKey?.id === APIKey.id &&
activeSignal?.signal === signalName &&
isEditAddLimitOpen ? (
<Form
@@ -1334,27 +1154,27 @@ function MultiIngestionSettings(): JSX.Element {
</div>
</div>
{activeAPIKey?.id === APIKey?.id &&
{activeAPIKey?.id === APIKey.id &&
activeSignal.signal === signalName &&
!isLoadingLimitForKey &&
hasCreateLimitForIngestionKeyError &&
createLimitForIngestionKeyError && (
createLimitForIngestionKeyError?.error && (
<div className="error">
{createLimitForIngestionKeyError}
{createLimitForIngestionKeyError?.error}
</div>
)}
{activeAPIKey?.id === APIKey?.id &&
{activeAPIKey?.id === APIKey.id &&
activeSignal.signal === signalName &&
!isLoadingLimitForKey &&
hasUpdateLimitForIngestionKeyError &&
updateLimitForIngestionKeyError && (
updateLimitForIngestionKeyError?.error && (
<div className="error">
{updateLimitForIngestionKeyError}
{updateLimitForIngestionKeyError?.error}
</div>
)}
{activeAPIKey?.id === APIKey?.id &&
{activeAPIKey?.id === APIKey.id &&
activeSignal.signal === signalName &&
isEditAddLimitOpen && (
<div className="signal-limit-save-discard">
@@ -1368,7 +1188,13 @@ function MultiIngestionSettings(): JSX.Element {
loading={
isLoadingLimitForKey || isLoadingUpdatedLimitForKey
}
onClick={onSaveSignalLimit}
onClick={(): void => {
if (!hasLimits(signalName)) {
handleAddLimit(APIKey, signalName);
} else {
handleUpdateLimit(APIKey, limitsDict[signalName]);
}
}}
>
Save
</Button>
@@ -1449,7 +1275,9 @@ function MultiIngestionSettings(): JSX.Element {
className="set-alert-btn periscope-btn ghost"
type="text"
data-testid={`set-alert-btn-${signalName}`}
onClick={onCreateSignalAlert}
onClick={(): void =>
handleCreateAlert(APIKey, limitsDict[signalName])
}
/>
</Tooltip>
)}
@@ -1564,7 +1392,7 @@ function MultiIngestionSettings(): JSX.Element {
const handleTableChange = (pagination: TablePaginationConfig): void => {
setPaginationParams({
page: pagination?.current || 1,
per_page: INITIAL_PAGE_SIZE,
per_page: 10,
});
};
@@ -1662,7 +1490,7 @@ function MultiIngestionSettings(): JSX.Element {
showHeader={false}
onChange={handleTableChange}
pagination={{
pageSize: isSearching ? SEARCH_PAGE_SIZE : paginationParams?.per_page,
pageSize: paginationParams?.per_page,
hideOnSinglePage: true,
showTotal: (total: number, range: number[]): string =>
`${range[0]}-${range[1]} of ${total} Ingestion keys`,

View File

@@ -1,4 +1,3 @@
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';
@@ -19,12 +18,6 @@ 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', () => {
@@ -93,34 +86,32 @@ 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: TestGatewayIngestionKeysResponse = {
const response: TestAllIngestionKeyProps = {
status: 'success',
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 },
},
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 },
};
server.use(
rest.get('*/api/v2/gateway/ingestion_keys*', (_req, res, ctx) =>
rest.get('*/workspaces/me/keys*', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(response)),
),
);
@@ -266,95 +257,4 @@ 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();
});
});

View File

@@ -35,10 +35,10 @@
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
padding: 0px;
.more-filter-actions {
.group-by-clause {
display: flex;
align-items: center;
gap: 8px;
gap: 4px;
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
@@ -53,7 +53,7 @@
}
}
.more-filter-actions:hover {
.group-by-clause:hover {
background-color: unset !important;
}
}
@@ -65,7 +65,7 @@
border: 1px solid var(--bg-vanilla-400);
background: var(--bg-vanilla-100) !important;
.more-filter-actions {
.group-by-clause {
color: var(--bg-ink-400);
}
}

View File

@@ -1,5 +1,4 @@
/* 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';
@@ -17,12 +16,7 @@ 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,
RefreshCw,
} from 'lucide-react';
import { ArrowDownToDot, ArrowUpFromDot, Ellipsis } from 'lucide-react';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { useTimezone } from 'providers/Timezone';
import {
@@ -211,70 +205,6 @@ 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;
@@ -397,21 +327,13 @@ export default function TableViewActions(
content={
<div>
<Button
className="more-filter-actions"
className="group-by-clause"
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"
@@ -483,21 +405,13 @@ export default function TableViewActions(
content={
<div>
<Button
className="more-filter-actions"
className="group-by-clause"
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"

View File

@@ -407,10 +407,6 @@
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;
}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { Button } from '@signozhq/button';
import { Form, Input, Select, Typography } from 'antd';
import { Form, Input, Select, Tooltip, 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,20 +220,6 @@ 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(
@@ -359,16 +345,11 @@ function Login(): JSX.Element {
<ParentContainer>
<div className="password-label-container">
<Label htmlFor="Password">Password</Label>
<Typography.Link
className="forgot-password-link"
href="#"
onClick={(event): void => {
event.preventDefault();
handleForgotPasswordClick();
}}
>
Forgot password?
</Typography.Link>
<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>
</div>
<FormContainer.Item name="password">
<Input.Password

View File

@@ -103,7 +103,7 @@ export const getTotalLogSizeWidgetData = (): Widgets =>
queryName: 'A',
reduceTo: ReduceOperators.SUM,
spaceAggregation: 'sum',
stepInterval: null,
stepInterval: 60,
timeAggregation: 'increase',
},
],
@@ -140,7 +140,7 @@ export const getTotalTraceSizeWidgetData = (): Widgets =>
queryName: 'A',
reduceTo: ReduceOperators.SUM,
spaceAggregation: 'sum',
stepInterval: null,
stepInterval: 60,
timeAggregation: 'increase',
},
],
@@ -177,7 +177,7 @@ export const getTotalMetricDatapointCountWidgetData = (): Widgets =>
queryName: 'A',
reduceTo: ReduceOperators.SUM,
spaceAggregation: 'sum',
stepInterval: null,
stepInterval: 60,
timeAggregation: 'increase',
},
],
@@ -214,7 +214,7 @@ export const getLogCountWidgetData = (): Widgets =>
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
stepInterval: 60,
timeAggregation: 'increase',
},
],
@@ -251,7 +251,7 @@ export const getLogSizeWidgetData = (): Widgets =>
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
stepInterval: 60,
timeAggregation: 'increase',
},
],
@@ -288,7 +288,7 @@ export const getSpanCountWidgetData = (): Widgets =>
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
stepInterval: 60,
timeAggregation: 'increase',
},
],
@@ -325,7 +325,7 @@ export const getSpanSizeWidgetData = (): Widgets =>
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
stepInterval: 60,
timeAggregation: 'increase',
},
],
@@ -362,7 +362,7 @@ export const getMetricCountWidgetData = (): Widgets =>
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: null,
stepInterval: 60,
timeAggregation: 'increase',
},
],

View File

@@ -27,8 +27,8 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
import { Check, X } from 'lucide-react';
import { DashboardWidgetPageParams } from 'pages/DashboardWidget';

View File

@@ -2,16 +2,13 @@ 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';
@@ -42,17 +39,17 @@ export default function OnboardingIngestionDetails(): JSX.Element {
const { notifications } = useNotifications();
const [, handleCopyToClipboard] = useCopyToClipboard();
const [
firstIngestionKey,
setFirstIngestionKey,
] = useState<GatewaytypesIngestionKeyDTO>({} as GatewaytypesIngestionKeyDTO);
const [firstIngestionKey, setFirstIngestionKey] = useState<IngestionKeyProps>(
{} as IngestionKeyProps,
);
const {
data: ingestionKeys,
isLoading: isIngestionKeysLoading,
error,
isError,
} = useGetIngestionKeys({
} = useGetAllIngestionsKeys({
search: '',
page: 1,
per_page: 10,
});
@@ -72,11 +69,8 @@ export default function OnboardingIngestionDetails(): JSX.Element {
};
useEffect(() => {
if (
ingestionKeys?.data?.data?.keys &&
ingestionKeys?.data.data.keys.length > 0
) {
setFirstIngestionKey(ingestionKeys?.data.data.keys[0]);
if (ingestionKeys?.data.data && ingestionKeys?.data.data.length > 0) {
setFirstIngestionKey(ingestionKeys?.data.data[0]);
}
}, [ingestionKeys]);
@@ -86,10 +80,7 @@ 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<RenderErrorResponseDTO>)?.response?.data?.error
?.message ||
(error as AxiosError)?.message ||
'Something went wrong'}
{(error as AxiosError)?.message || 'Something went wrong'}
</Typography.Text>
<div className="ingestion-setup-details-links">
@@ -185,7 +176,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}
@@ -195,9 +186,7 @@ export default function OnboardingIngestionDetails(): JSX.Element {
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.INGESTION_KEY_COPIED}`,
{},
);
if (firstIngestionKey?.value) {
handleCopyKey(firstIngestionKey.value);
}
handleCopyKey(firstIngestionKey?.value);
}}
/>
</Typography.Text>

View File

@@ -323,6 +323,7 @@
"java instrumentation",
"java k8s",
"java logs",
"java metrics",
"java microservices",
"java monitoring",
"java observability",
@@ -331,12 +332,10 @@
"java vm",
"java windows",
"jboss",
"wildfly",
"jdk 11",
"jdk 17",
"jdk 21",
"jdk 8",
"jdbc",
"opentelemetry java",
"quarkus",
"spring boot",
@@ -373,12 +372,6 @@
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/instrumentation/opentelemetry-springboot/"
},
{
"key": "docker",
"label": "Docker",
"imgUrl": "/Logos/docker.svg",
"link": "/docs/instrumentation/opentelemetry-springboot/"
},
{
"key": "windows",
"label": "Windows",
@@ -410,12 +403,6 @@
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/instrumentation/opentelemetry-tomcat/"
},
{
"key": "docker",
"label": "Docker",
"imgUrl": "/Logos/docker.svg",
"link": "/docs/instrumentation/opentelemetry-tomcat/"
},
{
"key": "windows",
"label": "Windows",
@@ -427,7 +414,7 @@
},
{
"key": "jboss",
"label": "JBoss/WildFly",
"label": "JBoss",
"imgUrl": "/Logos/jboss.svg",
"link": "/docs/instrumentation/opentelemetry-jboss/",
"question": {
@@ -447,12 +434,6 @@
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/instrumentation/opentelemetry-jboss/"
},
{
"key": "docker",
"label": "Docker",
"imgUrl": "/Logos/docker.svg",
"link": "/docs/instrumentation/opentelemetry-jboss/"
},
{
"key": "windows",
"label": "Windows",
@@ -484,12 +465,6 @@
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/instrumentation/opentelemetry-quarkus/"
},
{
"key": "docker",
"label": "Docker",
"imgUrl": "/Logos/docker.svg",
"link": "/docs/instrumentation/opentelemetry-quarkus/"
},
{
"key": "windows",
"label": "Windows",
@@ -520,18 +495,6 @@
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/instrumentation/opentelemetry-java/"
},
{
"key": "docker",
"label": "Docker",
"imgUrl": "/Logos/docker.svg",
"link": "/docs/instrumentation/opentelemetry-java/"
},
{
"key": "windows",
"label": "Windows",
"imgUrl": "/Logos/windows.svg",
"link": "/docs/instrumentation/opentelemetry-java/"
}
]
}
@@ -567,6 +530,7 @@
"python in containers",
"python instrumentation",
"python k8s",
"python metrics",
"python microservices",
"python observability",
"python on kubernetes",
@@ -619,6 +583,7 @@
],
"module": "apm",
"relatedSearchKeywords": [
"angular",
"apm",
"apm/traces",
"application performance monitoring",
@@ -630,6 +595,7 @@
"next.js",
"nodejs",
"nuxtjs",
"reactjs",
"traces",
"tracing",
"ts",
@@ -644,8 +610,8 @@
"entityID": "framework",
"options": [
{
"key": "nodejs-nestjs",
"label": "NodeJs/NestJS",
"key": "nodejs",
"label": "NodeJs",
"imgUrl": "/Logos/nodejs.svg",
"link": "/docs/instrumentation/opentelemetry-javascript/",
"question": {
@@ -666,16 +632,103 @@
"link": "/docs/instrumentation/opentelemetry-javascript/"
},
{
"key": "docker",
"label": "Docker",
"imgUrl": "/Logos/docker.svg",
"key": "windows",
"label": "Windows",
"imgUrl": "/Logos/windows.svg",
"link": "/docs/instrumentation/opentelemetry-javascript/"
}
]
}
},
{
"key": "express",
"label": "Express",
"imgUrl": "/Logos/express.svg",
"link": "/docs/instrumentation/opentelemetry-express/",
"question": {
"desc": "What is your Environment?",
"type": "select",
"entityID": "environment",
"options": [
{
"key": "vm",
"label": "VM",
"imgUrl": "/Logos/vm.svg",
"link": "/docs/instrumentation/opentelemetry-express/"
},
{
"key": "k8s",
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/instrumentation/opentelemetry-express/"
},
{
"key": "windows",
"label": "Windows",
"imgUrl": "/Logos/windows.svg",
"link": "/docs/instrumentation/opentelemetry-javascript/"
"link": "/docs/instrumentation/opentelemetry-express/"
}
]
}
},
{
"key": "nestjs",
"label": "NestJS",
"imgUrl": "/Logos/nestjs.svg",
"link": "/docs/instrumentation/opentelemetry-nestjs/",
"question": {
"desc": "What is your Environment?",
"type": "select",
"entityID": "environment",
"options": [
{
"key": "vm",
"label": "VM",
"imgUrl": "/Logos/vm.svg",
"link": "/docs/instrumentation/opentelemetry-nestjs/"
},
{
"key": "k8s",
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/instrumentation/opentelemetry-nestjs/"
},
{
"key": "windows",
"label": "Windows",
"imgUrl": "/Logos/windows.svg",
"link": "/docs/instrumentation/opentelemetry-nestjs/"
}
]
}
},
{
"key": "angular",
"label": "Angular",
"imgUrl": "/Logos/angular.svg",
"link": "/docs/instrumentation/opentelemetry-angular/",
"question": {
"desc": "What is your Environment?",
"type": "select",
"entityID": "environment",
"options": [
{
"key": "vm",
"label": "VM",
"imgUrl": "/Logos/vm.svg",
"link": "/docs/instrumentation/opentelemetry-angular/"
},
{
"key": "k8s",
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/instrumentation/opentelemetry-angular/"
},
{
"key": "windows",
"label": "Windows",
"imgUrl": "/Logos/windows.svg",
"link": "/docs/instrumentation/opentelemetry-angular/"
}
]
}
@@ -703,16 +756,41 @@
"link": "/docs/instrumentation/opentelemetry-nextjs/"
},
{
"key": "docker",
"label": "Docker",
"imgUrl": "/Logos/docker.svg",
"key": "windows",
"label": "Windows",
"imgUrl": "/Logos/windows.svg",
"link": "/docs/instrumentation/opentelemetry-nextjs/"
}
]
}
},
{
"key": "reactjs",
"label": "ReactJS",
"imgUrl": "/Logos/reactjs.svg",
"link": "/docs/instrumentation/opentelemetry-reactjs/",
"question": {
"desc": "What is your Environment?",
"type": "select",
"entityID": "environment",
"options": [
{
"key": "vm",
"label": "VM",
"imgUrl": "/Logos/vm.svg",
"link": "/docs/instrumentation/opentelemetry-reactjs/"
},
{
"key": "k8s",
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/instrumentation/opentelemetry-reactjs/"
},
{
"key": "windows",
"label": "Windows",
"imgUrl": "/Logos/windows.svg",
"link": "/docs/instrumentation/opentelemetry-nextjs/"
"link": "/docs/instrumentation/opentelemetry-reactjs/"
}
]
}
@@ -739,12 +817,6 @@
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/instrumentation/opentelemetry-nuxtjs/"
},
{
"key": "docker",
"label": "Docker",
"imgUrl": "/Logos/docker.svg",
"link": "/docs/instrumentation/opentelemetry-nuxtjs/"
},
{
"key": "windows",
"label": "Windows",
@@ -758,7 +830,32 @@
"key": "react-native",
"label": "React Native",
"imgUrl": "/Logos/reactjs.svg",
"link": "/docs/instrumentation/opentelemetry-react-native/"
"link": "/docs/instrumentation/opentelemetry-react-native/",
"question": {
"desc": "What is your Environment?",
"type": "select",
"entityID": "environment",
"options": [
{
"key": "vm",
"label": "VM",
"imgUrl": "/Logos/vm.svg",
"link": "/docs/instrumentation/opentelemetry-react-native/"
},
{
"key": "k8s",
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/instrumentation/opentelemetry-react-native/"
},
{
"key": "windows",
"label": "Windows",
"imgUrl": "/Logos/windows.svg",
"link": "/docs/instrumentation/opentelemetry-react-native/"
}
]
}
}
]
}
@@ -792,6 +889,7 @@
"golang",
"golang apm",
"golang instrumentation",
"golang metrics",
"golang observability",
"golang performance monitoring",
"golang tracing",
@@ -882,39 +980,69 @@
"php-fpm monitoring",
"slim php",
"traces",
"tracing",
"wordpress"
"tracing"
],
"id": "php",
"link": "/docs/instrumentation/opentelemetry-php/",
"question": {
"desc": "What is your Environment?",
"desc": "Which PHP framework do you use?",
"type": "select",
"entityID": "environment",
"entityID": "framework",
"options": [
{
"key": "vm",
"label": "VM",
"imgUrl": "/Logos/vm.svg",
"link": "/docs/instrumentation/opentelemetry-php/"
"key": "laravel",
"label": "Laravel",
"imgUrl": "/Logos/laravel.svg",
"link": "/docs/instrumentation/laravel/",
"question": {
"desc": "What is your Environment?",
"type": "select",
"entityID": "environment",
"options": [
{
"key": "vm",
"label": "VM",
"imgUrl": "/Logos/vm.svg",
"link": "/docs/instrumentation/laravel/"
},
{
"key": "k8s",
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/instrumentation/laravel/"
}
]
}
},
{
"key": "k8s",
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/instrumentation/opentelemetry-php/"
},
{
"key": "docker",
"label": "Docker",
"imgUrl": "/Logos/docker.svg",
"link": "/docs/instrumentation/opentelemetry-php/"
},
{
"key": "windows",
"label": "Windows",
"imgUrl": "/Logos/windows.svg",
"link": "/docs/instrumentation/opentelemetry-php/"
"key": "Others",
"label": "Others",
"imgUrl": "/Logos/php-others.svg",
"link": "/docs/instrumentation/opentelemetry-php/",
"question": {
"desc": "What is your Environment?",
"type": "select",
"entityID": "environment",
"options": [
{
"key": "vm",
"label": "VM",
"imgUrl": "/Logos/vm.svg",
"link": "/docs/instrumentation/opentelemetry-php/"
},
{
"key": "k8s",
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/instrumentation/opentelemetry-php/"
},
{
"key": "windows",
"label": "Windows",
"imgUrl": "/Logos/windows.svg",
"link": "/docs/instrumentation/opentelemetry-php/"
}
]
}
}
]
}
@@ -989,12 +1117,6 @@
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/instrumentation/opentelemetry-dotnet/"
},
{
"key": "docker",
"label": "Docker",
"imgUrl": "/Logos/docker.svg",
"link": "/docs/instrumentation/opentelemetry-dotnet/"
},
{
"key": "windows",
"label": "Windows",
@@ -1006,7 +1128,7 @@
},
{
"dataSource": "ruby-on-rails",
"label": "Ruby",
"label": "Ruby on Rails",
"imgUrl": "/Logos/ruby-on-rails.svg",
"tags": [
"apm/traces"
@@ -1046,12 +1168,9 @@
"ruby tracing",
"ruby-on-rails",
"traces",
"tracing",
"sinatra",
"sidekiq",
"resque"
"tracing"
],
"id": "ruby",
"id": "ruby-on-rails",
"link": "/docs/instrumentation/opentelemetry-ruby-on-rails/",
"question": {
"desc": "What is your Environment?",
@@ -1070,12 +1189,6 @@
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/instrumentation/opentelemetry-ruby-on-rails/"
},
{
"key": "docker",
"label": "Docker",
"imgUrl": "/Logos/docker.svg",
"link": "/docs/instrumentation/opentelemetry-ruby-on-rails/"
},
{
"key": "windows",
"label": "Windows",
@@ -1355,6 +1468,41 @@
"id": "nginx-tracing",
"link": "/docs/instrumentation/opentelemetry-nginx/"
},
{
"dataSource": "opentelemetry-wordpress",
"label": "WordPress",
"imgUrl": "/Logos/wordpress.svg",
"tags": [
"apm/traces"
],
"module": "apm",
"relatedSearchKeywords": [
"apm",
"apm/traces",
"application performance monitoring",
"monitor wordpress site",
"opentelemetry",
"opentelemetry wordpress",
"opentelemetry-wordpress",
"otel",
"otel wordpress",
"traces",
"tracing",
"wordpress",
"wordpress apm",
"wordpress instrumentation",
"wordpress metrics",
"wordpress monitoring",
"wordpress observability",
"wordpress performance",
"wordpress php monitoring",
"wordpress plugin monitoring",
"wordpress to signoz",
"wordpress tracing"
],
"id": "opentelemetry-wordpress",
"link": "/docs/instrumentation/opentelemetry-wordpress/"
},
{
"dataSource": "opentelemetry-cloudflare",
"label": "Cloudflare Tracing",
@@ -1419,25 +1567,6 @@
"id": "opentelemetry-cloudflare-logs",
"link": "/docs/logs-management/send-logs/cloudflare-logs/"
},
{
"dataSource": "convex-logs",
"label": "Convex Logs",
"imgUrl": "/Logos/convex-logo.svg",
"tags": [
"logs"
],
"module": "logs",
"relatedSearchKeywords": [
"convex",
"convex log streaming",
"convex logs",
"convex webhook",
"logging",
"logs"
],
"id": "convex-logs",
"link": "/docs/logs-management/send-logs/convex-log-streams-signoz/"
},
{
"dataSource": "kubernetes-pod-logs",
"label": "Kubernetes Pod Logs",
@@ -4439,7 +4568,6 @@
],
"module": "metrics",
"relatedSearchKeywords": [
"browser metrics",
"core web vitals metrics",
"frontend metrics",
"frontend monitoring",
@@ -4459,7 +4587,7 @@
"svelte",
"sveltekit",
"nextjs",
"next.js"
"next.js"
],
"link": "/docs/frontend-monitoring/web-vitals-with-metrics/"
},
@@ -4494,7 +4622,7 @@
"svelte",
"sveltekit",
"nextjs",
"next.js"
"next.js"
],
"link": "/docs/frontend-monitoring/web-vitals-with-traces/"
},
@@ -4536,7 +4664,7 @@
"svelte",
"sveltekit",
"nextjs",
"next.js"
"next.js"
],
"link": "/docs/frontend-monitoring/document-load/"
},
@@ -5188,6 +5316,23 @@
],
"link": "/docs/metrics-management/mysql-metrics/"
},
{
"dataSource": "jvm",
"label": "JVM",
"imgUrl": "/Logos/java.svg",
"tags": [
"metrics"
],
"module": "metrics",
"relatedSearchKeywords": [
"java virtual machine",
"jvm",
"jvm metrics",
"jvm monitoring",
"runtime"
],
"link": "/docs/tutorial/jvm-metrics/"
},
{
"dataSource": "jmx",
"label": "JMX",
@@ -5203,7 +5348,7 @@
"jmx monitoring",
"runtime"
],
"link": "/docs/metrics-management/send-metrics/applications/opentelemetry-java/jmx-metrics/"
"link": "/docs/tutorial/jmx-metrics/"
},
{
"dataSource": "prometheus-metrics",
@@ -5429,17 +5574,16 @@
"svelte",
"sveltekit",
"nextjs",
"next.js"
"next.js"
],
"link": "/docs/frontend-monitoring/sending-logs-with-opentelemetry/"
},
{
"dataSource": "frontend-traces",
"label": "Frontend Tracing",
"label": "Frontend Traces",
"imgUrl": "/Logos/traces.svg",
"tags": [
"Frontend Monitoring",
"apm/traces"
"Frontend Monitoring"
],
"module": "apm",
"relatedSearchKeywords": [
@@ -5461,7 +5605,7 @@
"svelte",
"sveltekit",
"nextjs",
"next.js"
"next.js"
],
"link": "/docs/frontend-monitoring/sending-traces-with-opentelemetry/"
},
@@ -5492,7 +5636,7 @@
"svelte",
"sveltekit",
"nextjs",
"next.js"
"next.js"
],
"link": "/docs/frontend-monitoring/sending-metrics-with-opentelemetry/"
},
@@ -5539,6 +5683,7 @@
"label": "Golang Metrics",
"imgUrl": "/Logos/go.svg",
"tags": [
"apm/traces",
"metrics"
],
"module": "metrics",
@@ -5592,64 +5737,12 @@
]
}
},
{
"dataSource": "java-metrics",
"label": "Java Metrics",
"imgUrl": "/Logos/java.svg",
"tags": [
"metrics"
],
"module": "metrics",
"relatedSearchKeywords": [
"java",
"java metrics",
"java monitoring",
"java observability",
"jvm metrics",
"metrics",
"opentelemetry java",
"otel java",
"runtime metrics"
],
"id": "java-metrics",
"link": "/docs/metrics-management/send-metrics/applications/opentelemetry-java/",
"question": {
"desc": "What is your Environment?",
"type": "select",
"entityID": "environment",
"options": [
{
"key": "vm",
"label": "VM",
"imgUrl": "/Logos/vm.svg",
"link": "/docs/metrics-management/send-metrics/applications/opentelemetry-java/"
},
{
"key": "k8s",
"label": "Kubernetes",
"imgUrl": "/Logos/kubernetes.svg",
"link": "/docs/metrics-management/send-metrics/applications/opentelemetry-java/"
},
{
"key": "windows",
"label": "Windows",
"imgUrl": "/Logos/windows.svg",
"link": "/docs/metrics-management/send-metrics/applications/opentelemetry-java/"
},
{
"key": "docker",
"label": "Docker",
"imgUrl": "/Logos/docker.svg",
"link": "/docs/metrics-management/send-metrics/applications/opentelemetry-java/"
}
]
}
},
{
"dataSource": "python-metrics",
"label": "Python Metrics",
"imgUrl": "/Logos/python.svg",
"tags": [
"apm/traces",
"metrics"
],
"module": "metrics",
@@ -5703,6 +5796,7 @@
"label": ".NET Metrics",
"imgUrl": "/Logos/dotnet.svg",
"tags": [
"apm/traces",
"metrics"
],
"module": "metrics",
@@ -5759,6 +5853,7 @@
"label": "Node.js Metrics",
"imgUrl": "/Logos/nodejs.svg",
"tags": [
"apm/traces",
"metrics"
],
"module": "metrics",

View File

@@ -1,5 +1,5 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { FilterOutlined, VerticalAlignTopOutlined } from '@ant-design/icons';
import { FilterOutlined } from '@ant-design/icons';
import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { Atom, Binoculars, SquareMousePointer, Terminal } from 'lucide-react';
@@ -32,7 +32,6 @@ export default function LeftToolbarActions({
<Tooltip title="Show Filters">
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
<FilterOutlined />
<VerticalAlignTopOutlined rotate={90} />
</Button>
</Tooltip>
)}

View File

@@ -7,7 +7,7 @@
align-items: center;
justify-content: center;
box-shadow: none;
width: 40px;
width: 32px;
height: 32px;
margin-right: 12px;
border: 1px solid var(--bg-slate-400);

View File

@@ -95,6 +95,7 @@ function ResourceAttributesFilter({
data-testid="resource-environment-filter"
style={{ minWidth: 200, height: 34 }}
onChange={handleEnvironmentChange}
onBlur={handleBlur}
>
{environments.map((opt) => (
<Select.Option key={opt.value} value={opt.value}>

View File

@@ -1,8 +1,8 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { ServicesList } from 'types/api/metrics/getService';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { EQueryType } from 'types/common/dashboard';

View File

@@ -32,7 +32,6 @@ 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],

View File

@@ -13,7 +13,7 @@ import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useNotifications } from 'hooks/useNotifications';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { isEmpty } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';

View File

@@ -3,8 +3,8 @@ import { useSelector } from 'react-redux';
import { initialQueriesMap } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';

View File

@@ -1,77 +0,0 @@
import ROUTES from 'constants/routes';
import { whilelistedKeys } from '../config';
import { mappingWithRoutesAndKeys } from '../utils';
describe('useResourceAttribute config', () => {
describe('whilelistedKeys', () => {
it('should include underscore-notation keys (DOT_METRICS_ENABLED=false)', () => {
expect(whilelistedKeys).toContain('resource_deployment_environment');
expect(whilelistedKeys).toContain('resource_k8s_cluster_name');
expect(whilelistedKeys).toContain('resource_k8s_cluster_namespace');
});
it('should include dot-notation keys (DOT_METRICS_ENABLED=true)', () => {
expect(whilelistedKeys).toContain('resource_deployment.environment');
expect(whilelistedKeys).toContain('resource_k8s.cluster.name');
expect(whilelistedKeys).toContain('resource_k8s.cluster.namespace');
});
});
describe('mappingWithRoutesAndKeys', () => {
const dotNotationFilters = [
{
label: 'deployment.environment',
value: 'resource_deployment.environment',
},
{ label: 'k8s.cluster.name', value: 'resource_k8s.cluster.name' },
{ label: 'k8s.cluster.namespace', value: 'resource_k8s.cluster.namespace' },
];
const underscoreNotationFilters = [
{
label: 'deployment.environment',
value: 'resource_deployment_environment',
},
{ label: 'k8s.cluster.name', value: 'resource_k8s_cluster_name' },
{ label: 'k8s.cluster.namespace', value: 'resource_k8s_cluster_namespace' },
];
const nonWhitelistedFilters = [
{ label: 'host.name', value: 'resource_host_name' },
{ label: 'service.name', value: 'resource_service_name' },
];
it('should keep dot-notation filters on the Service Map route', () => {
const result = mappingWithRoutesAndKeys(
ROUTES.SERVICE_MAP,
dotNotationFilters,
);
expect(result).toHaveLength(3);
expect(result).toEqual(dotNotationFilters);
});
it('should keep underscore-notation filters on the Service Map route', () => {
const result = mappingWithRoutesAndKeys(
ROUTES.SERVICE_MAP,
underscoreNotationFilters,
);
expect(result).toHaveLength(3);
expect(result).toEqual(underscoreNotationFilters);
});
it('should filter out non-whitelisted keys on the Service Map route', () => {
const allFilters = [...dotNotationFilters, ...nonWhitelistedFilters];
const result = mappingWithRoutesAndKeys(ROUTES.SERVICE_MAP, allFilters);
expect(result).toHaveLength(3);
expect(result).toEqual(dotNotationFilters);
});
it('should return all filters on non-Service Map routes', () => {
const allFilters = [...dotNotationFilters, ...nonWhitelistedFilters];
const result = mappingWithRoutesAndKeys('/services', allFilters);
expect(result).toHaveLength(5);
expect(result).toEqual(allFilters);
});
});
});

View File

@@ -1,8 +1,5 @@
export const whilelistedKeys = [
'resource_deployment_environment',
'resource_deployment.environment',
'resource_k8s_cluster_name',
'resource_k8s.cluster.name',
'resource_k8s_cluster_namespace',
'resource_k8s.cluster.namespace',
];

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