mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-13 11:50:42 +00:00
Compare commits
24 Commits
fix/safe-n
...
feat/distr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7096dff9a | ||
|
|
4ed16d1bfb | ||
|
|
99a4322db6 | ||
|
|
035c8960d8 | ||
|
|
ac982a3af6 | ||
|
|
646eef7fa2 | ||
|
|
1c5483b166 | ||
|
|
90a4325000 | ||
|
|
38c3793efd | ||
|
|
428b89e419 | ||
|
|
9bad81f266 | ||
|
|
b87d2dce5e | ||
|
|
1b23c2bde1 | ||
|
|
6eb6827e33 | ||
|
|
d1cf477ca3 | ||
|
|
1ced5ce038 | ||
|
|
a11ed42748 | ||
|
|
b68642c826 | ||
|
|
95d042d66e | ||
|
|
c64ed62fd6 | ||
|
|
328227e3c4 | ||
|
|
2ef1f756f7 | ||
|
|
a19ad8a432 | ||
|
|
2d958adc88 |
13
.github/CODEOWNERS
vendored
13
.github/CODEOWNERS
vendored
@@ -194,8 +194,8 @@ go.mod @therealpandey
|
||||
/frontend/src/pages/EditRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AlertHistory/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertRule/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlert/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditAlert/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditAlertV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/FormAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
|
||||
@@ -209,10 +209,6 @@ go.mod @therealpandey
|
||||
/frontend/src/types/api/alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/providers/Alert.tsx @SigNoz/pulse-frontend
|
||||
/frontend/src/constants/alerts.ts @SigNoz/pulse-frontend
|
||||
/frontend/src/container/PlannedDowntime/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/Home/AlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/Home/__tests__/AlertRules.test.tsx @SigNoz/pulse-frontend
|
||||
|
||||
## Notification Channels
|
||||
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
|
||||
@@ -221,11 +217,6 @@ go.mod @therealpandey
|
||||
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
|
||||
|
||||
## List filter + views (dashboards and alerts lists)
|
||||
/frontend/src/components/ListFilter/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/ListViews/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/listViews/ @SigNoz/pulse-frontend
|
||||
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
2
.github/workflows/build-community.yaml
vendored
2
.github/workflows/build-community.yaml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.25
|
||||
GO_VERSION: 1.24
|
||||
GO_NAME: signoz-community
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: community-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
|
||||
2
.github/workflows/build-enterprise.yaml
vendored
2
.github/workflows/build-enterprise.yaml
vendored
@@ -89,7 +89,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.25
|
||||
GO_VERSION: 1.24
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
GO_BUILD_CONTEXT: ./cmd/enterprise
|
||||
|
||||
2
.github/workflows/build-staging.yaml
vendored
2
.github/workflows/build-staging.yaml
vendored
@@ -95,7 +95,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.25
|
||||
GO_VERSION: 1.24
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: staging-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
GO_BUILD_CONTEXT: ./cmd/enterprise
|
||||
|
||||
20
.github/workflows/goci.yaml
vendored
20
.github/workflows/goci.yaml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_TEST_CONTEXT: ./...
|
||||
GO_VERSION: 1.25
|
||||
GO_VERSION: 1.24
|
||||
fmt:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.25
|
||||
GO_VERSION: 1.24
|
||||
lint:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.25
|
||||
GO_VERSION: 1.24
|
||||
deps:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.25
|
||||
GO_VERSION: 1.24
|
||||
semconv-generated:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
- name: check-semconv-generated-files
|
||||
run: make semconv-check
|
||||
build:
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
- name: qemu-install
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: aarch64-install
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
- name: generate-openapi
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate openapi
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
- name: generate-authz
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate authz
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
- name: generate-web-settings
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config web-settings
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
- name: generate-transaction-groups
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config transaction-groups
|
||||
|
||||
4
.github/workflows/gor-signoz-community.yaml
vendored
4
.github/workflows/gor-signoz-community.yaml
vendored
@@ -66,7 +66,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
- name: cross-compilation-tools
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
|
||||
# copy the caches from build
|
||||
- name: get-sha
|
||||
|
||||
4
.github/workflows/gor-signoz.yaml
vendored
4
.github/workflows/gor-signoz.yaml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
- name: cross-compilation-tools
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: "1.24"
|
||||
|
||||
# copy the caches from build
|
||||
- name: get-sha
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/cmd"
|
||||
"github.com/SigNoz/signoz/ee/auditor/fileauditor"
|
||||
"github.com/SigNoz/signoz/ee/auditor/kafkaauditor"
|
||||
"github.com/SigNoz/signoz/ee/auditor/otlphttpauditor"
|
||||
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
|
||||
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
|
||||
@@ -157,9 +156,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
if err := factories.Add(fileauditor.NewFactory(licensing, version.Info)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := factories.Add(kafkaauditor.NewFactory(licensing, version.Info)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return factories
|
||||
},
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, flagger pkgflagger.Flagger, licensing licensing.Licensing, telemetryStore telemetrystore.TelemetryStore, retentionGetter retention.Getter, orgGetter organization.Getter, zeus zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string) {
|
||||
|
||||
@@ -436,7 +436,6 @@ auditor:
|
||||
# Specifies the auditor provider to use.
|
||||
# noop: discards all audit events (community default).
|
||||
# otlphttp: exports audit events via OTLP HTTP (enterprise).
|
||||
# kafka: exports audit events to a Kafka topic as OTLP protobuf (enterprise).
|
||||
provider: noop
|
||||
# The async channel capacity for audit events. Events are dropped when full (fail-open).
|
||||
buffer_size: 1000
|
||||
@@ -462,18 +461,6 @@ auditor:
|
||||
max_interval: 30s
|
||||
# The total maximum time spent retrying.
|
||||
max_elapsed_time: 60s
|
||||
kafka:
|
||||
# The seed broker addresses (host:port) used to discover the cluster.
|
||||
brokers:
|
||||
- localhost:9092
|
||||
# The topic audit events are written to. It must already exist.
|
||||
topic: ""
|
||||
# The client id the producer reports to the brokers.
|
||||
client_id: signoz
|
||||
# The compression codec for produced batches: none, gzip, snappy, lz4 or zstd.
|
||||
compression: lz4
|
||||
# The maximum time to drain buffered events on shutdown. Undelivered events are dropped after it.
|
||||
shutdown_timeout: 30s
|
||||
|
||||
##################### Cloud Integration #####################
|
||||
cloudintegration:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +0,0 @@
|
||||
package kafkaauditor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/twmb/franz-go/pkg/kgo"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/audittypes"
|
||||
)
|
||||
|
||||
// Produces the whole batch as a single record so the collector's otlp_proto unmarshaler reads it as one ExportLogsServiceRequest.
|
||||
func (provider *provider) export(ctx context.Context, events []audittypes.AuditEvent) error {
|
||||
logs := audittypes.NewPLogsFromAuditEvents(events, "signoz", provider.build.Version(), "signoz.audit")
|
||||
|
||||
value, err := provider.marshaler.MarshalLogs(logs)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "failed to marshal audit logs")
|
||||
}
|
||||
|
||||
record := &kgo.Record{Topic: provider.config.Kafka.Topic, Value: value}
|
||||
if err := provider.client.ProduceSync(ctx, record).FirstErr(); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "audit export failed", errors.Attr(err), slog.Int("dropped_log_records", len(events)))
|
||||
|
||||
return errors.Wrapf(err, errors.TypeInternal, auditor.ErrCodeAuditExportFailed, "failed to produce audit logs to topic %q", provider.config.Kafka.Topic)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package kafkaauditor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/twmb/franz-go/pkg/kgo"
|
||||
"github.com/twmb/franz-go/plugin/kslog"
|
||||
"go.opentelemetry.io/collector/pdata/plog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/auditor/auditorserver"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/types/audittypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
)
|
||||
|
||||
var _ auditor.Auditor = (*provider)(nil)
|
||||
|
||||
type provider struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
config auditor.Config
|
||||
licensing licensing.Licensing
|
||||
build version.Build
|
||||
server *auditorserver.Server
|
||||
marshaler plog.ProtoMarshaler
|
||||
client *kgo.Client
|
||||
}
|
||||
|
||||
func NewFactory(licensing licensing.Licensing, build version.Build) factory.ProviderFactory[auditor.Auditor, auditor.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("kafka"), func(ctx context.Context, providerSettings factory.ProviderSettings, config auditor.Config) (auditor.Auditor, error) {
|
||||
return newProvider(ctx, providerSettings, config, licensing, build)
|
||||
})
|
||||
}
|
||||
|
||||
func newProvider(_ context.Context, providerSettings factory.ProviderSettings, config auditor.Config, licensing licensing.Licensing, build version.Build) (auditor.Auditor, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/auditor/kafkaauditor")
|
||||
|
||||
// Acks from all in-sync replicas, idempotent writes and unbounded record retries are franz-go defaults and are kept as is.
|
||||
// A delivery timeout or retry limit lets franz-go < v1.22.2 ack a record that was never written (twmb/franz-go#1484).
|
||||
client, err := kgo.NewClient(
|
||||
kgo.SeedBrokers(config.Kafka.Brokers...),
|
||||
kgo.ClientID(config.Kafka.ClientID),
|
||||
kgo.ProducerBatchCompression(compressionCodec(config.Kafka.Compression)),
|
||||
kgo.ProducerLinger(0),
|
||||
kgo.WithLogger(kslog.New(settings.Logger())),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider := &provider{
|
||||
settings: settings,
|
||||
config: config,
|
||||
licensing: licensing,
|
||||
build: build,
|
||||
marshaler: plog.ProtoMarshaler{},
|
||||
client: client,
|
||||
}
|
||||
|
||||
server, err := auditorserver.New(settings,
|
||||
auditorserver.Config{
|
||||
BufferSize: config.BufferSize,
|
||||
BatchSize: config.BatchSize,
|
||||
FlushInterval: config.FlushInterval,
|
||||
},
|
||||
provider.export,
|
||||
)
|
||||
if err != nil {
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider.server = server
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Start(ctx context.Context) error {
|
||||
return provider.server.Start(ctx)
|
||||
}
|
||||
|
||||
func (provider *provider) Audit(ctx context.Context, event audittypes.AuditEvent) {
|
||||
if event.PrincipalAttributes.PrincipalOrgID.IsZero() {
|
||||
provider.settings.Logger().WarnContext(ctx, "audit event dropped as org_id is zero")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := provider.licensing.GetActive(ctx, event.PrincipalAttributes.PrincipalOrgID); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
provider.server.Add(ctx, event)
|
||||
}
|
||||
|
||||
func (provider *provider) Healthy() <-chan struct{} {
|
||||
return provider.server.Healthy()
|
||||
}
|
||||
|
||||
func (provider *provider) Stop(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, provider.config.Kafka.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
stoppedC := make(chan error, 1)
|
||||
go func() { stoppedC <- provider.server.Stop(ctx) }()
|
||||
|
||||
select {
|
||||
case err := <-stoppedC:
|
||||
provider.client.Close()
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
provider.settings.Logger().WarnContext(ctx, "audit drain timed out, closing kafka client", slog.Duration("shutdown_timeout", provider.config.Kafka.ShutdownTimeout))
|
||||
// Closing fails buffered and in-flight records, so the blocked export and the rest of the drain return immediately.
|
||||
provider.client.Close()
|
||||
return <-stoppedC
|
||||
}
|
||||
}
|
||||
|
||||
func compressionCodec(name string) kgo.CompressionCodec {
|
||||
switch name {
|
||||
case "gzip":
|
||||
return kgo.GzipCompression()
|
||||
case "snappy":
|
||||
return kgo.SnappyCompression()
|
||||
case "lz4":
|
||||
return kgo.Lz4Compression()
|
||||
case "zstd":
|
||||
return kgo.ZstdCompression()
|
||||
default:
|
||||
return kgo.NoCompression()
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
package kafkaauditor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/twmb/franz-go/pkg/kerr"
|
||||
"github.com/twmb/franz-go/pkg/kfake"
|
||||
"github.com/twmb/franz-go/pkg/kgo"
|
||||
"go.opentelemetry.io/collector/pdata/plog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/types/audittypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
)
|
||||
|
||||
func newTestCluster(t *testing.T, topics ...string) *kfake.Cluster {
|
||||
t.Helper()
|
||||
|
||||
cluster, err := kfake.NewCluster(kfake.NumBrokers(1), kfake.SeedTopics(1, topics...))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(cluster.Close)
|
||||
|
||||
return cluster
|
||||
}
|
||||
|
||||
func newTestProvider(t *testing.T, brokers []string, topic string, compression string) *provider {
|
||||
t.Helper()
|
||||
|
||||
config := auditor.Config{
|
||||
Provider: "kafka",
|
||||
BufferSize: 10,
|
||||
BatchSize: 5,
|
||||
FlushInterval: 50 * time.Millisecond,
|
||||
Kafka: auditor.KafkaConfig{
|
||||
Brokers: brokers,
|
||||
Topic: topic,
|
||||
ClientID: "signoz-test",
|
||||
Compression: compression,
|
||||
ShutdownTimeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
auditor, err := newProvider(context.Background(), instrumentationtest.New().ToProviderSettings(), config, nil, version.Info)
|
||||
require.NoError(t, err)
|
||||
|
||||
return auditor.(*provider)
|
||||
}
|
||||
|
||||
func newTestEvent(resource coretypes.Resource, action coretypes.Verb, body string) audittypes.AuditEvent {
|
||||
return audittypes.AuditEvent{
|
||||
Timestamp: time.Now(),
|
||||
Body: body,
|
||||
EventName: audittypes.NewEventName(action),
|
||||
AuditAttributes: audittypes.AuditAttributes{
|
||||
Action: action,
|
||||
Outcome: audittypes.OutcomeSuccess,
|
||||
},
|
||||
ResourceAttributes: audittypes.ResourceAttributes{
|
||||
Resource: resource,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Polls the topic until the consumed records hold wantLogRecords audit log records in total.
|
||||
func consumeRecords(t *testing.T, brokers []string, topic string, wantLogRecords int) []*kgo.Record {
|
||||
t.Helper()
|
||||
|
||||
client, err := kgo.NewClient(
|
||||
kgo.SeedBrokers(brokers...),
|
||||
kgo.ConsumeTopics(topic),
|
||||
kgo.ConsumeResetOffset(kgo.NewOffset().AtStart()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var records []*kgo.Record
|
||||
gotLogRecords := 0
|
||||
for gotLogRecords < wantLogRecords {
|
||||
fetches := client.PollFetches(ctx)
|
||||
require.NoError(t, ctx.Err(), "timed out waiting for %d log records, got %d", wantLogRecords, gotLogRecords)
|
||||
require.Empty(t, fetches.Errors())
|
||||
|
||||
for _, record := range fetches.Records() {
|
||||
logs, err := (&plog.ProtoUnmarshaler{}).UnmarshalLogs(record.Value)
|
||||
require.NoError(t, err)
|
||||
gotLogRecords += logs.LogRecordCount()
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
func TestExport(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
compression string
|
||||
compressionType uint8
|
||||
}{
|
||||
{name: "CompressionNone", compression: "none", compressionType: 0},
|
||||
{name: "CompressionGzip", compression: "gzip", compressionType: 1},
|
||||
{name: "CompressionSnappy", compression: "snappy", compressionType: 2},
|
||||
{name: "CompressionLz4", compression: "lz4", compressionType: 3},
|
||||
{name: "CompressionZstd", compression: "zstd", compressionType: 4},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
cluster := newTestCluster(t, "tenant_audit")
|
||||
provider := newTestProvider(t, cluster.ListenAddrs(), "tenant_audit", testCase.compression)
|
||||
defer provider.client.Close()
|
||||
|
||||
events := []audittypes.AuditEvent{
|
||||
newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"),
|
||||
newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate, "dashboard created"),
|
||||
newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbDelete, "dashboard deleted"),
|
||||
}
|
||||
require.NoError(t, provider.export(context.Background(), events))
|
||||
|
||||
records := consumeRecords(t, cluster.ListenAddrs(), "tenant_audit", len(events))
|
||||
require.Len(t, records, 1)
|
||||
assert.Equal(t, "tenant_audit", records[0].Topic)
|
||||
assert.Equal(t, testCase.compressionType, records[0].Attrs.CompressionType())
|
||||
|
||||
logs, err := (&plog.ProtoUnmarshaler{}).UnmarshalLogs(records[0].Value)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(events), logs.LogRecordCount())
|
||||
|
||||
serviceName, ok := logs.ResourceLogs().At(0).Resource().Attributes().Get("service.name")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "signoz", serviceName.Str())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExport_RecordTooLarge(t *testing.T) {
|
||||
cluster := newTestCluster(t, "large_audit")
|
||||
provider := newTestProvider(t, cluster.ListenAddrs(), "large_audit", "none")
|
||||
defer provider.client.Close()
|
||||
|
||||
event := newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, strings.Repeat("a", 2<<20))
|
||||
|
||||
err := provider.export(context.Background(), []audittypes.AuditEvent{event})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, kerr.MessageTooLarge))
|
||||
}
|
||||
|
||||
func TestExport_UnknownTopic(t *testing.T) {
|
||||
cluster := newTestCluster(t)
|
||||
provider := newTestProvider(t, cluster.ListenAddrs(), "missing_audit", "lz4")
|
||||
defer provider.client.Close()
|
||||
|
||||
err := provider.export(context.Background(), []audittypes.AuditEvent{newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, "user created")})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, kerr.UnknownTopicOrPartition))
|
||||
}
|
||||
|
||||
func TestExport_BrokerDown_RetriesUntilContextDone(t *testing.T) {
|
||||
cluster := newTestCluster(t, "down_audit")
|
||||
brokers := cluster.ListenAddrs()
|
||||
cluster.Close()
|
||||
|
||||
provider := newTestProvider(t, brokers, "down_audit", "lz4")
|
||||
defer provider.client.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
err := provider.export(ctx, []audittypes.AuditEvent{newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, "user created")})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, context.DeadlineExceeded))
|
||||
assert.GreaterOrEqual(t, time.Since(start), 500*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestStop_DrainsQueuedEvents(t *testing.T) {
|
||||
cluster := newTestCluster(t, "drain_audit")
|
||||
provider := newTestProvider(t, cluster.ListenAddrs(), "drain_audit", "lz4")
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- provider.Start(context.Background()) }()
|
||||
<-provider.Healthy()
|
||||
|
||||
for range 7 {
|
||||
provider.server.Add(context.Background(), newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"))
|
||||
}
|
||||
|
||||
require.NoError(t, provider.Stop(context.Background()))
|
||||
require.NoError(t, <-done)
|
||||
|
||||
records := consumeRecords(t, cluster.ListenAddrs(), "drain_audit", 7)
|
||||
assert.NotEmpty(t, records)
|
||||
}
|
||||
|
||||
func TestStop_BrokerDown_ClosesAfterShutdownTimeout(t *testing.T) {
|
||||
cluster := newTestCluster(t, "stuck_audit")
|
||||
brokers := cluster.ListenAddrs()
|
||||
cluster.Close()
|
||||
|
||||
provider := newTestProvider(t, brokers, "stuck_audit", "lz4")
|
||||
provider.config.Kafka.ShutdownTimeout = 500 * time.Millisecond
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- provider.Start(context.Background()) }()
|
||||
<-provider.Healthy()
|
||||
|
||||
for range 7 {
|
||||
provider.server.Add(context.Background(), newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"))
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
require.NoError(t, provider.Stop(context.Background()))
|
||||
require.NoError(t, <-done)
|
||||
assert.GreaterOrEqual(t, time.Since(start), 500*time.Millisecond)
|
||||
assert.Less(t, time.Since(start), 5*time.Second)
|
||||
}
|
||||
@@ -34,7 +34,8 @@ type AnomalyRule struct {
|
||||
|
||||
provider anomaly.Provider
|
||||
|
||||
logger *slog.Logger
|
||||
version string
|
||||
logger *slog.Logger
|
||||
|
||||
seasonality anomaly.Seasonality
|
||||
}
|
||||
@@ -62,6 +63,7 @@ func NewAnomalyRule(
|
||||
r := AnomalyRule{
|
||||
BaseRule: baseRule,
|
||||
querier: querier,
|
||||
version: p.Version,
|
||||
logger: logger.With(slog.String("rule.id", id)),
|
||||
}
|
||||
|
||||
@@ -103,15 +105,15 @@ func (r *AnomalyRule) Type() ruletypes.RuleType {
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
|
||||
startTs, endTs := r.Timestamps(ts)
|
||||
start, end := startTs.UnixMilli(), endTs.UnixMilli()
|
||||
|
||||
r.logger.InfoContext(
|
||||
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
|
||||
slog.Int64("eval.window_ms", end-start),
|
||||
slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()),
|
||||
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
|
||||
)
|
||||
|
||||
startTs, endTs := r.Timestamps(ts)
|
||||
start, end := startTs.UnixMilli(), endTs.UnixMilli()
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(start),
|
||||
End: uint64(end),
|
||||
@@ -418,10 +420,12 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
func (r *AnomalyRule) String() string {
|
||||
|
||||
ar := ruletypes.PostableRule{
|
||||
AlertName: valuer.NewDisplayName(r.Name()),
|
||||
RuleCondition: r.Condition(),
|
||||
Labels: r.Labels().Map(),
|
||||
Annotations: r.Annotations().Map(),
|
||||
AlertName: valuer.NewDisplayName(r.Name()),
|
||||
RuleCondition: r.Condition(),
|
||||
EvalWindow: r.EvalWindow(),
|
||||
Labels: r.Labels().Map(),
|
||||
Annotations: r.Annotations().Map(),
|
||||
PreferredChannels: r.PreferredChannels(),
|
||||
}
|
||||
|
||||
byt, err := json.Marshal(ar)
|
||||
|
||||
@@ -54,6 +54,9 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &target,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
@@ -154,7 +157,10 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
AlertOnAbsent: true,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
AlertOnAbsent: true,
|
||||
Target: &target,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
|
||||
@@ -11,7 +11,7 @@ const config: Config.InitialOptions = {
|
||||
coverageReporters: ['text', 'cobertura', 'html', 'json-summary'],
|
||||
collectCoverageFrom: ['src/**/*.{ts,tsx}'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
|
||||
modulePathIgnorePatterns: ['dist'],
|
||||
modulePathIgnorePatterns: ['<rootDir>/dist/'],
|
||||
moduleNameMapper: {
|
||||
'\\.(png|jpg|jpeg|gif|svg|webp|avif|ico|bmp|tiff)$':
|
||||
'<rootDir>/__mocks__/fileMock.ts',
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { ErrorEvent, StackFrame } from '@sentry/react';
|
||||
import { AxiosError, AxiosHeaders } from 'axios';
|
||||
|
||||
import {
|
||||
hasNoAppFrames,
|
||||
isExpectedRequestFailure,
|
||||
isExternalIntegrationError,
|
||||
SENTRY_IGNORE_ERRORS,
|
||||
} from '../sentryFilters';
|
||||
@@ -140,36 +138,3 @@ describe('SENTRY_IGNORE_ERRORS', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpectedRequestFailure', () => {
|
||||
function axiosErrorWithStatus(status: number): AxiosError {
|
||||
const config = { headers: new AxiosHeaders() };
|
||||
return new AxiosError(
|
||||
`Request failed with status code ${status}`,
|
||||
AxiosError.ERR_BAD_REQUEST,
|
||||
config,
|
||||
undefined,
|
||||
{ status, statusText: '', headers: {}, config, data: {} },
|
||||
);
|
||||
}
|
||||
|
||||
it.each([401, 403])('drops a %i response', (status) => {
|
||||
expect(isExpectedRequestFailure(axiosErrorWithStatus(status))).toBe(true);
|
||||
});
|
||||
|
||||
it('drops a network error', () => {
|
||||
expect(
|
||||
isExpectedRequestFailure(
|
||||
new AxiosError('Network Error', AxiosError.ERR_NETWORK),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([400, 404, 500, 503])('keeps a %i response', (status) => {
|
||||
expect(isExpectedRequestFailure(axiosErrorWithStatus(status))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an error that is not from axios', () => {
|
||||
expect(isExpectedRequestFailure(new Error('Network Error'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,6 @@ import defaultRoutes, {
|
||||
} from './routes';
|
||||
import {
|
||||
hasNoAppFrames,
|
||||
isExpectedRequestFailure,
|
||||
isExternalIntegrationError,
|
||||
SENTRY_IGNORE_ERRORS,
|
||||
} from './sentryFilters';
|
||||
@@ -342,9 +341,6 @@ function App(): JSX.Element {
|
||||
if (error?.name === 'AbortError') {
|
||||
return null;
|
||||
}
|
||||
if (isExpectedRequestFailure(hint?.originalException)) {
|
||||
return null;
|
||||
}
|
||||
// Ignore benign Monaco cancellation errors (name 'Canceled').
|
||||
if (error?.name === 'Canceled') {
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ErrorEvent } from '@sentry/react';
|
||||
import { AxiosError, isAxiosError } from 'axios';
|
||||
|
||||
export const SENTRY_IGNORE_ERRORS = [
|
||||
// A tab that outlived a deploy requests hashed assets the new build no longer
|
||||
@@ -15,18 +14,6 @@ export const SENTRY_IGNORE_ERRORS = [
|
||||
/Called on script loaded before session recording is available/,
|
||||
];
|
||||
|
||||
// Not UI bugs: 401/403 come from the session or role, ERR_NETWORK from a
|
||||
// dropped connection.
|
||||
export function isExpectedRequestFailure(error: unknown): boolean {
|
||||
if (!isAxiosError(error)) {
|
||||
return false;
|
||||
}
|
||||
const status = error.response?.status;
|
||||
return (
|
||||
error.code === AxiosError.ERR_NETWORK || status === 401 || status === 403
|
||||
);
|
||||
}
|
||||
|
||||
const APP_ASSETS_PATH = '/assets/';
|
||||
|
||||
// Frames without a position are `[native code]`, which says nothing about the
|
||||
|
||||
@@ -45,6 +45,7 @@ import type {
|
||||
GetRuleHistoryTopContributorsParams,
|
||||
GetRuleHistoryTopContributorsPathParameters,
|
||||
ListRuleViews200,
|
||||
ListRules200,
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
PatchRuleByID200,
|
||||
@@ -426,6 +427,85 @@ export const useUpdateRuleView = <
|
||||
> => {
|
||||
return useMutation(getUpdateRuleViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
export const listRules = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListRules200>({
|
||||
url: `/api/v2/rules`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListRulesQueryKey = () => {
|
||||
return [`/api/v2/rules`] as const;
|
||||
};
|
||||
|
||||
export const getListRulesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listRules>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof listRules>>, TError, TData>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListRulesQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRules>>> = ({
|
||||
signal,
|
||||
}) => listRules(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRules>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListRulesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listRules>>
|
||||
>;
|
||||
export type ListRulesQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
|
||||
export function useListRules<
|
||||
TData = Awaited<ReturnType<typeof listRules>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof listRules>>, TError, TData>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListRulesQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
export const invalidateListRules = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListRulesQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a new alert rule
|
||||
* @summary Create alert rule
|
||||
|
||||
@@ -829,26 +829,33 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
|
||||
jira = 'jira',
|
||||
}
|
||||
export interface AlertmanagertypesChannelJiraAuthDTO {
|
||||
export interface AlertmanagertypesChannelJiraCloudAuthDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
* @description The Atlassian API token paired with email. Required when deployment is cloud. A secret; returned by create, update and fetch by ID, never by list.
|
||||
* @description The Atlassian API token paired with email. A secret; returned by create, update and fetch by ID, never by list.
|
||||
*/
|
||||
apiToken?: string;
|
||||
apiToken: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The Atlassian account email used for basic auth. Required when deployment is cloud.
|
||||
* @description The Atlassian account email used for basic auth.
|
||||
*/
|
||||
email?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
* @description The Jira personal access token, sent as a Bearer header. Required when deployment is selfhosted. A secret; returned by create, update and fetch by ID, never by list.
|
||||
*/
|
||||
pat?: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelJiraSelfHostedAuthDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
* @description The Jira personal access token, sent as a Bearer header. A secret; returned by create, update and fetch by ID, never by list.
|
||||
*/
|
||||
pat: string;
|
||||
}
|
||||
|
||||
export type AlertmanagertypesChannelJiraAuthDTO =
|
||||
| AlertmanagertypesChannelJiraCloudAuthDTO
|
||||
| AlertmanagertypesChannelJiraSelfHostedAuthDTO;
|
||||
|
||||
export enum AlertmanagertypesJiraDeploymentDTO {
|
||||
cloud = 'cloud',
|
||||
selfhosted = 'selfhosted',
|
||||
@@ -3604,7 +3611,6 @@ export enum CoretypesKindDTO {
|
||||
'logs-field' = 'logs-field',
|
||||
'traces-field' = 'traces-field',
|
||||
'metrics-field' = 'metrics-field',
|
||||
'llm-pricing-rule' = 'llm-pricing-rule',
|
||||
}
|
||||
export enum CoretypesTypeDTO {
|
||||
user = 'user',
|
||||
@@ -6943,6 +6949,107 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesScatterPlotPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel kind.
|
||||
*/
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind {
|
||||
'signoz/DistributionPanel' = 'signoz/DistributionPanel',
|
||||
}
|
||||
export interface DashboardtypesDistributionXAxisDTO {
|
||||
/**
|
||||
* @type number,null
|
||||
* @description Buckets wholly above this value are hidden. Null shows them all.
|
||||
*/
|
||||
max?: number | null;
|
||||
/**
|
||||
* @type number,null
|
||||
* @description Buckets wholly below this value are hidden. Null shows them all.
|
||||
*/
|
||||
min?: number | null;
|
||||
}
|
||||
|
||||
export enum DashboardtypesDistributionYScaleDTO {
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
}
|
||||
export interface DashboardtypesDistributionYAxisDTO {
|
||||
/**
|
||||
* @description How counts are spaced: linear (default) or log.
|
||||
*/
|
||||
scale: DashboardtypesDistributionYScaleDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionAxesDTO {
|
||||
/**
|
||||
* @description The bucket axis.
|
||||
*/
|
||||
x?: DashboardtypesDistributionXAxisDTO;
|
||||
/**
|
||||
* @description The count axis.
|
||||
*/
|
||||
y?: DashboardtypesDistributionYAxisDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionPercentilesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Whether percentile markers are drawn. Defaults to true.
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description The percentiles to mark, each in (0, 100), at most 5, no duplicates. Defaults to [50, 90, 99].
|
||||
*/
|
||||
values: number[] | null;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionChartAppearanceDTO {
|
||||
/**
|
||||
* @description Percentile markers drawn across the bucket axis.
|
||||
*/
|
||||
percentiles?: DashboardtypesDistributionPercentilesDTO;
|
||||
/**
|
||||
* @description How groups combine in a bucket: normal (default) stacks them, none draws them side by side, percent stacks them as shares of the bucket.
|
||||
*/
|
||||
stack: DashboardtypesStackModeDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionPanelSpecDTO {
|
||||
/**
|
||||
* @description The X (bucket) axis range and Y (count) axis scale.
|
||||
*/
|
||||
axes?: DashboardtypesDistributionAxesDTO;
|
||||
/**
|
||||
* @description Stacking and percentile markers.
|
||||
*/
|
||||
chartAppearance?: DashboardtypesDistributionChartAppearanceDTO;
|
||||
/**
|
||||
* @description Unit and decimal precision of the bucket bounds; counts take no unit.
|
||||
*/
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
/**
|
||||
* @description Legend placement and series colors.
|
||||
*/
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @description The time range the bucket counts are summed over.
|
||||
*/
|
||||
visualization?: DashboardtypesBasicVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/DistributionPanel
|
||||
* @type string
|
||||
* @description The panel kind.
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind;
|
||||
/**
|
||||
* @description The options for this panel kind. {} accepts every default.
|
||||
*/
|
||||
spec: DashboardtypesDistributionPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -6954,7 +7061,8 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -7073,7 +7181,7 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
*/
|
||||
functions?: Querybuildertypesv5FunctionDTO[] | null;
|
||||
/**
|
||||
* @description A filter on the formula values, referencing the formula by its name or as __result, for example F1 > 0. A time series keeps only the points that match and drops a series with none left; a scalar result keeps only the rows that match.
|
||||
* @description Accepted but currently ignored; the computed values are returned unfiltered.
|
||||
*/
|
||||
having?: Querybuildertypesv5HavingDTO;
|
||||
/**
|
||||
@@ -7443,14 +7551,14 @@ export interface DashboardtypesQuerySpecDTO {
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @description The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery.
|
||||
* @description The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap and distribution panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery.
|
||||
*/
|
||||
plugin: DashboardtypesQueryPluginDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesQueryDTO {
|
||||
/**
|
||||
* @description The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, trace for trace panels.
|
||||
* @description The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, distribution for distribution panels, trace for trace panels.
|
||||
*/
|
||||
kind: Querybuildertypesv5RequestTypeDTO;
|
||||
/**
|
||||
@@ -7470,7 +7578,7 @@ export interface DashboardtypesPanelSpecDTO {
|
||||
*/
|
||||
links?: DashboardtypesLinkDTO[] | null;
|
||||
/**
|
||||
* @description The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel or signoz/HeatmapPanel, and spec holds that kind's options ({} accepts every default).
|
||||
* @description The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel, signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind's options ({} accepts every default).
|
||||
*/
|
||||
plugin: DashboardtypesPanelPluginDTO;
|
||||
/**
|
||||
@@ -8241,6 +8349,7 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
|
||||
'signoz/DistributionPanel' = 'signoz/DistributionPanel',
|
||||
}
|
||||
/**
|
||||
* The RFC 6902 operations, applied in order against the dashboard's postable shape.
|
||||
@@ -12960,10 +13069,23 @@ export interface RuletypesRuleConditionDTO {
|
||||
* @description Whether to fire a nodata alert when the query returns no data. Defaults to false.
|
||||
*/
|
||||
alertOnAbsent?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
* @description anomaly_rule only: the anomaly detection algorithm. The supported value is standard.
|
||||
*/
|
||||
algorithm?: string;
|
||||
/**
|
||||
* @description The queries whose result is compared against the thresholds.
|
||||
*/
|
||||
compositeQuery: RuletypesAlertCompositeQueryDTO;
|
||||
/**
|
||||
* @description schemaVersion v1 only: which points in the window must satisfy op. Accepted as at_least_once, all_the_times, on_average, in_total or last, their aliases avg and sum, or the legacy codes as strings, '1' to '5'. Required for v1; v2alpha1 sets matchType on each threshold.
|
||||
*/
|
||||
matchType?: RuletypesMatchTypeDTO;
|
||||
/**
|
||||
* @description schemaVersion v1 only: how the value is compared with target. Accepted as above, below, equal, not_equal, above_or_equal, below_or_equal or outside_bounds, their symbols, or the legacy codes as strings, '1' to '7'. Required for v1; v2alpha1 sets op on each threshold.
|
||||
*/
|
||||
op?: RuletypesCompareOperatorDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Whether a series is skipped until it has at least requiredNumPoints points in the window. Defaults to false.
|
||||
@@ -12983,6 +13105,17 @@ export interface RuletypesRuleConditionDTO {
|
||||
* @description The name of the query whose result is evaluated, such as A or F1. Must match a query in compositeQuery.
|
||||
*/
|
||||
selectedQueryName: string;
|
||||
/**
|
||||
* @type number,null
|
||||
* @format double
|
||||
* @description schemaVersion v1 only: the value compared against, in targetUnit. Required for v1; v2alpha1 sets target on each threshold.
|
||||
*/
|
||||
target?: number | null;
|
||||
/**
|
||||
* @type string
|
||||
* @description schemaVersion v1 only: the unit of target, such as percent or ms. Converted to compositeQuery.unit before comparison when the two differ.
|
||||
*/
|
||||
targetUnit?: string;
|
||||
/**
|
||||
* @description The thresholds to evaluate, as a kind and spec.
|
||||
*/
|
||||
@@ -13018,10 +13151,20 @@ export interface RuletypesPostableRuleDTO {
|
||||
* @description Whether the rule is paused. A disabled rule is neither evaluated nor notifies. Defaults to false.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
* @description The lookback window as a Go duration such as 5m. Only read when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec instead.
|
||||
*/
|
||||
evalWindow?: string;
|
||||
/**
|
||||
* @description The evaluation window, as a kind and spec.
|
||||
*/
|
||||
evaluation: RuletypesEvaluationEnvelopeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description How often the rule is evaluated, as a Go duration such as 1m. Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set it in evaluation.spec instead.
|
||||
*/
|
||||
frequency?: string;
|
||||
/**
|
||||
* @type object
|
||||
* @description Labels attached to every alert the rule fires. Keys match [a-zA-Z_.][a-zA-Z0-9_.]*; values may use Go templates such as {{$labels.service_name}}. severity is the conventional routing label.
|
||||
@@ -13031,6 +13174,11 @@ export interface RuletypesPostableRuleDTO {
|
||||
* @description Grouping, re-notification and routing behaviour.
|
||||
*/
|
||||
notificationSettings: RuletypesNotificationSettingsDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @description The displayName of each notification channel to notify, for schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries its own channels.
|
||||
*/
|
||||
preferredChannels?: string[];
|
||||
/**
|
||||
* @description The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql queries and is forced to promql_rule for promql queries.
|
||||
*/
|
||||
@@ -13041,6 +13189,16 @@ export interface RuletypesPostableRuleDTO {
|
||||
* @description The rule payload schema.
|
||||
*/
|
||||
schemaVersion: RuletypesPostableRuleDTOSchemaVersion;
|
||||
/**
|
||||
* @type string
|
||||
* @description The URL of the page the rule was created from. Informational; it is not used for evaluation.
|
||||
*/
|
||||
source?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The query payload version. Nothing reads it.
|
||||
*/
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesPostableRuleViewDTO {
|
||||
@@ -13105,10 +13263,20 @@ export interface RuletypesRuleDTO {
|
||||
* @description Whether the rule is paused. A disabled rule is neither evaluated nor notifies. Defaults to false.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
* @description The lookback window as a Go duration such as 5m. Only read when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec instead.
|
||||
*/
|
||||
evalWindow?: string;
|
||||
/**
|
||||
* @description The evaluation window, as a kind and spec.
|
||||
*/
|
||||
evaluation: RuletypesEvaluationEnvelopeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description How often the rule is evaluated, as a Go duration such as 1m. Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set it in evaluation.spec instead.
|
||||
*/
|
||||
frequency?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The unique identifier of the rule.
|
||||
@@ -13123,6 +13291,11 @@ export interface RuletypesRuleDTO {
|
||||
* @description Grouping, re-notification and routing behaviour.
|
||||
*/
|
||||
notificationSettings: RuletypesNotificationSettingsDTO;
|
||||
/**
|
||||
* @type array
|
||||
* @description The displayName of each notification channel to notify, for schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries its own channels.
|
||||
*/
|
||||
preferredChannels?: string[];
|
||||
/**
|
||||
* @description The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql queries and is forced to promql_rule for promql queries.
|
||||
*/
|
||||
@@ -13133,6 +13306,11 @@ export interface RuletypesRuleDTO {
|
||||
* @description The rule payload schema.
|
||||
*/
|
||||
schemaVersion: RuletypesRuleDTOSchemaVersion;
|
||||
/**
|
||||
* @type string
|
||||
* @description The URL of the page the rule was created from. Informational; it is not used for evaluation.
|
||||
*/
|
||||
source?: string;
|
||||
/**
|
||||
* @description The current evaluation state: inactive, pending, recovering, firing, nodata or disabled.
|
||||
*/
|
||||
@@ -13146,6 +13324,11 @@ export interface RuletypesRuleDTO {
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The query payload version. Nothing reads it.
|
||||
*/
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export enum RuletypesThresholdKindDTO {
|
||||
@@ -16703,6 +16886,17 @@ export type UpdateRuleView200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRules200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: RuletypesRuleDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateRule201 = {
|
||||
data: RuletypesRuleDTO;
|
||||
/**
|
||||
|
||||
@@ -11,8 +11,6 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
// generated API Instance
|
||||
const generatedAPIAxiosInstance = axios.create({
|
||||
baseURL: ENVIRONMENT.baseURL,
|
||||
// The backend binds repeated keys (`states=a&states=b`); axios' default sends `states[]=`.
|
||||
paramsSerializer: { indexes: null },
|
||||
});
|
||||
|
||||
let generatedAPIQueryKeyHeaderContext: Record<string, unknown> | undefined;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SuccessResponse, Warning } from 'types/api';
|
||||
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
import {
|
||||
BuilderQuery,
|
||||
DistributionData,
|
||||
MetricRangePayloadV5,
|
||||
QueryEnvelope,
|
||||
QueryRangeRequestV5,
|
||||
@@ -314,20 +313,6 @@ function convertRawData(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts V5 DistributionData to legacy format
|
||||
*/
|
||||
function convertDistributionData(
|
||||
distributionData: DistributionData,
|
||||
legendMap: Record<string, string>,
|
||||
): any {
|
||||
// Convert V5 distribution format to legacy histogram format
|
||||
return {
|
||||
...distributionData,
|
||||
legendMap,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to convert V5 data based on type
|
||||
*/
|
||||
@@ -375,15 +360,6 @@ function convertV5DataByType(
|
||||
result: traceData.map((trace) => convertRawData(trace, legendMap)),
|
||||
};
|
||||
}
|
||||
case 'distribution': {
|
||||
const distributionData = v5Data.data.results as DistributionData[];
|
||||
return {
|
||||
resultType: 'distribution',
|
||||
result: distributionData.map((distribution) =>
|
||||
convertDistributionData(distribution, legendMap),
|
||||
),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
resultType: '',
|
||||
|
||||
@@ -906,6 +906,10 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
});
|
||||
|
||||
describe('mapPanelTypeToRequestType', () => {
|
||||
it('sends a histogram as time_series, which it bins in the browser', () => {
|
||||
expect(mapPanelTypeToRequestType(PANEL_TYPES.HISTOGRAM)).toBe('time_series');
|
||||
});
|
||||
|
||||
it('sends a scatter plot as scalar, like a table', () => {
|
||||
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
|
||||
});
|
||||
|
||||
@@ -47,8 +47,11 @@ type PrepareQueryRangePayloadV5Result = {
|
||||
*/
|
||||
export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
|
||||
switch (panelType) {
|
||||
// Histogram bins a time series in the browser; `distribution` is the
|
||||
// Distribution panel's request type.
|
||||
case PANEL_TYPES.TIME_SERIES:
|
||||
case PANEL_TYPES.BAR:
|
||||
case PANEL_TYPES.HISTOGRAM:
|
||||
return 'time_series';
|
||||
case PANEL_TYPES.TABLE:
|
||||
case PANEL_TYPES.SCATTER:
|
||||
@@ -59,8 +62,6 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
|
||||
return 'trace';
|
||||
case PANEL_TYPES.LIST:
|
||||
return 'raw';
|
||||
case PANEL_TYPES.HISTOGRAM:
|
||||
return 'distribution';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect x="1.25" y="1.25" width="21.5" height="21.5" rx="3.5" fill="none" stroke="#7A8291" stroke-width="1.5"/><g fill="#7A8291"><circle cx="7" cy="4.5" r="1.05"/><circle cx="9.5" cy="4.5" r="1.05"/><circle cx="12" cy="4.5" r="1.05"/><circle cx="14.5" cy="4.5" r="1.05"/><circle cx="7" cy="7" r="1.05"/><circle cx="17" cy="7" r="1.05"/><circle cx="7" cy="9.5" r="1.05"/><circle cx="17" cy="9.5" r="1.05"/><circle cx="7" cy="12" r="1.05"/><circle cx="9.5" cy="12" r="1.05"/><circle cx="12" cy="12" r="1.05"/><circle cx="14.5" cy="12" r="1.05"/><circle cx="7" cy="14.5" r="1.05"/><circle cx="7" cy="17" r="1.05"/><circle cx="7" cy="19.5" r="1.05"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 726 B |
@@ -1,13 +1,18 @@
|
||||
import { Select } from 'antd';
|
||||
import { ConfigProvider, Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { UpdatedWindow } from '../dsl/types';
|
||||
import type { CreatorOption } from '../types';
|
||||
import { UPDATED_OPTIONS } from './constants';
|
||||
import MultiSelectFilter from './MultiSelectFilter';
|
||||
|
||||
import styles from './FilterZone.module.scss';
|
||||
|
||||
// The option checkbox already marks selection, so selected rows stay regular weight.
|
||||
const CREATOR_SELECT_THEME = {
|
||||
components: { Select: { optionSelectedFontWeight: 400 } },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
createdBy: string[];
|
||||
updated: UpdatedWindow;
|
||||
@@ -47,16 +52,36 @@ function FilterChips({
|
||||
|
||||
return (
|
||||
<div className={styles.chips}>
|
||||
<MultiSelectFilter
|
||||
value={createdBy}
|
||||
options={creatorOptionsData}
|
||||
placeholder="Created by"
|
||||
testId={`${testIdPrefix}-filter-created-by`}
|
||||
disabled={disabled}
|
||||
onClear={onClearCreatedBy}
|
||||
onChange={onCreatedByChange}
|
||||
onDropdownVisibleChange={runOnClose}
|
||||
/>
|
||||
<ConfigProvider theme={CREATOR_SELECT_THEME}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
allowClear
|
||||
className={cx(styles.select, styles.selectWide)}
|
||||
placeholder="Created by"
|
||||
value={createdBy}
|
||||
options={creatorOptionsData}
|
||||
optionFilterProp="label"
|
||||
maxTagCount={1}
|
||||
menuItemSelectedIcon={null}
|
||||
data-testid={`${testIdPrefix}-filter-created-by`}
|
||||
disabled={disabled}
|
||||
onClear={onClearCreatedBy}
|
||||
optionRender={(option): JSX.Element => (
|
||||
<div className={styles.creatorOption}>
|
||||
<div className={styles.creatorCheck}>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
value={createdBy.includes(option.value as string)}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.creatorLabel}>{option.label}</span>
|
||||
</div>
|
||||
)}
|
||||
onChange={(value): void => onCreatedByChange(value as string[])}
|
||||
onDropdownVisibleChange={runOnClose}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
<Select
|
||||
showSearch
|
||||
className={cx(styles.select, styles.selectNarrow)}
|
||||
|
||||
@@ -86,21 +86,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-select option: a presentational checkbox + label (antd handles the toggle).
|
||||
.checkOption {
|
||||
// Created-by option: a presentational checkbox + label (antd handles the toggle).
|
||||
.creatorOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.checkOptionBox {
|
||||
.creatorCheck {
|
||||
// Flex, not block: a line box would shift with the check mark's baseline.
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.checkOptionLabel {
|
||||
.creatorLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { ConfigProvider, Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './FilterZone.module.scss';
|
||||
|
||||
// The option checkbox already marks selection, so selected rows stay regular weight.
|
||||
const CHECKBOX_SELECT_THEME = {
|
||||
components: { Select: { optionSelectedFontWeight: 400 } },
|
||||
};
|
||||
|
||||
export interface MultiSelectFilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string[];
|
||||
options: MultiSelectFilterOption[];
|
||||
placeholder: string;
|
||||
testId: string;
|
||||
onChange: (values: string[]) => void;
|
||||
onClear?: () => void;
|
||||
onDropdownVisibleChange?: (open: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function MultiSelectFilter({
|
||||
value,
|
||||
options,
|
||||
placeholder,
|
||||
testId,
|
||||
onChange,
|
||||
onClear,
|
||||
onDropdownVisibleChange,
|
||||
disabled = false,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<ConfigProvider theme={CHECKBOX_SELECT_THEME}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
allowClear
|
||||
className={cx(styles.select, styles.selectWide)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
options={options}
|
||||
optionFilterProp="label"
|
||||
maxTagCount={1}
|
||||
menuItemSelectedIcon={null}
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
onClear={onClear}
|
||||
optionRender={(option): JSX.Element => (
|
||||
<div className={styles.checkOption}>
|
||||
<div className={styles.checkOptionBox}>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
value={value.includes(option.value as string)}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.checkOptionLabel}>{option.label}</span>
|
||||
</div>
|
||||
)}
|
||||
onChange={(next): void => onChange(next as string[])}
|
||||
onDropdownVisibleChange={onDropdownVisibleChange}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default MultiSelectFilter;
|
||||
@@ -5,6 +5,7 @@ import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
import { Tooltip, TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { isBucketCountingPanelType } from 'lib/query/bucketCountingPanelTypes';
|
||||
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
|
||||
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
|
||||
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
|
||||
@@ -99,7 +100,7 @@ function QueryAddOns({
|
||||
}
|
||||
|
||||
if (
|
||||
panelType === PANEL_TYPES.HEATMAP &&
|
||||
isBucketCountingPanelType(panelType) &&
|
||||
query.dataSource === DataSource.METRICS &&
|
||||
!HISTOGRAM_ATTRIBUTE_TYPES.has(query.aggregateAttribute?.type ?? '')
|
||||
) {
|
||||
|
||||
@@ -107,8 +107,8 @@ export const BASE_ADD_ONS = (
|
||||
|
||||
export const REDUCE_TO = toAddOn(QueryBuilderField.ReduceTo);
|
||||
|
||||
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
|
||||
// against, and no other panel type sends one. A histogram brings its own buckets.
|
||||
// Offered only by a heatmap or distribution over metrics: the bucket axis is what they
|
||||
// plot against, and no other panel type sends one. A histogram brings its own buckets.
|
||||
export const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
|
||||
@@ -48,59 +48,22 @@ export function QuerySearchV2Provider({
|
||||
}, [initialExpression, store]);
|
||||
|
||||
const isInitialized = useRef(false);
|
||||
const lastCommittedRef = useRef(committedExpression);
|
||||
useEffect(() => {
|
||||
if (isInitialized.current || !urlExpression) {
|
||||
return;
|
||||
}
|
||||
isInitialized.current = true;
|
||||
store
|
||||
.getState()
|
||||
.initializeFromUrl(
|
||||
getUserExpressionFromCombined(initialExpression, urlExpression),
|
||||
if (!isInitialized.current && urlExpression) {
|
||||
const cleanedExpression = getUserExpressionFromCombined(
|
||||
initialExpression,
|
||||
urlExpression,
|
||||
);
|
||||
store.getState().initializeFromUrl(cleanedExpression);
|
||||
isInitialized.current = true;
|
||||
}
|
||||
}, [urlExpression, initialExpression, store]);
|
||||
|
||||
// Back and Forward. Reads the browser's URL: after a write, nuqs can briefly
|
||||
// report the value it replaced.
|
||||
useEffect(() => {
|
||||
const onPopState = (): void => {
|
||||
isInitialized.current = true;
|
||||
const urlValue = new URLSearchParams(window.location.search).get(
|
||||
queryParamKey,
|
||||
);
|
||||
const expression = urlValue
|
||||
? getUserExpressionFromCombined(initialExpression, urlValue)
|
||||
: '';
|
||||
// Another param changed; keep any text typed but not run.
|
||||
if (expression === store.getState().committedExpression) {
|
||||
return;
|
||||
}
|
||||
// Already in the URL, so the write below must not run for it.
|
||||
lastCommittedRef.current = expression;
|
||||
if (expression) {
|
||||
store.getState().initializeFromUrl(expression);
|
||||
} else {
|
||||
store.getState().resetExpression();
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', onPopState);
|
||||
return (): void => window.removeEventListener('popstate', onPopState);
|
||||
}, [queryParamKey, initialExpression, store]);
|
||||
|
||||
// Write only when the committed filter changes, never in reply to a URL change.
|
||||
useEffect(() => {
|
||||
if (committedExpression === lastCommittedRef.current) {
|
||||
return;
|
||||
if (isInitialized.current || !urlExpression) {
|
||||
setUrlExpression(committedExpression || null);
|
||||
}
|
||||
lastCommittedRef.current = committedExpression;
|
||||
isInitialized.current = true;
|
||||
const next = committedExpression || null;
|
||||
if (next === urlExpression) {
|
||||
return;
|
||||
}
|
||||
void setUrlExpression(next);
|
||||
}, [committedExpression, urlExpression, setUrlExpression]);
|
||||
}, [committedExpression, setUrlExpression, urlExpression]);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { act, renderHook } from 'tests/test-utils';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
useExpression,
|
||||
useInitialExpression,
|
||||
useInputExpression,
|
||||
useQuerySearchInitialExpressionProp,
|
||||
useQuerySearchOnChange,
|
||||
useQuerySearchOnRun,
|
||||
@@ -35,22 +34,10 @@ function createWrapper(
|
||||
};
|
||||
}
|
||||
|
||||
// Back or Forward. nuqs has not caught up yet, so its value is still the old one.
|
||||
function goTo(expression: string | null): void {
|
||||
act(() => {
|
||||
const search = expression
|
||||
? `?${new URLSearchParams({ testExpression: expression })}`
|
||||
: '';
|
||||
window.history.pushState(null, '', `/${search}`);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
}
|
||||
|
||||
function useTestHooks(): {
|
||||
expression: string;
|
||||
userExpression: string;
|
||||
initialExpression: string;
|
||||
inputExpression: string;
|
||||
querySearchInitialExpressionProp: string | undefined;
|
||||
onChange: (expr: string) => void;
|
||||
onRun: (expr: string) => void;
|
||||
@@ -59,7 +46,6 @@ function useTestHooks(): {
|
||||
expression: useExpression(),
|
||||
userExpression: useUserExpression(),
|
||||
initialExpression: useInitialExpression(),
|
||||
inputExpression: useInputExpression(),
|
||||
querySearchInitialExpressionProp: useQuerySearchInitialExpressionProp(),
|
||||
onChange: useQuerySearchOnChange(),
|
||||
onRun: useQuerySearchOnRun(),
|
||||
@@ -70,7 +56,6 @@ describe('QuerySearchExpressionProvider', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUrlValue = null;
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
it('should provide initial context values', () => {
|
||||
@@ -125,96 +110,6 @@ describe('QuerySearchExpressionProvider', () => {
|
||||
expect(result.current.expression).toBe('status = 500');
|
||||
});
|
||||
|
||||
it('follows Back and Forward from the browser URL', () => {
|
||||
mockUrlValue = 'status = 500';
|
||||
const { result } = renderHook(() => useTestHooks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
goTo('status = 404');
|
||||
|
||||
expect(result.current.userExpression).toBe('status = 404');
|
||||
expect(mockSetQueryState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the filter on Back to a URL without the param', () => {
|
||||
mockUrlValue = 'status = 500';
|
||||
const { result } = renderHook(() => useTestHooks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
goTo(null);
|
||||
|
||||
expect(result.current.userExpression).toBe('');
|
||||
expect(mockSetQueryState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps text typed but not run when Back changes another param', () => {
|
||||
mockUrlValue = 'status = 500';
|
||||
const { result } = renderHook(() => useTestHooks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
act(() => {
|
||||
result.current.onChange('status = 404');
|
||||
});
|
||||
|
||||
goTo('status = 500');
|
||||
|
||||
expect(result.current.inputExpression).toBe('status = 404');
|
||||
});
|
||||
|
||||
it('writes a run filter to the URL once and ignores it coming back', () => {
|
||||
const { result, rerender } = renderHook(() => useTestHooks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onRun('status = 500');
|
||||
});
|
||||
expect(mockSetQueryState).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetQueryState).toHaveBeenLastCalledWith('status = 500');
|
||||
|
||||
mockUrlValue = 'status = 500';
|
||||
rerender();
|
||||
|
||||
expect(result.current.userExpression).toBe('status = 500');
|
||||
expect(mockSetQueryState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps a run filter when the URL briefly reports the value it replaced', () => {
|
||||
mockUrlValue = 'status = 500';
|
||||
const { result, rerender } = renderHook(() => useTestHooks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onRun('status = 404');
|
||||
});
|
||||
mockUrlValue = 'status = 404';
|
||||
rerender();
|
||||
mockUrlValue = 'status = 500';
|
||||
rerender();
|
||||
|
||||
expect(result.current.userExpression).toBe('status = 404');
|
||||
});
|
||||
|
||||
it('keeps a newer run when a late URL value arrives after an empty mount', () => {
|
||||
const { result, rerender } = renderHook(() => useTestHooks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onRun('status = 500');
|
||||
});
|
||||
act(() => {
|
||||
result.current.onRun('status = 404');
|
||||
});
|
||||
mockUrlValue = 'status = 500';
|
||||
rerender();
|
||||
|
||||
expect(result.current.userExpression).toBe('status = 404');
|
||||
});
|
||||
|
||||
it('should throw error when used outside provider', () => {
|
||||
expect(() => {
|
||||
renderHook(() => useExpression());
|
||||
|
||||
@@ -15,41 +15,11 @@ $max-recents-shown: 5;
|
||||
Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', sans-serif;
|
||||
|
||||
.query-where-clause-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
// single-line editor: 36px .cm-line + 1px .cm-content border on each side
|
||||
--button-height: 38px;
|
||||
|
||||
.query-where-clause-editor-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.query-where-clause-editor-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
// The field draws one outline around the scope chip and the editor, so
|
||||
// the editor inside draws none
|
||||
&.hasScopeChip {
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding-left: var(--spacing-3);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l1-background);
|
||||
--query-builder-v2-border-color: transparent;
|
||||
--query-builder-v2-background-color: transparent;
|
||||
|
||||
.query-where-clause-editor {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.query-search-initial-scope-label {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
CircleCheck,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
Filter,
|
||||
Play,
|
||||
} from '@signozhq/icons';
|
||||
import { CircleCheck, Info, TriangleAlert, Filter } from '@signozhq/icons';
|
||||
import {
|
||||
autocompletion,
|
||||
closeCompletion,
|
||||
@@ -22,7 +16,6 @@ import { githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
|
||||
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button as SignozButton } from '@signozhq/ui/button';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
negationQueryOperatorSuggestions,
|
||||
@@ -66,7 +59,6 @@ import {
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS,
|
||||
SUGGESTIONS_SECTION,
|
||||
} from './constants';
|
||||
import { ScopeChip } from './ScopeChip/ScopeChip';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
dedupeOptionsByLabel,
|
||||
@@ -113,14 +105,9 @@ interface QuerySearchProps {
|
||||
signalSource?: string;
|
||||
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
|
||||
onRun?: (query: string) => void;
|
||||
showRunButton?: boolean;
|
||||
/** Shows the Run button busy while the page's queries fetch. */
|
||||
isLoadingQueries?: boolean;
|
||||
showFilterSuggestionsWithoutMetric?: boolean;
|
||||
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
|
||||
initialExpression?: string;
|
||||
/** Shows `initialExpression` as a locked chip in the field instead of the scope icon. */
|
||||
showScopeChip?: boolean;
|
||||
/** When set, replaces the generic value-suggestion API with a custom fetcher. */
|
||||
valueSuggestionsOverride?: (
|
||||
key: string,
|
||||
@@ -138,13 +125,10 @@ function QuerySearch({
|
||||
queryData,
|
||||
dataSource,
|
||||
onRun,
|
||||
showRunButton,
|
||||
isLoadingQueries,
|
||||
signalSource,
|
||||
hardcodedAttributeKeys,
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
initialExpression,
|
||||
showScopeChip: showScopeChipProp,
|
||||
metricNamespace,
|
||||
valueSuggestionsOverride,
|
||||
}: QuerySearchProps): JSX.Element {
|
||||
@@ -164,7 +148,6 @@ function QuerySearch({
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
|
||||
const isScopedFilter = initialExpression !== undefined;
|
||||
const showScopeChip = !!showScopeChipProp && !!initialExpression;
|
||||
|
||||
const validateExpressionForEditor = useCallback(
|
||||
(editorDoc: string): void => {
|
||||
@@ -190,18 +173,6 @@ function QuerySearch({
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRun = useCallback((): void => {
|
||||
if (!onRun) {
|
||||
return;
|
||||
}
|
||||
const user = getCurrentExpression();
|
||||
onRun(
|
||||
isScopedFilter
|
||||
? combineInitialAndUserExpression(initialExpression ?? '', user)
|
||||
: user,
|
||||
);
|
||||
}, [onRun, getCurrentExpression, isScopedFilter, initialExpression]);
|
||||
|
||||
const updateEditorValue = useCallback(
|
||||
(value: string, options: { skipOnChange?: boolean } = {}): void => {
|
||||
const view = editorRef.current;
|
||||
@@ -1586,182 +1557,169 @@ function QuerySearch({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="query-where-clause-row">
|
||||
<div
|
||||
className={cx('query-where-clause-editor-container', {
|
||||
hasScopeChip: showScopeChip,
|
||||
})}
|
||||
>
|
||||
{showScopeChip && <ScopeChip expression={initialExpression ?? ''} />}
|
||||
{isScopedFilter && !showScopeChip ? (
|
||||
<Tooltip title={initialExpression || ''} placement="left">
|
||||
<div className="query-search-initial-scope-label">
|
||||
<Filter
|
||||
size={14}
|
||||
style={{
|
||||
opacity: 0.9,
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip
|
||||
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
|
||||
placement="left"
|
||||
>
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/search-syntax/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: validation.isValid === false && getCurrentExpression() ? 40 : 8, // Move left when error shown
|
||||
cursor: 'help',
|
||||
zIndex: 10,
|
||||
transition: 'right 0.2s ease',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
color: '#8c8c8c',
|
||||
}}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<Info
|
||||
<div className="query-where-clause-editor-container">
|
||||
{isScopedFilter ? (
|
||||
<Tooltip title={initialExpression || ''} placement="left">
|
||||
<div className="query-search-initial-scope-label">
|
||||
<Filter
|
||||
size={14}
|
||||
style={{
|
||||
opacity: 0.9,
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip
|
||||
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
|
||||
placement="left"
|
||||
>
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/search-syntax/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: validation.isValid === false && getCurrentExpression() ? 40 : 8, // Move left when error shown
|
||||
cursor: 'help',
|
||||
zIndex: 10,
|
||||
transition: 'right 0.2s ease',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
color: '#8c8c8c',
|
||||
}}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<Info
|
||||
size={14}
|
||||
style={{
|
||||
opacity: 0.9,
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
</Tooltip>
|
||||
|
||||
<CodeMirror
|
||||
theme={isDarkMode ? copilot : githubLight}
|
||||
onChange={handleChange}
|
||||
onUpdate={handleUpdate}
|
||||
onCreateEditor={handleEditorCreate}
|
||||
data-testid="query-where-clause-editor"
|
||||
className={cx('query-where-clause-editor', {
|
||||
isValid: validation.isValid === true,
|
||||
<CodeMirror
|
||||
theme={isDarkMode ? copilot : githubLight}
|
||||
onChange={handleChange}
|
||||
onUpdate={handleUpdate}
|
||||
onCreateEditor={handleEditorCreate}
|
||||
data-testid="query-where-clause-editor"
|
||||
className={cx('query-where-clause-editor', {
|
||||
isValid: validation.isValid === true,
|
||||
hasErrors: validation.errors.length > 0,
|
||||
hasInitialExpression: isScopedFilter,
|
||||
})}
|
||||
extensions={[
|
||||
autocompletion({
|
||||
override: [combinedSuggestions],
|
||||
defaultKeymap: true,
|
||||
closeOnBlur: true,
|
||||
activateOnTyping: true,
|
||||
maxRenderedOptions: 50,
|
||||
addToOptions: [{ render: renderRecentDeleteButton, position: 100 }],
|
||||
}),
|
||||
javascript({ jsx: false, typescript: false }),
|
||||
EditorView.lineWrapping,
|
||||
stopEventsExtension,
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
...completionKeymap,
|
||||
{
|
||||
key: 'Escape',
|
||||
run: closeCompletion,
|
||||
},
|
||||
{
|
||||
key: 'Enter',
|
||||
preventDefault: true,
|
||||
// Prevent default behavior of Enter to add new line
|
||||
// and instead run a custom action
|
||||
run: (): boolean => true,
|
||||
},
|
||||
{
|
||||
key: 'Mod-Enter',
|
||||
preventDefault: true,
|
||||
// Prevent default behavior of Mod-Enter to add new line
|
||||
// and instead run a custom action
|
||||
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
|
||||
run: (): boolean => {
|
||||
if (onRun && typeof onRun === 'function') {
|
||||
const user = getCurrentExpression();
|
||||
onRun(
|
||||
isScopedFilter
|
||||
? combineInitialAndUserExpression(initialExpression ?? '', user)
|
||||
: user,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Shift-Enter',
|
||||
preventDefault: true,
|
||||
// Prevent default behavior of Shift-Enter to add new line
|
||||
run: (): boolean => true,
|
||||
},
|
||||
]),
|
||||
),
|
||||
]}
|
||||
placeholder={placeholder}
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
}}
|
||||
onFocus={(): void => {
|
||||
setIsFocused(true);
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
{getCurrentExpression() && validation.isValid === false && !isFocused && (
|
||||
<div
|
||||
className={cx('query-status-container', {
|
||||
hasErrors: validation.errors.length > 0,
|
||||
hasInitialExpression: isScopedFilter && !showScopeChip,
|
||||
})}
|
||||
extensions={[
|
||||
autocompletion({
|
||||
override: [combinedSuggestions],
|
||||
defaultKeymap: true,
|
||||
closeOnBlur: true,
|
||||
activateOnTyping: true,
|
||||
maxRenderedOptions: 50,
|
||||
addToOptions: [{ render: renderRecentDeleteButton, position: 100 }],
|
||||
}),
|
||||
javascript({ jsx: false, typescript: false }),
|
||||
EditorView.lineWrapping,
|
||||
stopEventsExtension,
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
...completionKeymap,
|
||||
{
|
||||
key: 'Escape',
|
||||
run: closeCompletion,
|
||||
},
|
||||
{
|
||||
key: 'Enter',
|
||||
preventDefault: true,
|
||||
// Prevent default behavior of Enter to add new line
|
||||
// and instead run a custom action
|
||||
run: (): boolean => true,
|
||||
},
|
||||
{
|
||||
key: 'Mod-Enter',
|
||||
preventDefault: true,
|
||||
// Prevent default behavior of Mod-Enter to add new line
|
||||
// and instead run a custom action
|
||||
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
|
||||
run: (): boolean => {
|
||||
handleRun();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Shift-Enter',
|
||||
preventDefault: true,
|
||||
// Prevent default behavior of Shift-Enter to add new line
|
||||
run: (): boolean => true,
|
||||
},
|
||||
]),
|
||||
),
|
||||
]}
|
||||
placeholder={placeholder}
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
}}
|
||||
onFocus={(): void => {
|
||||
setIsFocused(true);
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
{getCurrentExpression() && validation.isValid === false && !isFocused && (
|
||||
<div
|
||||
className={cx('query-status-container', {
|
||||
hasErrors: validation.errors.length > 0,
|
||||
})}
|
||||
data-testid="query-validation-status"
|
||||
>
|
||||
<Popover
|
||||
placement="bottomRight"
|
||||
showArrow={false}
|
||||
content={
|
||||
<div className="query-status-content">
|
||||
<div className="query-status-content-header">
|
||||
<div className="query-validation">
|
||||
<div className="query-validation-errors">
|
||||
{validation.errors.map((error) => (
|
||||
<div key={error.message} className="query-validation-error">
|
||||
<div className="query-validation-error">
|
||||
{error.line}:{error.column} - {error.message}
|
||||
</div>
|
||||
data-testid="query-validation-status"
|
||||
>
|
||||
<Popover
|
||||
placement="bottomRight"
|
||||
showArrow={false}
|
||||
content={
|
||||
<div className="query-status-content">
|
||||
<div className="query-status-content-header">
|
||||
<div className="query-validation">
|
||||
<div className="query-validation-errors">
|
||||
{validation.errors.map((error) => (
|
||||
<div key={error.message} className="query-validation-error">
|
||||
<div className="query-validation-error">
|
||||
{error.line}:{error.column} - {error.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
overlayClassName="query-status-popover"
|
||||
>
|
||||
{validation.isValid ? (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CircleCheck size="md" />}
|
||||
className="periscope-btn ghost"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<TriangleAlert size={14} color={Color.BG_CHERRY_500} />}
|
||||
className="periscope-btn ghost"
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showRunButton && (
|
||||
<SignozButton
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Play size={14} />}
|
||||
loading={isLoadingQueries}
|
||||
onClick={handleRun}
|
||||
testId="query-search-run-button"
|
||||
>
|
||||
Run
|
||||
</SignozButton>
|
||||
</div>
|
||||
}
|
||||
overlayClassName="query-status-popover"
|
||||
>
|
||||
{validation.isValid ? (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CircleCheck size="md" />}
|
||||
className="periscope-btn ghost"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<TriangleAlert size={14} color={Color.BG_CHERRY_500} />}
|
||||
className="periscope-btn ghost"
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1855,15 +1813,12 @@ function QuerySearch({
|
||||
|
||||
QuerySearch.defaultProps = {
|
||||
onRun: undefined,
|
||||
showRunButton: false,
|
||||
isLoadingQueries: false,
|
||||
signalSource: '',
|
||||
hardcodedAttributeKeys: undefined,
|
||||
placeholder:
|
||||
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
|
||||
showFilterSuggestionsWithoutMetric: false,
|
||||
initialExpression: undefined,
|
||||
showScopeChip: false,
|
||||
};
|
||||
|
||||
export default QuerySearch;
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
.scopeChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 420px;
|
||||
padding: var(--spacing-1) var(--spacing-3);
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.scopeIcon {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.scopeMore {
|
||||
flex: none;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
// The editor beside it is CodeMirror on the copilot theme in dark and
|
||||
// githubLight in light; the chip borrows those token colours so one filter
|
||||
// does not read as two
|
||||
.scopeKey {
|
||||
color: #939da5;
|
||||
}
|
||||
|
||||
.scopeOperator {
|
||||
color: #939da5;
|
||||
}
|
||||
|
||||
.scopeValue {
|
||||
color: #5bec95;
|
||||
}
|
||||
|
||||
:global(.lightMode) {
|
||||
.scopeKey,
|
||||
.scopeOperator {
|
||||
color: #005cc5;
|
||||
}
|
||||
|
||||
.scopeValue {
|
||||
color: #032f62;
|
||||
}
|
||||
}
|
||||
|
||||
.scopeExpression {
|
||||
font-family: var(--font-mono, monospace);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Lock } from '@signozhq/icons';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
|
||||
import styles from './ScopeChip.module.scss';
|
||||
|
||||
interface ScopeChipProps {
|
||||
expression: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The locked half of the filter. It reads like the editor beside it: the last
|
||||
* clause carries the relation, the ones scoping it are counted instead.
|
||||
*/
|
||||
export function ScopeChip({ expression }: ScopeChipProps): JSX.Element {
|
||||
const clauses = useMemo(() => extractQueryPairs(expression), [expression]);
|
||||
const relation = clauses[clauses.length - 1];
|
||||
|
||||
return (
|
||||
<Tooltip title={expression} side="top">
|
||||
<span className={styles.scopeChip} data-testid="query-search-scope-chip">
|
||||
<Lock size={12} className={styles.scopeIcon} />
|
||||
<span className={styles.scopeExpression}>
|
||||
{relation ? (
|
||||
<>
|
||||
<span className={styles.scopeKey}>{relation.key}</span>
|
||||
<span className={styles.scopeOperator}>{` ${relation.operator} `}</span>
|
||||
<span className={styles.scopeValue}>{relation.value}</span>
|
||||
</>
|
||||
) : (
|
||||
expression
|
||||
)}
|
||||
</span>
|
||||
{clauses.length > 1 && (
|
||||
<span className={styles.scopeMore}>{`+${clauses.length - 1}`}</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -224,134 +224,6 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||
await waitFor(() => expect(onRun).toHaveBeenCalled(), { timeout: 2000 });
|
||||
});
|
||||
|
||||
it('does not render the run button by default', () => {
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
onRun={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('query-search-run-button'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRun with the editor text when the run button is clicked', async () => {
|
||||
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={{
|
||||
...initialQueriesMap.logs.builder.queryData[0],
|
||||
filter: { expression: SAMPLE_STATUS_QUERY },
|
||||
}}
|
||||
dataSource={DataSource.LOGS}
|
||||
onRun={onRun}
|
||||
showRunButton
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector(CM_EDITOR_SELECTOR)?.textContent).toBe(
|
||||
SAMPLE_STATUS_QUERY,
|
||||
),
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('query-search-run-button'));
|
||||
|
||||
expect(onRun).toHaveBeenCalledWith(SAMPLE_STATUS_QUERY);
|
||||
});
|
||||
|
||||
it('shows the locked scope as a chip in place of the scope icon', () => {
|
||||
const scope = "kind_string = 'Client' AND http_host = 'api.example.com'";
|
||||
const { container } = render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.traces.builder.queryData[0]}
|
||||
dataSource={DataSource.TRACES}
|
||||
initialExpression={scope}
|
||||
showScopeChip
|
||||
/>,
|
||||
);
|
||||
|
||||
const chip = screen.getByTestId('query-search-scope-chip');
|
||||
expect(chip).toHaveTextContent("http_host = 'api.example.com'");
|
||||
expect(chip).toHaveTextContent('+1');
|
||||
expect(chip).not.toHaveTextContent('kind_string');
|
||||
expect(
|
||||
container.querySelector('.query-search-initial-scope-label'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the scope icon unless the chip is asked for', () => {
|
||||
const { container } = render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.traces.builder.queryData[0]}
|
||||
dataSource={DataSource.TRACES}
|
||||
initialExpression="kind_string = 'Client'"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('query-search-scope-chip'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector('.query-search-initial-scope-label'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the run button busy while the queries load', () => {
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={initialQueriesMap.logs.builder.queryData[0]}
|
||||
dataSource={DataSource.LOGS}
|
||||
onRun={jest.fn()}
|
||||
showRunButton
|
||||
isLoadingQueries
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('query-search-run-button')).toHaveAttribute(
|
||||
'aria-busy',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('runs the locked scope combined with the editor text', async () => {
|
||||
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
|
||||
const scope = "kind_string = 'Client'";
|
||||
|
||||
render(
|
||||
<QuerySearch
|
||||
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
|
||||
queryData={{
|
||||
...initialQueriesMap.traces.builder.queryData[0],
|
||||
filter: { expression: SAMPLE_STATUS_QUERY },
|
||||
}}
|
||||
dataSource={DataSource.TRACES}
|
||||
onRun={onRun}
|
||||
initialExpression={scope}
|
||||
showRunButton
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector(CM_EDITOR_SELECTOR)?.textContent).toBe(
|
||||
SAMPLE_STATUS_QUERY,
|
||||
),
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('query-search-run-button'));
|
||||
|
||||
expect(onRun).toHaveBeenCalledWith(`${scope} AND (${SAMPLE_STATUS_QUERY})`);
|
||||
});
|
||||
|
||||
it('initializes CodeMirror with expression from queryData.filter.expression on mount', async () => {
|
||||
const testExpression =
|
||||
"http.status_code >= 500 AND service.name = 'frontend'";
|
||||
|
||||
@@ -438,6 +438,24 @@ describe('QueryAddOns', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is offered on a metrics distribution too', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ dataSource: DataSource.METRICS })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.DISTRIBUTION}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('query-add-on-bucket_options'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is not offered on other panel types', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
|
||||
@@ -34,6 +34,7 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.HISTOGRAM]: Uplot,
|
||||
// V2-only kind; it renders through the V2 panel registry.
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
[PANEL_TYPES.DISTRIBUTION]: null,
|
||||
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.SCATTER]: null,
|
||||
|
||||
@@ -53,6 +53,7 @@ export enum QueryParams {
|
||||
variables = 'variables',
|
||||
version = 'version',
|
||||
source = 'source',
|
||||
showClassicCreateAlertsPage = 'showClassicCreateAlertsPage',
|
||||
isTestAlert = 'isTestAlert',
|
||||
yAxisUnit = 'yAxisUnit',
|
||||
ruleName = 'ruleName',
|
||||
|
||||
@@ -340,6 +340,7 @@ export enum PANEL_TYPES {
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
HEATMAP = 'heatmap',
|
||||
DISTRIBUTION = 'distribution',
|
||||
TEXT = 'text',
|
||||
EMPTY_WIDGET = 'EMPTY_WIDGET',
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export const REACT_QUERY_KEY = {
|
||||
REMOVE_ALERT_RULE: 'REMOVE_ALERT_RULE',
|
||||
CLONE_ALERT_RULE: 'CLONE_ALERT_RULE',
|
||||
GET_HOST_LIST: 'GET_HOST_LIST',
|
||||
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
|
||||
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
|
||||
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
|
||||
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
|
||||
@@ -69,6 +70,8 @@ export const REACT_QUERY_KEY = {
|
||||
GET_NESTED_ENDPOINTS_LIST: 'GET_NESTED_ENDPOINTS_LIST',
|
||||
GET_ENDPOINT_METRICS_DATA: 'GET_ENDPOINT_METRICS_DATA',
|
||||
GET_ENDPOINT_STATUS_CODE_DATA: 'GET_ENDPOINT_STATUS_CODE_DATA',
|
||||
GET_ENDPOINT_RATE_OVER_TIME_DATA: 'GET_ENDPOINT_RATE_OVER_TIME_DATA',
|
||||
GET_ENDPOINT_LATENCY_OVER_TIME_DATA: 'GET_ENDPOINT_LATENCY_OVER_TIME_DATA',
|
||||
GET_ENDPOINT_DROPDOWN_DATA: 'GET_ENDPOINT_DROPDOWN_DATA',
|
||||
GET_ENDPOINT_DEPENDENT_SERVICES_DATA: 'GET_ENDPOINT_DEPENDENT_SERVICES_DATA',
|
||||
GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA:
|
||||
|
||||
@@ -21,15 +21,14 @@ import {
|
||||
useListDashboardsForUserV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
getListRulesV3QueryKey,
|
||||
useListRulesV3,
|
||||
getListRulesQueryKey,
|
||||
useListRules,
|
||||
} from 'api/generated/services/rules';
|
||||
import type {
|
||||
DashboardtypesListedDashboardForUserV2DTO,
|
||||
ListDashboardsForUserV2200,
|
||||
ListDashboardsForUserV2Params,
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
ListRules200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useQueryService } from 'hooks/useQueryService';
|
||||
@@ -180,9 +179,6 @@ const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
|
||||
*/
|
||||
const DASHBOARD_LIST_PARAMS: ListDashboardsForUserV2Params = { limit: 1000 };
|
||||
|
||||
/** Same as dashboards. 200 is the most the rules list returns per call. */
|
||||
const ALERT_RULE_LIST_PARAMS: ListRulesV3Params = { limit: 200 };
|
||||
|
||||
function dashboardTitle(
|
||||
dashboard: DashboardtypesListedDashboardForUserV2DTO,
|
||||
): string {
|
||||
@@ -759,7 +755,7 @@ export default function ChatInput({
|
||||
data: alertsResponse,
|
||||
isLoading: isAlertsLoading,
|
||||
isError: isAlertsError,
|
||||
} = useListRulesV3(ALERT_RULE_LIST_PARAMS, {
|
||||
} = useListRules({
|
||||
query: {
|
||||
enabled: isContextPickerOpen && activeContextCategory === 'Alerts',
|
||||
staleTime: Infinity,
|
||||
@@ -808,10 +804,10 @@ export default function ChatInput({
|
||||
}
|
||||
}
|
||||
if (ctx.type === 'alert' && ctx.resourceId) {
|
||||
const cached = queryClient.getQueryData<ListRulesV3200>(
|
||||
getListRulesV3QueryKey(ALERT_RULE_LIST_PARAMS),
|
||||
const cached = queryClient.getQueryData<ListRules200>(
|
||||
getListRulesQueryKey(),
|
||||
);
|
||||
const rule = cached?.data.rules.find((r) => r.id === ctx.resourceId);
|
||||
const rule = cached?.data?.find((r) => r.id === ctx.resourceId);
|
||||
if (rule?.alert) {
|
||||
return rule.alert;
|
||||
}
|
||||
@@ -839,8 +835,8 @@ export default function ChatInput({
|
||||
value: dashboardTitle(dashboard),
|
||||
})) ?? [],
|
||||
Alerts:
|
||||
alertsResponse?.data.rules
|
||||
.filter((alertRule) => Boolean(alertRule.alert))
|
||||
alertsResponse?.data
|
||||
?.filter((alertRule) => Boolean(alertRule.alert))
|
||||
.map((alertRule) => ({
|
||||
id: alertRule.id,
|
||||
value: alertRule.alert,
|
||||
@@ -1063,7 +1059,6 @@ export default function ChatInput({
|
||||
color="secondary"
|
||||
size="sm"
|
||||
id={`ai-context-tab-${category}`}
|
||||
testId={`ai-context-tab-${category}`}
|
||||
// Single stable panel id shared by every tab: only the
|
||||
// active category's tabpanel is rendered, so per-category
|
||||
// `aria-controls` ids would point at nonexistent nodes
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useListDashboardsForUserV2: (): unknown => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useQueryService', () => ({
|
||||
useQueryService: (): unknown => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useSpeechRecognition', () => ({
|
||||
useSpeechRecognition: (): unknown => ({
|
||||
isListening: false,
|
||||
isSupported: false,
|
||||
permission: 'prompt',
|
||||
start: jest.fn(),
|
||||
discard: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
|
||||
useAIAssistantAnalyticsContext: (): unknown => ({
|
||||
threadId: undefined,
|
||||
page: '/',
|
||||
mode: 'sidepane',
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import ChatInput from '../ChatInput';
|
||||
|
||||
describe('ChatInput — alerts context picker', () => {
|
||||
it('lists the rules from one v3 rules request', async () => {
|
||||
const [first, second] = alertRulesFixture;
|
||||
const requests: string[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
requests.push(req.url.search);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response([first, second], req.url.searchParams)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ChatInput onSend={jest.fn()} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
|
||||
await userEvent.click(await screen.findByTestId('ai-context-tab-Alerts'));
|
||||
|
||||
await expect(screen.findByText(first.alert)).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText(second.alert)).toBeInTheDocument();
|
||||
expect(requests).toStrictEqual(['?limit=200']);
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,8 @@ jest.mock('api/generated/services/dashboard', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
useListRulesV3: (): unknown => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
getListRulesV3QueryKey: (): string[] => ['rules'],
|
||||
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
|
||||
getListRulesQueryKey: (): string[] => ['rules'],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useQueryService', () => ({
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import {
|
||||
getAllEndpointsWidgetData,
|
||||
getGroupByExpressionFromRowData,
|
||||
getGroupByFiltersFromGroupByValues,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import GridCard from 'container/WidgetCard/Card';
|
||||
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { ENDPOINT_STATS_EXPRESSION_KEY } from '../../../filterParamKeys';
|
||||
import { useApiMonitoringParams } from '../../../queryParams';
|
||||
import { SPAN_ATTRIBUTES, VIEWS } from './constants';
|
||||
import { useFilterExpression } from './hooks/useFilterExpression';
|
||||
import { useWidgetQueries } from './hooks/useWidgetQueries';
|
||||
|
||||
function AllEndPoints({
|
||||
domainName,
|
||||
@@ -27,6 +23,8 @@ function AllEndPoints({
|
||||
groupBy,
|
||||
setGroupBy,
|
||||
timeRange,
|
||||
initialFilters,
|
||||
setInitialFiltersEndPointStats,
|
||||
}: {
|
||||
domainName: string;
|
||||
setSelectedEndPointName: (name: string) => void;
|
||||
@@ -37,50 +35,113 @@ function AllEndPoints({
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
initialFilters: IBuilderQuery['filters'];
|
||||
setInitialFiltersEndPointStats: (filters: IBuilderQuery['filters']) => void;
|
||||
}): JSX.Element {
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
const [, setEndPointStatsExpression] = useQueryState(
|
||||
ENDPOINT_STATS_EXPRESSION_KEY,
|
||||
parseAsString,
|
||||
);
|
||||
const [groupBySearchValue, setGroupBySearchValue] = useState<string>('');
|
||||
const [allAvailableGroupByOptions, setAllAvailableGroupByOptions] = useState<{
|
||||
[key: string]: any;
|
||||
}>({});
|
||||
|
||||
const { data: groupByFiltersData, isLoading: isLoadingGroupByFilters } =
|
||||
useGetAggregateKeys({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateAttribute: '',
|
||||
aggregateOperator: 'noop',
|
||||
searchText: groupBySearchValue,
|
||||
tagType: '',
|
||||
});
|
||||
|
||||
const [groupByOptions, setGroupByOptions] = useState<
|
||||
{ value: string; label: string }[]
|
||||
>([]);
|
||||
|
||||
// --- FILTERS STATE SYNC ---
|
||||
const {
|
||||
expression: filterExpression,
|
||||
initialExpression: scopeExpression,
|
||||
queryData,
|
||||
onDraftChange,
|
||||
onRun,
|
||||
onBlur: handleFilterBlur,
|
||||
} = useFilterExpression({
|
||||
groupBy,
|
||||
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
|
||||
if (params.allEndpointsLocalFilters) {
|
||||
return params.allEndpointsLocalFilters;
|
||||
}
|
||||
// Initialize filters based on the initial endPointName prop
|
||||
const initialItems = [...(initialFilters?.items || [])];
|
||||
return { op: 'AND', items: initialItems };
|
||||
});
|
||||
|
||||
// Sync params to local filters state on param change
|
||||
useEffect(() => {
|
||||
if (
|
||||
params.allEndpointsLocalFilters &&
|
||||
!isEqual(params.allEndpointsLocalFilters, filters)
|
||||
) {
|
||||
setFilters(params.allEndpointsLocalFilters);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params.allEndpointsLocalFilters]);
|
||||
|
||||
// Handler for changes from the QueryBuilderSearchV2 component
|
||||
const handleFilterChange = useCallback(
|
||||
(newFilters: IBuilderQuery['filters']): void => {
|
||||
setFilters(newFilters);
|
||||
setParams({ allEndpointsLocalFilters: newFilters });
|
||||
},
|
||||
[setParams],
|
||||
);
|
||||
|
||||
const currentQuery = initialQueriesMap[DataSource.TRACES];
|
||||
|
||||
const updatedCurrentQuery = useMemo(
|
||||
() => ({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
dataSource: DataSource.TRACES,
|
||||
filters, // Use the local filters state
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
[filters, currentQuery],
|
||||
);
|
||||
|
||||
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
|
||||
|
||||
const allEndpointsWidgetData = useMemo(
|
||||
() => getAllEndpointsWidgetData(groupBy, domainName, filterExpression),
|
||||
[groupBy, domainName, filterExpression],
|
||||
);
|
||||
|
||||
const tableWidgets = useMemo(
|
||||
() => [allEndpointsWidgetData],
|
||||
[allEndpointsWidgetData],
|
||||
);
|
||||
const { isFetching: isFetchingTable, refetch: refetchTable } =
|
||||
useWidgetQueries(tableWidgets);
|
||||
|
||||
const handleRun = useCallback(
|
||||
(value: string): void => onRun(value, refetchTable),
|
||||
[onRun, refetchTable],
|
||||
() => getAllEndpointsWidgetData(groupBy, domainName, filters),
|
||||
[groupBy, domainName, filters],
|
||||
);
|
||||
|
||||
// --- GROUP BY STATE SYNC (existing) ---
|
||||
const handleGroupByChange = useCallback(
|
||||
(value: IBuilderQuery['groupBy']) => {
|
||||
setGroupBy(value);
|
||||
const newGroupBy = [];
|
||||
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const element = value[index] as unknown as string;
|
||||
|
||||
// Check if the key exists in our cached options first
|
||||
if (allAvailableGroupByOptions[element]) {
|
||||
newGroupBy.push(allAvailableGroupByOptions[element]);
|
||||
} else {
|
||||
// If not found in cache, check the current filtered results
|
||||
const key = groupByFiltersData?.payload?.attributeKeys?.find(
|
||||
(key) => key.key === element,
|
||||
);
|
||||
|
||||
if (key) {
|
||||
newGroupBy.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setGroupBy(newGroupBy);
|
||||
setGroupBySearchValue('');
|
||||
// Update params
|
||||
setParams({ groupBy: value.map((g) => g.key) });
|
||||
setParams({ groupBy: newGroupBy.map((g) => g.key) });
|
||||
},
|
||||
[setGroupBy, setParams],
|
||||
[groupByFiltersData, setGroupBy, allAvailableGroupByOptions, setParams],
|
||||
);
|
||||
|
||||
// Sync params to local groupBy state on param change
|
||||
@@ -93,31 +154,77 @@ function AllEndPoints({
|
||||
groupBy.map((g) => g.key),
|
||||
)
|
||||
) {
|
||||
setGroupBy(
|
||||
params.groupBy.map((key) => chooseAutocompleteFromCustomValue([], key)),
|
||||
);
|
||||
// Only update if different
|
||||
const newGroupBy = params.groupBy
|
||||
.map((key) => allAvailableGroupByOptions[key])
|
||||
.filter(Boolean);
|
||||
if (newGroupBy.length === params.groupBy.length) {
|
||||
setGroupBy(newGroupBy);
|
||||
}
|
||||
}
|
||||
}, [params.groupBy, groupBy, setGroupBy]);
|
||||
}, [params.groupBy, allAvailableGroupByOptions, groupBy, setGroupBy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupByFiltersData?.payload) {
|
||||
// Update dropdown options
|
||||
setGroupByOptions(
|
||||
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
|
||||
value: filter.key,
|
||||
label: filter.key,
|
||||
})) || [],
|
||||
);
|
||||
|
||||
// Cache all available options to preserve selected values using functional update
|
||||
setAllAvailableGroupByOptions((prevOptions) => {
|
||||
const newOptions = { ...prevOptions };
|
||||
groupByFiltersData?.payload?.attributeKeys?.forEach((filter) => {
|
||||
newOptions[filter.key] = filter;
|
||||
});
|
||||
return newOptions;
|
||||
});
|
||||
}
|
||||
}, [groupByFiltersData]); // Only depends on groupByFiltersData now
|
||||
|
||||
// Cache existing selected options on component mount
|
||||
useEffect(() => {
|
||||
if (groupBy && groupBy.length > 0) {
|
||||
setAllAvailableGroupByOptions((prevOptions) => {
|
||||
const newOptions = { ...prevOptions };
|
||||
groupBy.forEach((option) => {
|
||||
newOptions[option.key] = option;
|
||||
});
|
||||
return newOptions;
|
||||
});
|
||||
}
|
||||
}, [groupBy]);
|
||||
|
||||
const onRowClick = useCallback(
|
||||
(props: any): void => {
|
||||
setSelectedEndPointName(props[SPAN_ATTRIBUTES.HTTP_URL] as string);
|
||||
setSelectedView(VIEWS.ENDPOINT_STATS);
|
||||
const statsExpression = combineInitialAndUserExpression(
|
||||
getGroupByExpressionFromRowData(props),
|
||||
filterExpression,
|
||||
);
|
||||
void setEndPointStatsExpression(statsExpression || null);
|
||||
const initialItems = [
|
||||
...(filters?.items || []),
|
||||
...(getGroupByFiltersFromGroupByValues(props, groupBy)?.items || []),
|
||||
];
|
||||
setInitialFiltersEndPointStats({
|
||||
items: initialItems,
|
||||
op: 'AND',
|
||||
});
|
||||
setParams({
|
||||
selectedEndPointName: props[SPAN_ATTRIBUTES.HTTP_URL] as string,
|
||||
selectedView: VIEWS.ENDPOINT_STATS,
|
||||
endPointDetailsLocalFilters: {
|
||||
items: initialItems,
|
||||
op: 'AND',
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
filterExpression,
|
||||
setEndPointStatsExpression,
|
||||
filters,
|
||||
setInitialFiltersEndPointStats,
|
||||
setSelectedEndPointName,
|
||||
setSelectedView,
|
||||
groupBy,
|
||||
setParams,
|
||||
],
|
||||
);
|
||||
@@ -125,28 +232,28 @@ function AllEndPoints({
|
||||
return (
|
||||
<div className="all-endpoints-container">
|
||||
<div className="all-endpoints-header">
|
||||
<div className="filter-container" onBlur={handleFilterBlur}>
|
||||
<QuerySearch
|
||||
queryData={queryData}
|
||||
onChange={onDraftChange}
|
||||
onRun={handleRun}
|
||||
showRunButton
|
||||
isLoadingQueries={isFetchingTable}
|
||||
initialExpression={scopeExpression}
|
||||
showScopeChip
|
||||
dataSource={DataSource.TRACES}
|
||||
<div className="filter-container">
|
||||
<QueryBuilderSearchV2
|
||||
query={query}
|
||||
onChange={handleFilterChange}
|
||||
placeholder="Search for filters..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group-by-container">
|
||||
<div className="group-by-label"> Group by </div>
|
||||
<GroupByFilter
|
||||
query={queryData}
|
||||
onChange={handleGroupByChange}
|
||||
disabled={false}
|
||||
<Select
|
||||
className="group-by-select"
|
||||
loading={isLoadingGroupByFilters}
|
||||
mode="multiple"
|
||||
value={groupBy}
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
placeholder="Search for attribute"
|
||||
/>
|
||||
options={groupByOptions}
|
||||
onChange={handleGroupByChange}
|
||||
onSearch={(value: string): void => setGroupBySearchValue(value)}
|
||||
/>{' '}
|
||||
</div>
|
||||
<div className="endpoints-table-container">
|
||||
<GridCard
|
||||
|
||||
@@ -195,6 +195,16 @@
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.group-by-select {
|
||||
width: 100%;
|
||||
.ant-select-selector {
|
||||
font-size: 14px;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add border-bottom to table cells when pagination is not present
|
||||
.ant-spin-container:not(:has(.ant-pagination)) .ant-table-cell {
|
||||
@@ -355,9 +365,16 @@
|
||||
.top-errors-dropdown-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
|
||||
.endpoint-details-filters-container-dropdown {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.endpoint-details-filters-container-search {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.endpoint-details-container {
|
||||
@@ -399,8 +416,29 @@
|
||||
|
||||
.endpoint-details-filters-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border: 1px solid var(--l1-border);
|
||||
height: 36px;
|
||||
box-sizing: content-box;
|
||||
.ant-select-selector {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.endpoint-details-filters-container-dropdown {
|
||||
width: 120px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.ant-select-single {
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.endpoint-details-filters-container-search {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-item,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Button, Drawer } from 'antd';
|
||||
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { QuerySearchV2Provider } from 'components/QueryBuilderV2';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
@@ -18,12 +17,7 @@ import { AppState } from 'store/reducers';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import {
|
||||
ALL_ENDPOINTS_EXPRESSION_KEY,
|
||||
ENDPOINT_STATS_EXPRESSION_KEY,
|
||||
} from '../../../filterParamKeys';
|
||||
import { useApiMonitoringParams } from '../../../queryParams';
|
||||
import { getDomainScopeExpression } from '../../../utils';
|
||||
import AllEndPoints from './AllEndPoints';
|
||||
import DomainMetrics from './components/DomainMetrics';
|
||||
import { VIEW_TYPES, VIEWS } from './constants';
|
||||
@@ -40,14 +34,14 @@ function DomainDetails({
|
||||
selectedDomainIndex,
|
||||
setSelectedDomainIndex,
|
||||
domainListLength,
|
||||
domainListFilterExpression,
|
||||
domainListFilters,
|
||||
}: {
|
||||
domainData: any;
|
||||
handleClose: () => void;
|
||||
selectedDomainIndex: number;
|
||||
setSelectedDomainIndex: (index: number) => void;
|
||||
domainListLength: number;
|
||||
domainListFilterExpression: string;
|
||||
domainListFilters: IBuilderQuery['filters'];
|
||||
}): JSX.Element {
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
const [selectedView, setSelectedView] = useState<VIEWS>(
|
||||
@@ -59,16 +53,8 @@ function DomainDetails({
|
||||
const [endPointsGroupBy, setEndPointsGroupBy] = useState<
|
||||
IBuilderQuery['groupBy']
|
||||
>([]);
|
||||
|
||||
const domainName = domainData?.domainName ?? '';
|
||||
const allEndpointsScope = useMemo(
|
||||
() => getDomainScopeExpression(domainName),
|
||||
[domainName],
|
||||
);
|
||||
const endPointStatsScope = useMemo(
|
||||
() => getDomainScopeExpression(domainName, selectedEndPointName),
|
||||
[domainName, selectedEndPointName],
|
||||
);
|
||||
const [initialFiltersEndPointStats, setInitialFiltersEndPointStats] =
|
||||
useState<IBuilderQuery['filters']>(domainListFilters);
|
||||
|
||||
const handleTabChange = (value: string): void => {
|
||||
setSelectedView(value as VIEWS);
|
||||
@@ -236,7 +222,7 @@ function DomainDetails({
|
||||
<>
|
||||
<DomainMetrics
|
||||
domainName={domainData.domainName}
|
||||
domainListFilterExpression={domainListFilterExpression}
|
||||
domainListFilters={domainListFilters}
|
||||
timeRange={modalTimeRange}
|
||||
/>
|
||||
<div className="views-tabs-container">
|
||||
@@ -267,43 +253,34 @@ function DomainDetails({
|
||||
/>
|
||||
</div>
|
||||
{selectedView === VIEW_TYPES.ALL_ENDPOINTS && (
|
||||
<QuerySearchV2Provider
|
||||
queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}
|
||||
initialExpression={allEndpointsScope}
|
||||
persistOnUnmount
|
||||
>
|
||||
<AllEndPoints
|
||||
domainName={domainData.domainName}
|
||||
setSelectedEndPointName={handleEndPointChange}
|
||||
setSelectedView={setSelectedView}
|
||||
groupBy={endPointsGroupBy}
|
||||
setGroupBy={setEndPointsGroupBy}
|
||||
timeRange={modalTimeRange}
|
||||
/>
|
||||
</QuerySearchV2Provider>
|
||||
<AllEndPoints
|
||||
domainName={domainData.domainName}
|
||||
setSelectedEndPointName={handleEndPointChange}
|
||||
setSelectedView={setSelectedView}
|
||||
groupBy={endPointsGroupBy}
|
||||
setGroupBy={setEndPointsGroupBy}
|
||||
timeRange={modalTimeRange}
|
||||
initialFilters={domainListFilters}
|
||||
setInitialFiltersEndPointStats={setInitialFiltersEndPointStats}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.ENDPOINT_STATS && (
|
||||
<QuerySearchV2Provider
|
||||
queryParamKey={ENDPOINT_STATS_EXPRESSION_KEY}
|
||||
initialExpression={endPointStatsScope}
|
||||
persistOnUnmount
|
||||
>
|
||||
<EndPointDetails
|
||||
domainName={domainData.domainName}
|
||||
endPointName={selectedEndPointName}
|
||||
setSelectedEndPointName={handleEndPointChange}
|
||||
timeRange={modalTimeRange}
|
||||
handleTimeChange={handleTimeChange}
|
||||
/>
|
||||
</QuerySearchV2Provider>
|
||||
<EndPointDetails
|
||||
domainName={domainData.domainName}
|
||||
endPointName={selectedEndPointName}
|
||||
setSelectedEndPointName={handleEndPointChange}
|
||||
initialFilters={initialFiltersEndPointStats}
|
||||
timeRange={modalTimeRange}
|
||||
handleTimeChange={handleTimeChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.TOP_ERRORS && (
|
||||
<TopErrors
|
||||
domainName={domainData.domainName}
|
||||
timeRange={modalTimeRange}
|
||||
initialFilterExpression={domainListFilterExpression}
|
||||
initialFilters={domainListFilters}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
|
||||
import {
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
|
||||
extractPortAndEndpoint,
|
||||
getEndPointDetailsQueryPayload,
|
||||
getHttpUrlExpression,
|
||||
getLatencyOverTimeWidgetData,
|
||||
getRateOverTimeWidgetData,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
@@ -19,28 +19,36 @@ import {
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
|
||||
import DependentServices from './components/DependentServices';
|
||||
import EndPointMetrics from './components/EndPointMetrics';
|
||||
import EndPointPicker from './components/EndPointPicker';
|
||||
import EndPointsDropDown from './components/EndPointsDropDown';
|
||||
import MetricOverTimeGraph from './components/MetricOverTimeGraph';
|
||||
import StatusCodeBarCharts from './components/StatusCodeBarCharts';
|
||||
import StatusCodeTable from './components/StatusCodeTable';
|
||||
import { useFilterExpression } from './hooks/useFilterExpression';
|
||||
import { useWidgetQueries } from './hooks/useWidgetQueries';
|
||||
import { SPAN_ATTRIBUTES } from './constants';
|
||||
|
||||
const httpUrlKey = {
|
||||
dataType: DataTypes.String,
|
||||
key: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
type: 'tag',
|
||||
};
|
||||
|
||||
function EndPointDetails({
|
||||
domainName,
|
||||
endPointName,
|
||||
setSelectedEndPointName,
|
||||
initialFilters,
|
||||
timeRange,
|
||||
handleTimeChange,
|
||||
}: {
|
||||
domainName: string;
|
||||
endPointName: string;
|
||||
setSelectedEndPointName: (value: string) => void;
|
||||
initialFilters: IBuilderQuery['filters'];
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
@@ -51,66 +59,159 @@ function EndPointDetails({
|
||||
) => void;
|
||||
}): JSX.Element {
|
||||
const { startTime: minTime, endTime: maxTime } = timeRange;
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
|
||||
// Search filters; the endpoint filter is combined in at query time
|
||||
const {
|
||||
expression: filterExpression,
|
||||
initialExpression: scopeExpression,
|
||||
queryData,
|
||||
onDraftChange,
|
||||
onRun,
|
||||
onBlur: handleFilterBlur,
|
||||
} = useFilterExpression();
|
||||
const currentQuery = initialQueriesMap[DataSource.TRACES];
|
||||
|
||||
const httpUrlExpression = useMemo(
|
||||
() => getHttpUrlExpression(endPointName),
|
||||
[endPointName],
|
||||
// Local state for filters, combining endpoint filter and search filters
|
||||
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
|
||||
// Initialize filters based on the initial endPointName prop
|
||||
const initialItems = params.endPointDetailsLocalFilters
|
||||
? [...(params.endPointDetailsLocalFilters?.items || [])]
|
||||
: [...(initialFilters?.items || [])];
|
||||
if (endPointName) {
|
||||
initialItems.push({
|
||||
id: '92b8a1c1',
|
||||
key: httpUrlKey,
|
||||
op: '=',
|
||||
value: endPointName,
|
||||
});
|
||||
}
|
||||
return { op: 'AND', items: initialItems };
|
||||
});
|
||||
|
||||
// Effect to synchronize local filters when the endPointName prop changes (e.g., from dropdown)
|
||||
useEffect(() => {
|
||||
setFilters((currentFilters) => {
|
||||
const existingHttpUrlFilter = currentFilters?.items?.find(
|
||||
(item) => item.key?.key === httpUrlKey.key,
|
||||
);
|
||||
const existingHttpUrlValue = (existingHttpUrlFilter?.value as string) || '';
|
||||
|
||||
// Only update filters if the prop value is different from what's already in filters
|
||||
if (endPointName === existingHttpUrlValue) {
|
||||
return currentFilters; // No change needed, prevents loop
|
||||
}
|
||||
|
||||
// Rebuild filters: Keep non-http_url filters and add/update http_url filter based on prop
|
||||
const otherFilters = currentFilters?.items?.filter(
|
||||
(item) => item.key?.key !== httpUrlKey.key,
|
||||
);
|
||||
const newItems = [...(otherFilters || [])];
|
||||
if (endPointName) {
|
||||
newItems.push({
|
||||
id: '92b8a1c1',
|
||||
key: httpUrlKey,
|
||||
op: '=',
|
||||
value: endPointName,
|
||||
});
|
||||
}
|
||||
return { op: 'AND', items: newItems };
|
||||
});
|
||||
}, [endPointName]);
|
||||
|
||||
// Separate effect to update params when filters change
|
||||
useEffect(() => {
|
||||
const filtersWithoutHttpUrl = {
|
||||
op: 'AND',
|
||||
items:
|
||||
filters?.items?.filter((item) => item.key?.key !== httpUrlKey.key) || [],
|
||||
};
|
||||
setParams({ endPointDetailsLocalFilters: filtersWithoutHttpUrl });
|
||||
}, [filters, setParams]);
|
||||
|
||||
// Handler for changes from the QueryBuilderSearchV2 component
|
||||
const handleFilterChange = useCallback(
|
||||
(newFilters: IBuilderQuery['filters']): void => {
|
||||
// 1. Update local filters state immediately
|
||||
setFilters(newFilters);
|
||||
// Filter out http_url filter before saving to params
|
||||
const filteredNewFilters = {
|
||||
op: 'AND',
|
||||
items:
|
||||
newFilters?.items?.filter((item) => item.key?.key !== httpUrlKey.key) ||
|
||||
[],
|
||||
};
|
||||
setParams({ endPointDetailsLocalFilters: filteredNewFilters });
|
||||
|
||||
// 2. Derive the endpoint name from the *new* filters state
|
||||
const httpUrlFilter = newFilters?.items?.find(
|
||||
(item) => item.key?.key === httpUrlKey.key,
|
||||
);
|
||||
const derivedEndPointName = (httpUrlFilter?.value as string) || '';
|
||||
|
||||
// 3. If the derived endpoint name is different from the current prop,
|
||||
// it means the search change modified the effective endpoint.
|
||||
// Notify the parent component.
|
||||
if (derivedEndPointName !== endPointName) {
|
||||
setSelectedEndPointName(derivedEndPointName);
|
||||
}
|
||||
},
|
||||
[endPointName, setSelectedEndPointName, setParams], // Dependencies for the callback
|
||||
);
|
||||
|
||||
const scopedExpression = useMemo(
|
||||
() => combineInitialAndUserExpression(httpUrlExpression, filterExpression),
|
||||
[httpUrlExpression, filterExpression],
|
||||
const updatedCurrentQuery = useMemo(
|
||||
() => ({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
dataSource: DataSource.TRACES,
|
||||
filters, // Use the local filters state
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
[filters, currentQuery],
|
||||
);
|
||||
|
||||
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
|
||||
|
||||
const isServicesFilterApplied = useMemo(
|
||||
() =>
|
||||
extractQueryPairs(filterExpression).some(
|
||||
(pair) => pair.key === 'service.name',
|
||||
),
|
||||
[filterExpression],
|
||||
() => filters?.items?.some((item) => item.key?.key === 'service.name'),
|
||||
[filters],
|
||||
);
|
||||
|
||||
const endPointDetailsQueryPayload = useMemo(
|
||||
() =>
|
||||
getEndPointDetailsQueryPayload(
|
||||
domainName,
|
||||
minTime,
|
||||
maxTime,
|
||||
scopedExpression,
|
||||
),
|
||||
[domainName, scopedExpression, minTime, maxTime],
|
||||
() => getEndPointDetailsQueryPayload(domainName, minTime, maxTime, filters),
|
||||
[domainName, filters, minTime, maxTime],
|
||||
);
|
||||
|
||||
const V5_QUERIES = [
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_METRICS_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
|
||||
] as const;
|
||||
|
||||
const endPointDetailsDataQueries = useQueries(
|
||||
endPointDetailsQueryPayload.map((payload, index) => ({
|
||||
queryKey: [
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
|
||||
payload,
|
||||
ENTITY_VERSION_V5,
|
||||
],
|
||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
|
||||
enabled: !!payload,
|
||||
// Keeps the table's rows, and so its height, while a new filter loads
|
||||
keepPreviousData:
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index] ===
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
|
||||
})),
|
||||
endPointDetailsQueryPayload.map((payload, index) => {
|
||||
const queryKey = END_POINT_DETAILS_QUERY_KEYS_ARRAY[index];
|
||||
const version = (V5_QUERIES as readonly string[]).includes(queryKey)
|
||||
? ENTITY_VERSION_V5
|
||||
: ENTITY_VERSION_V4;
|
||||
return {
|
||||
queryKey: [
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
|
||||
payload,
|
||||
...(filters?.items?.length ? filters.items : []), // Include filters.items in queryKey for better caching
|
||||
version,
|
||||
],
|
||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(payload, version),
|
||||
enabled: !!payload,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const [
|
||||
endPointMetricsDataQuery,
|
||||
endPointStatusCodeDataQuery,
|
||||
endPointDropDownDataQuery,
|
||||
endPointDependentServicesDataQuery,
|
||||
endPointStatusCodeBarChartsDataQuery,
|
||||
endPointStatusCodeLatencyBarChartsDataQuery,
|
||||
@@ -121,6 +222,7 @@ function EndPointDetails({
|
||||
endPointDetailsDataQueries[2],
|
||||
endPointDetailsDataQueries[3],
|
||||
endPointDetailsDataQueries[4],
|
||||
endPointDetailsDataQueries[5],
|
||||
],
|
||||
[endPointDetailsDataQueries],
|
||||
);
|
||||
@@ -132,32 +234,10 @@ function EndPointDetails({
|
||||
|
||||
const [rateOverTimeWidget, latencyOverTimeWidget] = useMemo(
|
||||
() => [
|
||||
getRateOverTimeWidgetData(domainName, endPointName, scopedExpression),
|
||||
getLatencyOverTimeWidgetData(domainName, endPointName, scopedExpression),
|
||||
getRateOverTimeWidgetData(domainName, endPointName, filters),
|
||||
getLatencyOverTimeWidgetData(domainName, endPointName, filters),
|
||||
],
|
||||
[domainName, endPointName, scopedExpression],
|
||||
);
|
||||
|
||||
const graphWidgets = useMemo(
|
||||
() => [rateOverTimeWidget, latencyOverTimeWidget],
|
||||
[rateOverTimeWidget, latencyOverTimeWidget],
|
||||
);
|
||||
const { isFetching: isFetchingGraphs, refetch: refetchGraphs } =
|
||||
useWidgetQueries(graphWidgets);
|
||||
const isFetchingQueries =
|
||||
isFetchingGraphs ||
|
||||
endPointDetailsDataQueries.some((query) => query.isFetching);
|
||||
|
||||
const refetchQueries = useCallback((): void => {
|
||||
refetchGraphs();
|
||||
endPointDetailsDataQueries.forEach((query) => {
|
||||
void query.refetch();
|
||||
});
|
||||
}, [refetchGraphs, endPointDetailsDataQueries]);
|
||||
|
||||
const handleRun = useCallback(
|
||||
(value: string): void => onRun(value, refetchQueries),
|
||||
[onRun, refetchQueries],
|
||||
[domainName, endPointName, filters], // Use combinedFilters
|
||||
);
|
||||
|
||||
// // [TODO] Fix this later
|
||||
@@ -177,23 +257,19 @@ function EndPointDetails({
|
||||
return (
|
||||
<div className="endpoint-details-container">
|
||||
<div className="endpoint-details-filters-container">
|
||||
<EndPointPicker
|
||||
domainName={domainName}
|
||||
timeRange={timeRange}
|
||||
filterExpression={filterExpression}
|
||||
value={endPointName}
|
||||
onChange={setSelectedEndPointName}
|
||||
/>
|
||||
<div onBlur={handleFilterBlur}>
|
||||
<QuerySearch
|
||||
queryData={queryData}
|
||||
onChange={onDraftChange}
|
||||
onRun={handleRun}
|
||||
showRunButton
|
||||
isLoadingQueries={isFetchingQueries}
|
||||
initialExpression={scopeExpression}
|
||||
showScopeChip
|
||||
dataSource={DataSource.TRACES}
|
||||
<div className="endpoint-details-filters-container-dropdown">
|
||||
<EndPointsDropDown
|
||||
selectedEndPointName={endPointName}
|
||||
setSelectedEndPointName={setSelectedEndPointName}
|
||||
endPointDropDownDataQuery={endPointDropDownDataQuery}
|
||||
parentContainerDiv=".endpoint-details-filters-container"
|
||||
dropdownStyle={{ width: 'calc(100% - 36px)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="endpoint-details-filters-container-search">
|
||||
<QueryBuilderSearchV2
|
||||
query={query}
|
||||
onChange={handleFilterChange}
|
||||
placeholder="Search for filters..."
|
||||
/>
|
||||
</div>
|
||||
@@ -223,7 +299,7 @@ function EndPointDetails({
|
||||
endPointStatusCodeLatencyBarChartsDataQuery
|
||||
}
|
||||
domainName={domainName}
|
||||
filterExpression={scopedExpression}
|
||||
filters={filters}
|
||||
timeRange={timeRange}
|
||||
onDragSelect={onDragSelect}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { QueryFunctionContext, useQuery } from 'react-query';
|
||||
import { QueryFunctionContext, useQueries, useQuery } from 'react-query';
|
||||
import { Spin, Table, Tooltip } from 'antd';
|
||||
import { Info, Loader } from '@signozhq/icons';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
@@ -7,36 +7,41 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { getQueryRangeV5 } from 'api/v5/queryRange/getQueryRange';
|
||||
import { MetricRangePayloadV5, ScalarData } from 'api/v5/v5';
|
||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { withErrorBoundary } from 'components/ErrorBoundaryHOC';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
|
||||
formatTopErrorsDataForTable,
|
||||
getHttpUrlExpression,
|
||||
getEndPointDetailsQueryPayload,
|
||||
getTopErrorsColumnsConfig,
|
||||
getTopErrorsCoRelationQueryFilters,
|
||||
getTopErrorsQueryPayload,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { SuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
|
||||
|
||||
import EndPointPicker from './components/EndPointPicker';
|
||||
import EndPointsDropDown from './components/EndPointsDropDown';
|
||||
import ErrorState from './components/ErrorState';
|
||||
import { SPAN_ATTRIBUTES } from './constants';
|
||||
|
||||
function TopErrors({
|
||||
domainName,
|
||||
timeRange,
|
||||
initialFilterExpression,
|
||||
initialFilters,
|
||||
}: {
|
||||
domainName: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
initialFilterExpression: string;
|
||||
initialFilters: IBuilderQuery['filters'];
|
||||
}): JSX.Element {
|
||||
const { startTime: minTime, endTime: maxTime } = timeRange;
|
||||
|
||||
@@ -50,10 +55,27 @@ function TopErrors({
|
||||
domainName,
|
||||
minTime,
|
||||
maxTime,
|
||||
combineInitialAndUserExpression(
|
||||
getHttpUrlExpression(endPointName),
|
||||
initialFilterExpression,
|
||||
),
|
||||
{
|
||||
items: endPointName
|
||||
? [
|
||||
// Remove any existing http_url filters from initialFilters to avoid duplicates
|
||||
...(initialFilters?.items?.filter(
|
||||
(item) => item.key?.key !== SPAN_ATTRIBUTES.HTTP_URL,
|
||||
) || []),
|
||||
{
|
||||
id: '92b8a1c1',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: endPointName,
|
||||
},
|
||||
]
|
||||
: [...(initialFilters?.items || [])],
|
||||
op: 'AND',
|
||||
},
|
||||
showStatusCodeErrors,
|
||||
),
|
||||
[
|
||||
@@ -61,7 +83,7 @@ function TopErrors({
|
||||
endPointName,
|
||||
minTime,
|
||||
maxTime,
|
||||
initialFilterExpression,
|
||||
initialFilters,
|
||||
showStatusCodeErrors,
|
||||
],
|
||||
);
|
||||
@@ -98,6 +120,35 @@ function TopErrors({
|
||||
[topErrorsData],
|
||||
);
|
||||
|
||||
const endPointDropDownQueryPayload = useMemo(
|
||||
() => [
|
||||
getEndPointDetailsQueryPayload(domainName, minTime, maxTime, {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
})[2],
|
||||
],
|
||||
[domainName, minTime, maxTime],
|
||||
);
|
||||
|
||||
const endPointDropDownDataQueries = useQueries(
|
||||
endPointDropDownQueryPayload.map((payload) => ({
|
||||
queryKey: [
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY[2],
|
||||
payload,
|
||||
ENTITY_VERSION_V5,
|
||||
],
|
||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
|
||||
enabled: !!payload,
|
||||
staleTime: 60 * 1000,
|
||||
})),
|
||||
);
|
||||
|
||||
const [endPointDropDownDataQuery] = useMemo(
|
||||
() => [endPointDropDownDataQueries[0]],
|
||||
[endPointDropDownDataQueries],
|
||||
);
|
||||
|
||||
const navigateToExplorer = useNavigateToExplorer();
|
||||
|
||||
if (isError) {
|
||||
@@ -111,13 +162,14 @@ function TopErrors({
|
||||
return (
|
||||
<div className="all-endpoints-container">
|
||||
<div className="top-errors-dropdown-container">
|
||||
<EndPointPicker
|
||||
domainName={domainName}
|
||||
timeRange={timeRange}
|
||||
filterExpression=""
|
||||
value={endPointName}
|
||||
onChange={setSelectedEndPointName}
|
||||
/>
|
||||
<div className="endpoint-details-filters-container-dropdown">
|
||||
<EndPointsDropDown
|
||||
selectedEndPointName={endPointName}
|
||||
setSelectedEndPointName={setSelectedEndPointName}
|
||||
endPointDropDownDataQuery={endPointDropDownDataQuery}
|
||||
parentContainerDiv=".endpoint-details-filters-container"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Switch
|
||||
color="primary"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Table, TablePaginationConfig } from 'antd';
|
||||
import { Skeleton, Table, TablePaginationConfig } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
@@ -54,6 +54,10 @@ function DependentServices({
|
||||
[isExpanded, dependentServicesData.length],
|
||||
);
|
||||
|
||||
if (isLoading || isRefetching) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <ErrorState refetch={refetch} />;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { TraceAggregation } from 'api/v5/v5';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { SPAN_ATTRIBUTES } from '../constants';
|
||||
import DomainMetrics from './DomainMetrics';
|
||||
@@ -33,7 +34,10 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
|
||||
startTime: 1758259531000,
|
||||
endTime: 1758261331000,
|
||||
},
|
||||
domainListFilterExpression: '',
|
||||
domainListFilters: {
|
||||
items: [],
|
||||
op: 'AND' as const,
|
||||
} as IBuilderQuery['filters'],
|
||||
};
|
||||
|
||||
const mockSuccessResponse = {
|
||||
@@ -170,12 +174,35 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
|
||||
it('includes custom filters in filter expressions', async () => {
|
||||
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
|
||||
|
||||
const customFilterExpression =
|
||||
"service.name = 'my-service' AND deployment.environment = 'production'";
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'my-service',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
key: {
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'production',
|
||||
},
|
||||
],
|
||||
op: 'AND' as const,
|
||||
};
|
||||
|
||||
renderComponent({
|
||||
...mockProps,
|
||||
domainListFilterExpression: customFilterExpression,
|
||||
domainListFilters: customFilters,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -14,17 +14,18 @@ import {
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import ErrorState from './ErrorState';
|
||||
|
||||
function DomainMetrics({
|
||||
domainName,
|
||||
timeRange,
|
||||
domainListFilterExpression,
|
||||
domainListFilters,
|
||||
}: {
|
||||
domainName: string;
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
domainListFilterExpression: string;
|
||||
domainListFilters: IBuilderQuery['filters'];
|
||||
}): JSX.Element {
|
||||
const { startTime: minTime, endTime: maxTime } = timeRange;
|
||||
|
||||
@@ -34,9 +35,9 @@ function DomainMetrics({
|
||||
domainName,
|
||||
minTime,
|
||||
maxTime,
|
||||
domainListFilterExpression,
|
||||
domainListFilters,
|
||||
),
|
||||
[domainName, minTime, maxTime, domainListFilterExpression],
|
||||
[domainName, minTime, maxTime, domainListFilters],
|
||||
);
|
||||
|
||||
// Since only one query here
|
||||
|
||||
@@ -114,7 +114,10 @@ describe('EndPointMetrics - V5 Query Payload Tests', () => {
|
||||
const domainName = 'api.example.com';
|
||||
const startTime = 1758259531000;
|
||||
const endTime = 1758261331000;
|
||||
const filters = '';
|
||||
const filters = {
|
||||
items: [],
|
||||
op: 'AND' as const,
|
||||
};
|
||||
|
||||
// Get the actual payload that would be generated
|
||||
const payloads = getEndPointDetailsQueryPayload(
|
||||
@@ -234,8 +237,31 @@ describe('EndPointMetrics - V5 Query Payload Tests', () => {
|
||||
it('includes custom domainListFilters in all query expressions', async () => {
|
||||
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
|
||||
|
||||
const customFilters =
|
||||
"service.name = 'payment-service' AND deployment.environment = 'staging'";
|
||||
const customFilters = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'payment-service',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
key: {
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'staging',
|
||||
},
|
||||
],
|
||||
op: 'AND' as const,
|
||||
};
|
||||
|
||||
const payloads = getEndPointDetailsQueryPayload(
|
||||
'api.internal.com',
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
.picker {
|
||||
width: 460px;
|
||||
max-width: 100%;
|
||||
|
||||
// Popover fits its longest option by default; match the trigger so long URLs truncate.
|
||||
[data-slot='combobox-content'] {
|
||||
--popover-width: var(--radix-popover-trigger-width);
|
||||
}
|
||||
}
|
||||
|
||||
.option {
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { ComboboxSimple, ComboboxSimpleItem } from '@signozhq/ui/combobox';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
getEndPointDropDownQueryPayload,
|
||||
getFormattedEndPointDropDownData,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
|
||||
import styles from './EndPointPicker.module.scss';
|
||||
|
||||
const VIRTUALIZE_AFTER = 100;
|
||||
const ALL_ENDPOINTS = '__all_endpoints__';
|
||||
|
||||
interface EndPointPickerProps {
|
||||
domainName: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
filterExpression: string;
|
||||
value: string;
|
||||
onChange: (endPointName: string) => void;
|
||||
}
|
||||
|
||||
function EndPointPicker({
|
||||
domainName,
|
||||
timeRange,
|
||||
filterExpression,
|
||||
value,
|
||||
onChange,
|
||||
}: EndPointPickerProps): JSX.Element {
|
||||
const payload = useMemo(
|
||||
() =>
|
||||
getEndPointDropDownQueryPayload(
|
||||
domainName,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
filterExpression,
|
||||
),
|
||||
[domainName, timeRange.startTime, timeRange.endTime, filterExpression],
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
|
||||
payload,
|
||||
ENTITY_VERSION_V5,
|
||||
],
|
||||
queryFn: () => GetMetricQueryRange(payload, ENTITY_VERSION_V5),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const items = useMemo(
|
||||
(): ComboboxSimpleItem[] => [
|
||||
{ value: ALL_ENDPOINTS, label: 'All endpoints' },
|
||||
...getFormattedEndPointDropDownData(
|
||||
data?.payload?.data?.result?.[0]?.table?.rows ?? [],
|
||||
).map(({ value: url }) => ({
|
||||
value: url,
|
||||
label: (
|
||||
<span className={styles.option} title={url}>
|
||||
{url}
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
],
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.picker}>
|
||||
<ComboboxSimple
|
||||
items={items}
|
||||
value={value || ALL_ENDPOINTS}
|
||||
onChange={(next): void =>
|
||||
onChange(!next || next === ALL_ENDPOINTS ? '' : (next as string))
|
||||
}
|
||||
inputPlaceholder="Search endpoints"
|
||||
emptyPlaceholder="No endpoints found"
|
||||
loading={isLoading || isFetching}
|
||||
virtualized={items.length > VIRTUALIZE_AFTER}
|
||||
withPortal={false}
|
||||
testId="endpoint-picker"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EndPointPicker;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Select } from 'antd';
|
||||
import { getFormattedEndPointDropDownData } from 'container/ApiMonitoring/utils';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
|
||||
interface EndPointsDropDownProps {
|
||||
selectedEndPointName?: string;
|
||||
setSelectedEndPointName: (value: string) => void;
|
||||
endPointDropDownDataQuery: UseQueryResult<SuccessResponse<any>, unknown>;
|
||||
parentContainerDiv?: string;
|
||||
dropdownStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
selectedEndPointName: '',
|
||||
parentContainerDiv: '',
|
||||
dropdownStyle: {},
|
||||
};
|
||||
|
||||
function EndPointsDropDown({
|
||||
selectedEndPointName,
|
||||
setSelectedEndPointName,
|
||||
endPointDropDownDataQuery,
|
||||
parentContainerDiv,
|
||||
dropdownStyle,
|
||||
}: EndPointsDropDownProps): JSX.Element {
|
||||
const { data, isLoading, isFetching } = endPointDropDownDataQuery;
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
setSelectedEndPointName(value);
|
||||
};
|
||||
|
||||
const formattedData = useMemo(
|
||||
() =>
|
||||
getFormattedEndPointDropDownData(data?.payload.data.result[0].table.rows),
|
||||
[data?.payload.data.result],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedEndPointName || undefined}
|
||||
placeholder="Select endpoint"
|
||||
loading={isLoading || isFetching}
|
||||
style={{ width: '100%' }}
|
||||
onChange={handleChange}
|
||||
options={formattedData}
|
||||
getPopupContainer={
|
||||
parentContainerDiv
|
||||
? (): HTMLElement =>
|
||||
document.querySelector(parentContainerDiv) as HTMLElement
|
||||
: (triggerNode): HTMLElement => triggerNode.parentNode as HTMLElement
|
||||
}
|
||||
dropdownStyle={dropdownStyle}
|
||||
allowClear
|
||||
onClear={(): void => {
|
||||
setSelectedEndPointName('');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
EndPointsDropDown.defaultProps = defaultProps;
|
||||
|
||||
export default EndPointsDropDown;
|
||||
@@ -24,6 +24,7 @@ import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import ErrorState from './ErrorState';
|
||||
import {
|
||||
@@ -36,7 +37,7 @@ function StatusCodeBarCharts({
|
||||
endPointStatusCodeBarChartsDataQuery,
|
||||
endPointStatusCodeLatencyBarChartsDataQuery,
|
||||
domainName,
|
||||
filterExpression,
|
||||
filters,
|
||||
timeRange,
|
||||
onDragSelect,
|
||||
}: {
|
||||
@@ -49,7 +50,7 @@ function StatusCodeBarCharts({
|
||||
unknown
|
||||
>;
|
||||
domainName: string;
|
||||
filterExpression: string;
|
||||
filters: IBuilderQuery['filters'];
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
@@ -129,8 +130,12 @@ function StatusCodeBarCharts({
|
||||
);
|
||||
|
||||
const widget = useMemo<Widgets>(
|
||||
() => getStatusCodeBarChartWidgetData(domainName, filterExpression),
|
||||
[domainName, filterExpression],
|
||||
() =>
|
||||
getStatusCodeBarChartWidgetData(domainName, {
|
||||
items: [...(filters?.items || [])],
|
||||
op: filters?.op || 'AND',
|
||||
}),
|
||||
[domainName, filters],
|
||||
);
|
||||
|
||||
const activeApiResponse = useMemo(
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { FilterProviders } from 'container/ApiMonitoring/__tests__/FilterProviders';
|
||||
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import { useFilterExpression } from '../useFilterExpression';
|
||||
|
||||
const PARAM_KEY = 'testExpression';
|
||||
const linkExpression = "service.name = 'frontend'";
|
||||
const linkSearch = `${PARAM_KEY}=${encodeURIComponent(linkExpression)}`;
|
||||
|
||||
function renderFilterHook(
|
||||
searchParams = '',
|
||||
): ReturnType<
|
||||
typeof renderHook<ReturnType<typeof useFilterExpression>, unknown>
|
||||
> {
|
||||
return renderHook(() => useFilterExpression(), {
|
||||
wrapper: ({ children }) => (
|
||||
<FilterProviders queryParamKey={PARAM_KEY} searchParams={searchParams}>
|
||||
{children}
|
||||
</FilterProviders>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function FilterBox(): JSX.Element {
|
||||
const { expression, onDraftChange, onBlur } = useFilterExpression();
|
||||
|
||||
return (
|
||||
<div onBlur={onBlur}>
|
||||
<span data-testid="applied">{expression}</span>
|
||||
<input
|
||||
data-testid="filter-input"
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
/>
|
||||
<button type="button" data-testid="inside-button">
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useFilterExpression', () => {
|
||||
beforeEach(() => {
|
||||
resetNuqsState();
|
||||
});
|
||||
|
||||
it('reads the filter from the link', async () => {
|
||||
const { result } = renderFilterHook(linkSearch);
|
||||
|
||||
await waitFor(() => expect(result.current.expression).toBe(linkExpression));
|
||||
expect(result.current.queryData.dataSource).toBe('traces');
|
||||
expect(result.current.queryData.filter?.expression).toBe(linkExpression);
|
||||
});
|
||||
|
||||
it('applies a valid expression on run and ignores an invalid one', async () => {
|
||||
const { result } = renderFilterHook(linkSearch);
|
||||
await waitFor(() => expect(result.current.expression).toBe(linkExpression));
|
||||
|
||||
act(() => {
|
||||
result.current.onRun('service.name =');
|
||||
});
|
||||
expect(result.current.expression).toBe(linkExpression);
|
||||
|
||||
act(() => {
|
||||
result.current.onRun('http.status_code = 500');
|
||||
});
|
||||
expect(result.current.expression).toBe('http.status_code = 500');
|
||||
await waitFor(() =>
|
||||
expect(getCurrentNuqsSearchParams().get(PARAM_KEY)).toBe(
|
||||
'http.status_code = 500',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('runs again instead of applying when the filter is already applied', async () => {
|
||||
const scope = "http_host = 'api.example.com'";
|
||||
const { result } = renderHook(() => useFilterExpression(), {
|
||||
wrapper: ({ children }) => (
|
||||
<FilterProviders
|
||||
queryParamKey={PARAM_KEY}
|
||||
initialExpression={scope}
|
||||
searchParams={linkSearch}
|
||||
>
|
||||
{children}
|
||||
</FilterProviders>
|
||||
),
|
||||
});
|
||||
await waitFor(() => expect(result.current.expression).toBe(linkExpression));
|
||||
const onUnchanged = jest.fn() as jest.MockedFunction<() => void>;
|
||||
|
||||
act(() => {
|
||||
result.current.onRun(
|
||||
combineInitialAndUserExpression(scope, linkExpression),
|
||||
onUnchanged,
|
||||
);
|
||||
});
|
||||
expect(onUnchanged).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
result.current.onRun(
|
||||
combineInitialAndUserExpression(scope, 'http.status_code = 500'),
|
||||
onUnchanged,
|
||||
);
|
||||
});
|
||||
expect(onUnchanged).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.expression).toBe('http.status_code = 500');
|
||||
});
|
||||
|
||||
it('applies the draft on blur only when focus leaves the container', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<FilterProviders queryParamKey={PARAM_KEY}>
|
||||
<FilterBox />
|
||||
</FilterProviders>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId('filter-input'), "service.name = 'cart'");
|
||||
await user.click(screen.getByTestId('inside-button'));
|
||||
expect(screen.getByTestId('applied')).toHaveTextContent('');
|
||||
|
||||
await user.click(document.body);
|
||||
expect(screen.getByTestId('applied')).toHaveTextContent(
|
||||
"service.name = 'cart'",
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps only the user text when the scope is set', async () => {
|
||||
const scope = "http_host = 'api.example.com'";
|
||||
const { result } = renderHook(() => useFilterExpression(), {
|
||||
wrapper: ({ children }) => (
|
||||
<FilterProviders queryParamKey={PARAM_KEY} initialExpression={scope}>
|
||||
{children}
|
||||
</FilterProviders>
|
||||
),
|
||||
});
|
||||
await waitFor(() => expect(result.current.initialExpression).toBe(scope));
|
||||
|
||||
act(() => {
|
||||
result.current.onRun(`${scope} AND (service.name = 'cart')`);
|
||||
});
|
||||
|
||||
expect(result.current.expression).toBe("service.name = 'cart'");
|
||||
await waitFor(() =>
|
||||
expect(getCurrentNuqsSearchParams().get(PARAM_KEY)).toBe(
|
||||
"service.name = 'cart'",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider, QueryObserver } from 'react-query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
|
||||
import { act, renderHook, waitFor } from 'tests/test-utils';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
|
||||
import { useWidgetQueries } from '../useWidgetQueries';
|
||||
|
||||
const widgetWithQueryId = (id: string): Widgets =>
|
||||
getWidgetQueryBuilder({
|
||||
query: { ...initialQueriesMap.traces, id },
|
||||
panelTypes: PANEL_TYPES.TABLE,
|
||||
});
|
||||
|
||||
const gridCardKey = (queryId: string): unknown[] => [
|
||||
REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE,
|
||||
0,
|
||||
0,
|
||||
{ id: queryId },
|
||||
];
|
||||
|
||||
function startGridCardFetch(client: QueryClient, queryId: string): void {
|
||||
void client.prefetchQuery(gridCardKey(queryId), () => new Promise(() => {}));
|
||||
}
|
||||
|
||||
function renderWidgetQueries(
|
||||
client: QueryClient,
|
||||
widgets: Widgets[],
|
||||
): ReturnType<typeof renderHook<ReturnType<typeof useWidgetQueries>, unknown>> {
|
||||
return renderHook(() => useWidgetQueries(widgets), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
describe('useWidgetQueries', () => {
|
||||
it('counts only the given widgets’ grid card queries', async () => {
|
||||
const client = new QueryClient();
|
||||
const { result } = renderWidgetQueries(client, [widgetWithQueryId('table')]);
|
||||
|
||||
await act(async () => {
|
||||
startGridCardFetch(client, 'other-tab-chart');
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
expect(client.isFetching()).toBe(1);
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
|
||||
act(() => startGridCardFetch(client, 'table'));
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
});
|
||||
|
||||
it('refetches only the given widgets’ grid card queries', async () => {
|
||||
const client = new QueryClient();
|
||||
const fetchTable = jest.fn(() =>
|
||||
Promise.resolve('table'),
|
||||
) as jest.MockedFunction<() => Promise<string>>;
|
||||
const fetchOther = jest.fn(() =>
|
||||
Promise.resolve('other'),
|
||||
) as jest.MockedFunction<() => Promise<string>>;
|
||||
const unsubscribers = [
|
||||
new QueryObserver(client, {
|
||||
queryKey: gridCardKey('table'),
|
||||
queryFn: fetchTable,
|
||||
}).subscribe(() => {}),
|
||||
new QueryObserver(client, {
|
||||
queryKey: gridCardKey('other-tab-chart'),
|
||||
queryFn: fetchOther,
|
||||
}).subscribe(() => {}),
|
||||
];
|
||||
await waitFor(() => expect(client.isFetching()).toBe(0));
|
||||
const { result } = renderWidgetQueries(client, [widgetWithQueryId('table')]);
|
||||
|
||||
act(() => result.current.refetch());
|
||||
|
||||
await waitFor(() => expect(fetchTable).toHaveBeenCalledTimes(2));
|
||||
expect(fetchOther).toHaveBeenCalledTimes(1);
|
||||
unsubscribers.forEach((unsubscribe) => unsubscribe());
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import { FocusEvent, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
useInitialExpression,
|
||||
useQuerySearchInitialExpressionProp,
|
||||
useQuerySearchOnRun,
|
||||
useUserExpression,
|
||||
} from 'components/QueryBuilderV2';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
getUserExpressionFromCombined,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
|
||||
interface UseFilterExpressionParams {
|
||||
groupBy?: IBuilderQuery['groupBy'];
|
||||
}
|
||||
|
||||
interface UseFilterExpressionReturn {
|
||||
expression: string;
|
||||
/** The provider's locked scope, for `QuerySearch`'s `initialExpression` */
|
||||
initialExpression: string | undefined;
|
||||
queryData: IBuilderQuery;
|
||||
onDraftChange: (value: string) => void;
|
||||
/** `onUnchanged` runs instead when the filter is already applied. */
|
||||
onRun: (value: string, onUnchanged?: () => void) => void;
|
||||
onBlur: (event: FocusEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
/** Must be used inside a `QuerySearchV2Provider`. */
|
||||
export function useFilterExpression({
|
||||
groupBy,
|
||||
}: UseFilterExpressionParams = {}): UseFilterExpressionReturn {
|
||||
const expression = useUserExpression();
|
||||
const scope = useInitialExpression();
|
||||
const initialExpression = useQuerySearchInitialExpressionProp();
|
||||
const querySearchOnRun = useQuerySearchOnRun();
|
||||
|
||||
const queryData = useMemo(
|
||||
(): IBuilderQuery => ({
|
||||
...initialQueriesMap[DataSource.TRACES].builder.queryData[0],
|
||||
dataSource: DataSource.TRACES,
|
||||
filter: { expression },
|
||||
...(groupBy ? { groupBy } : {}),
|
||||
}),
|
||||
[expression, groupBy],
|
||||
);
|
||||
|
||||
const onRun = useCallback(
|
||||
(value: string, onUnchanged?: () => void): void => {
|
||||
if (!validateQuery(value).isValid) {
|
||||
return;
|
||||
}
|
||||
if (getUserExpressionFromCombined(scope, value) === expression) {
|
||||
onUnchanged?.();
|
||||
return;
|
||||
}
|
||||
querySearchOnRun(value);
|
||||
},
|
||||
[querySearchOnRun, scope, expression],
|
||||
);
|
||||
|
||||
// A ref, not the provider's draft, so typing does not re-render the page.
|
||||
const draftRef = useRef(expression);
|
||||
useEffect(() => {
|
||||
draftRef.current = expression;
|
||||
}, [expression]);
|
||||
|
||||
const onDraftChange = useCallback((value: string): void => {
|
||||
draftRef.current = value;
|
||||
}, []);
|
||||
|
||||
const onBlur = useCallback(
|
||||
(event: FocusEvent<HTMLDivElement>): void => {
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
return;
|
||||
}
|
||||
onRun(combineInitialAndUserExpression(scope, draftRef.current));
|
||||
},
|
||||
[onRun, scope],
|
||||
);
|
||||
|
||||
return {
|
||||
expression,
|
||||
initialExpression,
|
||||
queryData,
|
||||
onDraftChange,
|
||||
onRun,
|
||||
onBlur,
|
||||
};
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Query as ReactQuery,
|
||||
useIsFetching,
|
||||
useQueryClient,
|
||||
} from 'react-query';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
|
||||
interface UseWidgetQueriesReturn {
|
||||
isFetching: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* These widgets' `GridCard` queries. Matches on the widget's query id, so
|
||||
* another tab's cards are neither counted nor refetched.
|
||||
*/
|
||||
export function useWidgetQueries(widgets: Widgets[]): UseWidgetQueriesReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const predicate = useMemo(() => {
|
||||
const queryIds = new Set(
|
||||
widgets
|
||||
.map((widget) => widget.query?.id)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
return ({ queryKey }: ReactQuery): boolean =>
|
||||
Array.isArray(queryKey) &&
|
||||
queryKey[0] === REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE &&
|
||||
queryKey.some((part) => {
|
||||
const id = (part as Query | undefined)?.id;
|
||||
return !!id && queryIds.has(id);
|
||||
});
|
||||
}, [widgets]);
|
||||
|
||||
const isFetching = useIsFetching({ predicate }) > 0;
|
||||
|
||||
const refetch = useCallback((): void => {
|
||||
void queryClient.refetchQueries({ predicate, active: true });
|
||||
}, [queryClient, predicate]);
|
||||
|
||||
return { isFetching, refetch };
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useListOverview } from 'hooks/thirdPartyApis/useListOverview';
|
||||
import { get } from 'lodash-es';
|
||||
import { parseAsString, useQueryStates } from 'nuqs';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
|
||||
@@ -28,26 +27,16 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import { ApiMonitoringHardcodedAttributeKeys } from '../../constants';
|
||||
import {
|
||||
ALL_ENDPOINTS_EXPRESSION_KEY,
|
||||
ENDPOINT_STATS_EXPRESSION_KEY,
|
||||
} from '../../filterParamKeys';
|
||||
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
|
||||
import { columnsConfig, formatDataForTable } from '../../utils';
|
||||
import DomainDetails from './DomainDetails/DomainDetails';
|
||||
import { useConvertLegacyFilterLinks } from './hooks/useConvertLegacyFilterLinks';
|
||||
|
||||
import '../Explorer.styles.scss';
|
||||
|
||||
function DomainList(): JSX.Element {
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
const { showIP, selectedDomain } = params;
|
||||
const [, setDrawerFilters] = useQueryStates({
|
||||
[ALL_ENDPOINTS_EXPRESSION_KEY]: parseAsString,
|
||||
[ENDPOINT_STATS_EXPRESSION_KEY]: parseAsString,
|
||||
});
|
||||
const [selectedDomainIndex, setSelectedDomainIndex] = useState<number>(-1);
|
||||
useConvertLegacyFilterLinks();
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
@@ -66,8 +55,6 @@ function DomainList(): JSX.Element {
|
||||
});
|
||||
|
||||
const compositeData = useGetCompositeQueryParam();
|
||||
const listFilterExpression =
|
||||
compositeData?.builder?.queryData[0]?.filter?.expression ?? '';
|
||||
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
@@ -168,7 +155,6 @@ function DomainList(): JSX.Element {
|
||||
dataSource={DataSource.TRACES}
|
||||
queryData={query}
|
||||
onChange={handleSearchChange}
|
||||
onRun={handleStageAndRunQuery}
|
||||
placeholder="Enter your filter query (e.g., deployment.environment = 'otel-demo' AND service.name = 'frontend')"
|
||||
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
|
||||
/>
|
||||
@@ -239,11 +225,6 @@ function DomainList(): JSX.Element {
|
||||
);
|
||||
setSelectedDomainIndex(dataIndex);
|
||||
setParams({ selectedDomain: record.domainName });
|
||||
// The drawer tabs start from the list's filter
|
||||
void setDrawerFilters({
|
||||
[ALL_ENDPOINTS_EXPRESSION_KEY]: listFilterExpression || null,
|
||||
[ENDPOINT_STATS_EXPRESSION_KEY]: listFilterExpression || null,
|
||||
});
|
||||
logEvent('API Monitoring: Domain name row clicked', {});
|
||||
}
|
||||
},
|
||||
@@ -264,12 +245,8 @@ function DomainList(): JSX.Element {
|
||||
handleClose={(): void => {
|
||||
setSelectedDomainIndex(-1);
|
||||
setParams(DEFAULT_PARAMS);
|
||||
void setDrawerFilters({
|
||||
[ALL_ENDPOINTS_EXPRESSION_KEY]: null,
|
||||
[ENDPOINT_STATS_EXPRESSION_KEY]: null,
|
||||
});
|
||||
}}
|
||||
domainListFilterExpression={listFilterExpression}
|
||||
domainListFilters={query?.filters}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import {
|
||||
ALL_ENDPOINTS_EXPRESSION_KEY,
|
||||
ENDPOINT_STATS_EXPRESSION_KEY,
|
||||
} from 'container/ApiMonitoring/filterParamKeys';
|
||||
import { getApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import {
|
||||
getCurrentNuqsSearchParams,
|
||||
onNuqsUrlUpdate,
|
||||
resetNuqsState,
|
||||
} from 'tests/nuqs-helpers';
|
||||
import { mockAppLocation, mockNavigate } from 'tests/router-mocks';
|
||||
import { renderHook, waitFor } from 'tests/test-utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
__resetSearchParamsGetter,
|
||||
__setSearchParamsGetterForTest,
|
||||
} from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import { useConvertLegacyFilterLinks } from '../useConvertLegacyFilterLinks';
|
||||
|
||||
const legacyFilter = (key: string, value: string): TagFilter => ({
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: key,
|
||||
key: { key, dataType: DataTypes.String, type: 'tag' },
|
||||
op: '=',
|
||||
value,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// NuqsTestingAdapter resets its update queue on every render, not only on mount,
|
||||
// dropping writes queued just before it re-renders.
|
||||
function UrlAdapter({
|
||||
searchParams,
|
||||
children,
|
||||
}: {
|
||||
searchParams: string;
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
return (
|
||||
<NuqsTestingAdapter
|
||||
searchParams={searchParams}
|
||||
onUrlUpdate={onNuqsUrlUpdate}
|
||||
rateLimitFactor={0}
|
||||
resetUrlUpdateQueueOnMount={!mounted}
|
||||
hasMemory
|
||||
>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWithLink(linkParams: object): jest.SpyInstance {
|
||||
const search = `?apiMonitoringParams=${encodeURIComponent(
|
||||
encodeURIComponent(JSON.stringify(linkParams)),
|
||||
)}`;
|
||||
__setSearchParamsGetterForTest(() => new URLSearchParams(search));
|
||||
mockAppLocation({ search });
|
||||
const navigateSpy = mockNavigate();
|
||||
renderHook(() => useConvertLegacyFilterLinks(), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<UrlAdapter searchParams={search}>{children}</UrlAdapter>
|
||||
),
|
||||
});
|
||||
return navigateSpy;
|
||||
}
|
||||
|
||||
describe('useConvertLegacyFilterLinks', () => {
|
||||
beforeEach(() => {
|
||||
resetNuqsState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetSearchParamsGetter();
|
||||
});
|
||||
|
||||
it('moves an older link’s filters into the drawer params once', async () => {
|
||||
const navigateSpy = renderWithLink({
|
||||
selectedDomain: 'api.example.com',
|
||||
allEndpointsLocalFilters: legacyFilter('service.name', 'cart'),
|
||||
endPointDetailsLocalFilters: legacyFilter('http.status_code', '500'),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const params = getCurrentNuqsSearchParams();
|
||||
expect(params.get(ALL_ENDPOINTS_EXPRESSION_KEY)).toBe(
|
||||
"service.name = 'cart'",
|
||||
);
|
||||
expect(params.get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
|
||||
"http.status_code = '500'",
|
||||
);
|
||||
});
|
||||
const [to, options] = navigateSpy.mock.calls[0];
|
||||
const rewritten = getApiMonitoringParams(
|
||||
typeof to === 'string' ? to : (to.search ?? ''),
|
||||
);
|
||||
expect(rewritten.selectedDomain).toBe('api.example.com');
|
||||
expect(rewritten).not.toHaveProperty('allEndpointsLocalFilters');
|
||||
expect(options).toStrictEqual({ replace: true });
|
||||
});
|
||||
|
||||
it('leaves a link without legacy filters alone', () => {
|
||||
const navigateSpy = renderWithLink({ selectedDomain: 'api.example.com' });
|
||||
|
||||
expect(navigateSpy).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ search: expect.any(String) }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(getCurrentNuqsSearchParams().has(ALL_ENDPOINTS_EXPRESSION_KEY)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { parseAsString, useQueryStates } from 'nuqs';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import {
|
||||
ALL_ENDPOINTS_EXPRESSION_KEY,
|
||||
ENDPOINT_STATS_EXPRESSION_KEY,
|
||||
} from '../../../filterParamKeys';
|
||||
import {
|
||||
getLegacyFilterExpressions,
|
||||
useApiMonitoringParams,
|
||||
} from '../../../queryParams';
|
||||
|
||||
/**
|
||||
* Moves the filters an older link keeps inside `apiMonitoringParams` into the
|
||||
* drawer's own params, then rewrites `apiMonitoringParams` without them, so it
|
||||
* happens once.
|
||||
*/
|
||||
export function useConvertLegacyFilterLinks(): void {
|
||||
const [, setParams] = useApiMonitoringParams();
|
||||
const [, setDrawerFilters] = useQueryStates({
|
||||
[ALL_ENDPOINTS_EXPRESSION_KEY]: parseAsString,
|
||||
[ENDPOINT_STATS_EXPRESSION_KEY]: parseAsString,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const legacy = getLegacyFilterExpressions(
|
||||
`?${getUnstableCurrentSearchParams().toString()}`,
|
||||
);
|
||||
if (
|
||||
legacy.allEndpoints === undefined &&
|
||||
legacy.endPointDetails === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void setDrawerFilters({
|
||||
[ALL_ENDPOINTS_EXPRESSION_KEY]: legacy.allEndpoints || null,
|
||||
[ENDPOINT_STATS_EXPRESSION_KEY]: legacy.endPointDetails || null,
|
||||
});
|
||||
setParams({}, true);
|
||||
}, [setParams, setDrawerFilters]);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
|
||||
import {
|
||||
@@ -9,14 +11,20 @@ import {
|
||||
getFormattedEndPointDropDownData,
|
||||
getFormattedEndPointStatusCodeChartData,
|
||||
getFormattedEndPointStatusCodeData,
|
||||
getGroupByExpressionFromRowData,
|
||||
getHttpUrlExpression,
|
||||
getGroupByFiltersFromGroupByValues,
|
||||
getStatusCodeBarChartWidgetData,
|
||||
getTopErrorsColumnsConfig,
|
||||
getTopErrorsCoRelationQueryFilters,
|
||||
} from '../utils';
|
||||
import { APIMonitoringColumnsMock } from './mock';
|
||||
|
||||
// Mock or define DataTypes since it seems to be missing from imports
|
||||
const DataTypes = {
|
||||
String: 'string',
|
||||
Float64: 'float64',
|
||||
bool: 'bool',
|
||||
};
|
||||
|
||||
// Mock the external utils dependencies that are used within our tested functions
|
||||
jest.mock('../utils', () => {
|
||||
// Import the actual module to partial mock
|
||||
@@ -82,39 +90,140 @@ describe('API Monitoring Utils', () => {
|
||||
expect(result[0].lastUsed).toBe('-');
|
||||
});
|
||||
});
|
||||
describe('getGroupByExpressionFromRowData', () => {
|
||||
it('builds an AND expression from the row values, skipping the url and metric columns', () => {
|
||||
expect(
|
||||
getGroupByExpressionFromRowData({
|
||||
http_url: '/api/test',
|
||||
key: '3f2a-row-uuid',
|
||||
A: 10,
|
||||
B: 20,
|
||||
C: 1,
|
||||
F1: 5,
|
||||
'service.name': 'api-service',
|
||||
'http.status_code': 200,
|
||||
}),
|
||||
).toBe("service.name = 'api-service' AND http.status_code = 200");
|
||||
});
|
||||
describe('getGroupByFiltersFromGroupByValues', () => {
|
||||
it('should convert row data to filters correctly', () => {
|
||||
// Arrange
|
||||
const rowData = {
|
||||
'http.method': 'GET',
|
||||
'http.status_code': '200',
|
||||
'service.name': 'api-service',
|
||||
// Fields that should be filtered out
|
||||
data: 'someValue',
|
||||
key: 'someKey',
|
||||
};
|
||||
|
||||
it('escapes quotes in values', () => {
|
||||
expect(getGroupByExpressionFromRowData({ 'user.name': "o'brien" })).toBe(
|
||||
"user.name = 'o\\'brien'",
|
||||
const groupBy = [
|
||||
{
|
||||
id: 'group-by-1',
|
||||
key: 'http.method',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
{
|
||||
id: 'group-by-2',
|
||||
key: 'http.status_code',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
{
|
||||
id: 'group-by-3',
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = getGroupByFiltersFromGroupByValues(
|
||||
rowData,
|
||||
groupBy as BaseAutocompleteData[],
|
||||
);
|
||||
});
|
||||
|
||||
it('matches spans without the attribute when the group has no value', () => {
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.op).toBe('AND');
|
||||
// The implementation includes all keys from rowData, not just those in groupBy
|
||||
expect(result?.items?.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Verify each filter matches the corresponding groupBy
|
||||
expect(
|
||||
getGroupByExpressionFromRowData({
|
||||
'service.name': 'cart',
|
||||
'ai.embeddings': null,
|
||||
}),
|
||||
).toBe("service.name = 'cart' AND ai.embeddings NOT EXISTS");
|
||||
result?.items?.some(
|
||||
(item) =>
|
||||
item.key &&
|
||||
item.key.key === 'http.method' &&
|
||||
item.value === 'GET' &&
|
||||
item.op === '=',
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
result?.items?.some(
|
||||
(item) =>
|
||||
item.key &&
|
||||
item.key.key === 'http.status_code' &&
|
||||
item.value === '200' &&
|
||||
item.op === '=',
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
result?.items?.some(
|
||||
(item) =>
|
||||
item.key &&
|
||||
item.key.key === 'service.name' &&
|
||||
item.value === 'api-service' &&
|
||||
item.op === '=',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an empty expression for a row without group columns', () => {
|
||||
expect(getGroupByExpressionFromRowData({ http_url: '/x', A: 1 })).toBe('');
|
||||
it('should handle fields not in groupBy', () => {
|
||||
// Arrange
|
||||
const rowData = {
|
||||
'http.method': 'GET',
|
||||
'unknown.field': 'someValue',
|
||||
};
|
||||
|
||||
const groupBy = [
|
||||
{
|
||||
id: 'group-by-1',
|
||||
key: 'http.method',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
];
|
||||
// Act
|
||||
const result = getGroupByFiltersFromGroupByValues(
|
||||
rowData,
|
||||
groupBy as BaseAutocompleteData[],
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
// The implementation includes all keys from rowData, not just those in groupBy
|
||||
expect(result?.items?.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Should include the known field with the proper dataType from groupBy
|
||||
const knownField = result?.items?.find(
|
||||
(item) => item.key && item.key.key === 'http.method',
|
||||
);
|
||||
expect(knownField).toBeDefined();
|
||||
if (knownField && knownField.key) {
|
||||
expect(knownField.key.dataType).toBe(DataTypes.String);
|
||||
}
|
||||
|
||||
// Should include the unknown field
|
||||
const unknownField = result?.items?.find(
|
||||
(item) => item.key && item.key.key === 'unknown.field',
|
||||
);
|
||||
expect(unknownField).toBeDefined();
|
||||
if (unknownField && unknownField.key) {
|
||||
expect(unknownField.key.dataType).toBe(DataTypes.String); // Default
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
// Arrange
|
||||
const rowData = {};
|
||||
const groupBy: any[] = [];
|
||||
|
||||
// Act
|
||||
const result = getGroupByFiltersFromGroupByValues(rowData, groupBy);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.op).toBe('AND');
|
||||
expect(result?.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -594,9 +703,13 @@ describe('API Monitoring Utils', () => {
|
||||
it('should generate widget configuration for status code bar chart', () => {
|
||||
// Arrange
|
||||
const domainName = 'test-domain';
|
||||
const filters = { items: [], op: 'AND' };
|
||||
|
||||
// Act
|
||||
const result = getStatusCodeBarChartWidgetData(domainName, '');
|
||||
const result = getStatusCodeBarChartWidgetData(
|
||||
domainName,
|
||||
filters as IBuilderQuery['filters'],
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
@@ -610,29 +723,47 @@ describe('API Monitoring Utils', () => {
|
||||
const queryData = result.query.builder.queryData[0];
|
||||
|
||||
// Should have domain filter
|
||||
expect(queryData.filter?.expression).toBe(
|
||||
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}'`,
|
||||
const domainFilter = queryData.filters?.items?.find(
|
||||
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
);
|
||||
expect(domainFilter).toBeDefined();
|
||||
if (domainFilter) {
|
||||
expect(domainFilter.value).toBe(domainName);
|
||||
}
|
||||
});
|
||||
|
||||
it('should include custom filters in the widget configuration', () => {
|
||||
// Arrange
|
||||
const domainName = 'test-domain';
|
||||
const customFilterExpression = "custom.key = 'custom-value'";
|
||||
const customFilter = {
|
||||
id: 'custom-filter',
|
||||
key: {
|
||||
dataType: 'string',
|
||||
key: 'custom.key',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: 'custom-value',
|
||||
};
|
||||
const filters = { items: [customFilter], op: 'AND' };
|
||||
|
||||
// Act
|
||||
const result = getStatusCodeBarChartWidgetData(
|
||||
domainName,
|
||||
customFilterExpression,
|
||||
filters as IBuilderQuery['filters'],
|
||||
);
|
||||
|
||||
// Assert
|
||||
const queryData = result.query.builder.queryData[0];
|
||||
|
||||
// Should include our custom filter after the domain filter
|
||||
expect(queryData.filter?.expression).toBe(
|
||||
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}' AND (${customFilterExpression})`,
|
||||
// Should include our custom filter
|
||||
const includedFilter = queryData.filters?.items?.find(
|
||||
(item) => item.id === 'custom-filter',
|
||||
);
|
||||
expect(includedFilter).toBeDefined();
|
||||
if (includedFilter) {
|
||||
expect(includedFilter.value).toBe('custom-value');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -796,17 +927,4 @@ describe('API Monitoring Utils', () => {
|
||||
expect(latencyColumn?.title).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHttpUrlExpression', () => {
|
||||
it('returns no clause when no endpoint is picked', () => {
|
||||
expect(getHttpUrlExpression('')).toBe('');
|
||||
});
|
||||
|
||||
it('quotes the endpoint and escapes apostrophes', () => {
|
||||
expect(getHttpUrlExpression('/x')).toBe("http_url = '/x'");
|
||||
expect(getHttpUrlExpression("/users/o'brien")).toBe(
|
||||
"http_url = '/users/o\\'brien'",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import {
|
||||
getAllEndpointsWidgetData,
|
||||
getGroupByExpressionFromRowData,
|
||||
getGroupByFiltersFromGroupByValues,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
|
||||
import {
|
||||
ALL_ENDPOINTS_EXPRESSION_KEY,
|
||||
ENDPOINT_STATS_EXPRESSION_KEY,
|
||||
} from 'container/ApiMonitoring/filterParamKeys';
|
||||
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
|
||||
import AllEndPoints from '../Explorer/Domains/DomainDetails/AllEndPoints';
|
||||
import { FilterProviders } from './FilterProviders';
|
||||
import {
|
||||
SPAN_ATTRIBUTES,
|
||||
VIEWS,
|
||||
@@ -20,7 +14,7 @@ import {
|
||||
// Mock the dependencies
|
||||
jest.mock('container/ApiMonitoring/utils', () => ({
|
||||
getAllEndpointsWidgetData: jest.fn(),
|
||||
getGroupByExpressionFromRowData: jest.fn(),
|
||||
getGroupByFiltersFromGroupByValues: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/WidgetCard/Card', () => ({
|
||||
@@ -40,47 +34,53 @@ jest.mock('container/WidgetCard/Card', () => ({
|
||||
)),
|
||||
}));
|
||||
|
||||
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(({ onRun }) => (
|
||||
<div data-testid="query-builder-mock">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="filter-change-button"
|
||||
onClick={(): void => onRun("test = 'test'")}
|
||||
>
|
||||
Change Filter
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
jest.mock(
|
||||
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(({ onChange }) => (
|
||||
<div data-testid="query-builder-mock">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="filter-change-button"
|
||||
onClick={(): void =>
|
||||
onChange({
|
||||
items: [{ id: 'test', key: 'test', op: '=', value: 'test' }],
|
||||
op: 'AND',
|
||||
})
|
||||
}
|
||||
>
|
||||
Change Filter
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('hooks/queryBuilder/useGetAggregateKeys', () => ({
|
||||
useGetAggregateKeys: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/QueryBuilder/filters/GroupByFilter/GroupByFilter', () => ({
|
||||
GroupByFilter: jest.fn().mockImplementation(({ onChange }) => (
|
||||
<div data-testid="select-mock">
|
||||
<button
|
||||
data-testid="select-change-button"
|
||||
type="button"
|
||||
onClick={(): void =>
|
||||
onChange([
|
||||
{
|
||||
id: 'http.status_code--string--attribute',
|
||||
key: 'http.status_code',
|
||||
dataType: 'string',
|
||||
type: 'attribute',
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
Change GroupBy
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
jest.mock('antd', () => {
|
||||
const originalModule = jest.requireActual('antd');
|
||||
return {
|
||||
...originalModule,
|
||||
Select: jest.fn().mockImplementation(({ onChange }) => (
|
||||
<div data-testid="select-mock">
|
||||
<button
|
||||
data-testid="select-change-button"
|
||||
type="button"
|
||||
onClick={(): void => onChange(['http.status_code'])}
|
||||
>
|
||||
Change GroupBy
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock useApiMonitoringParams hook
|
||||
jest.mock('container/ApiMonitoring/queryParams', () => ({
|
||||
...jest.requireActual('container/ApiMonitoring/queryParams'),
|
||||
useApiMonitoringParams: jest.fn().mockReturnValue([
|
||||
{
|
||||
showIP: true,
|
||||
@@ -88,6 +88,8 @@ jest.mock('container/ApiMonitoring/queryParams', () => ({
|
||||
selectedView: 'all_endpoints',
|
||||
selectedEndPointName: '',
|
||||
groupBy: [],
|
||||
allEndpointsLocalFilters: undefined,
|
||||
endPointDetailsLocalFilters: undefined,
|
||||
modalTimeRange: undefined,
|
||||
selectedInterval: undefined,
|
||||
},
|
||||
@@ -106,21 +108,29 @@ describe('AllEndPoints', () => {
|
||||
startTime: 1609459200000,
|
||||
endTime: 1609545600000,
|
||||
},
|
||||
};
|
||||
|
||||
const renderAllEndPoints = (): void => {
|
||||
render(
|
||||
<FilterProviders queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}>
|
||||
<AllEndPoints {...mockProps} />
|
||||
</FilterProviders>,
|
||||
);
|
||||
initialFilters: { op: 'AND', items: [] },
|
||||
setInitialFiltersEndPointStats: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetNuqsState();
|
||||
|
||||
// Setup mock implementations
|
||||
(useGetAggregateKeys as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
payload: {
|
||||
attributeKeys: [
|
||||
{
|
||||
key: 'http.status_code',
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
(getAllEndpointsWidgetData as jest.Mock).mockReturnValue({
|
||||
id: 'test-widget',
|
||||
title: 'Endpoint Overview',
|
||||
@@ -129,9 +139,10 @@ describe('AllEndPoints', () => {
|
||||
queryData: [],
|
||||
});
|
||||
|
||||
(getGroupByExpressionFromRowData as jest.Mock).mockReturnValue(
|
||||
"status = '200'",
|
||||
);
|
||||
(getGroupByFiltersFromGroupByValues as jest.Mock).mockReturnValue({
|
||||
items: [{ id: 'group-filter', key: 'status', op: '=', value: '200' }],
|
||||
op: 'AND',
|
||||
});
|
||||
});
|
||||
|
||||
// Add cleanup after each test
|
||||
@@ -140,7 +151,7 @@ describe('AllEndPoints', () => {
|
||||
});
|
||||
|
||||
it('renders component correctly', () => {
|
||||
renderAllEndPoints();
|
||||
render(<AllEndPoints {...mockProps} />);
|
||||
|
||||
// Verify basic component rendering
|
||||
expect(screen.getByText('Group by')).toBeInTheDocument();
|
||||
@@ -150,7 +161,7 @@ describe('AllEndPoints', () => {
|
||||
});
|
||||
|
||||
it('handles filter changes', () => {
|
||||
renderAllEndPoints();
|
||||
render(<AllEndPoints {...mockProps} />);
|
||||
|
||||
// Trigger filter change
|
||||
fireEvent.click(screen.getByTestId('filter-change-button'));
|
||||
@@ -159,24 +170,25 @@ describe('AllEndPoints', () => {
|
||||
expect(getAllEndpointsWidgetData).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'test-domain',
|
||||
"test = 'test'",
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([expect.objectContaining({ id: 'test' })]),
|
||||
op: 'AND',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles group by changes', () => {
|
||||
renderAllEndPoints();
|
||||
render(<AllEndPoints {...mockProps} />);
|
||||
|
||||
// Trigger group by change
|
||||
fireEvent.click(screen.getByTestId('select-change-button'));
|
||||
|
||||
// The picked keys are stored as given and written to the URL as bare names
|
||||
expect(mockProps.setGroupBy).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ key: 'http.status_code', type: 'attribute' }),
|
||||
]);
|
||||
// Check if setGroupBy was called with updated group by value
|
||||
expect(mockProps.setGroupBy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles row click in grid card', async () => {
|
||||
renderAllEndPoints();
|
||||
render(<AllEndPoints {...mockProps} />);
|
||||
|
||||
// Trigger row click
|
||||
fireEvent.click(screen.getByTestId('row-click-button'));
|
||||
@@ -184,11 +196,7 @@ describe('AllEndPoints', () => {
|
||||
// Check if proper functions were called
|
||||
expect(mockProps.setSelectedEndPointName).toHaveBeenCalledWith('/api/test');
|
||||
expect(mockProps.setSelectedView).toHaveBeenCalledWith(VIEWS.ENDPOINT_STATS);
|
||||
expect(getGroupByExpressionFromRowData).toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(getCurrentNuqsSearchParams().get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
|
||||
"status = '200'",
|
||||
),
|
||||
);
|
||||
expect(mockProps.setInitialFiltersEndPointStats).toHaveBeenCalled();
|
||||
expect(getGroupByFiltersFromGroupByValues).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,12 +14,16 @@ import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
|
||||
|
||||
describe('AllEndpointsWidget - V5 Migration Validation', () => {
|
||||
const mockDomainName = 'api.example.com';
|
||||
const emptyFilters = '';
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyGroupBy: BaseAutocompleteData[] = [];
|
||||
|
||||
describe('1. V5 Format Migration - All Four Queries', () => {
|
||||
|
||||
@@ -121,7 +121,8 @@ describe('DependentServices', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the table in place while loading', () => {
|
||||
it('renders loading state correctly', () => {
|
||||
// Arrange
|
||||
const mockQuery: MockQueryResult = {
|
||||
isLoading: true,
|
||||
isRefetching: false,
|
||||
@@ -130,15 +131,16 @@ describe('DependentServices', () => {
|
||||
refetch: refetchFn,
|
||||
};
|
||||
|
||||
render(
|
||||
// Act
|
||||
const { container } = render(
|
||||
<DependentServices
|
||||
dependentServicesQuery={mockQuery as any}
|
||||
timeRange={mockTimeRange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('dependent-services-table')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
|
||||
// Assert
|
||||
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error state correctly', () => {
|
||||
@@ -223,7 +225,8 @@ describe('DependentServices', () => {
|
||||
expect(screen.getByTestId('page-size')).toHaveTextContent('5');
|
||||
});
|
||||
|
||||
it('shows the rows under the spinner while refetching', () => {
|
||||
it('handles refetching state correctly', () => {
|
||||
// Arrange
|
||||
const mockQuery: MockQueryResult = {
|
||||
isLoading: false,
|
||||
isRefetching: true,
|
||||
@@ -232,16 +235,16 @@ describe('DependentServices', () => {
|
||||
refetch: refetchFn,
|
||||
};
|
||||
|
||||
render(
|
||||
// Act
|
||||
const { container } = render(
|
||||
<DependentServices
|
||||
dependentServicesQuery={mockQuery as any}
|
||||
timeRange={mockTimeRange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
|
||||
expect(screen.getByTestId('row-count')).toHaveTextContent('2');
|
||||
expect(screen.getByTestId('table-row-0')).toHaveTextContent('auth-service');
|
||||
// Assert
|
||||
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles row click correctly', () => {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { VIEWS } from '../Explorer/Domains/DomainDetails/constants';
|
||||
import DomainDetails from '../Explorer/Domains/DomainDetails/DomainDetails';
|
||||
|
||||
jest.mock('container/ApiMonitoring/queryParams', () => ({
|
||||
...jest.requireActual('container/ApiMonitoring/queryParams'),
|
||||
useApiMonitoringParams: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
jest.mock('../Explorer/Domains/DomainDetails/components/DomainMetrics', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
jest.mock('../Explorer/Domains/DomainDetails/EndPointDetails', () => {
|
||||
const { useInitialExpression } = jest.requireActual(
|
||||
'components/QueryBuilderV2',
|
||||
);
|
||||
return {
|
||||
__esModule: true,
|
||||
default: function MockEndPointDetails(): JSX.Element {
|
||||
return (
|
||||
<span data-testid="endpoint-stats-scope">{useInitialExpression()}</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('DomainDetails', () => {
|
||||
it('scopes Endpoint Stats to the domain and the picked endpoint', () => {
|
||||
jest
|
||||
.mocked(useApiMonitoringParams)
|
||||
.mockReturnValue([
|
||||
{ selectedView: VIEWS.ENDPOINT_STATS, selectedEndPointName: '/v1/charges' },
|
||||
jest.fn(),
|
||||
]);
|
||||
|
||||
render(
|
||||
<DomainDetails
|
||||
domainData={{ domainName: 'api.example.com' }}
|
||||
handleClose={jest.fn()}
|
||||
selectedDomainIndex={0}
|
||||
setSelectedDomainIndex={jest.fn()}
|
||||
domainListLength={1}
|
||||
domainListFilterExpression=""
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('endpoint-stats-scope')).toHaveTextContent(
|
||||
"kind_string = 'Client' AND http_host = 'api.example.com' AND http_url = '/v1/charges'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import DomainList from '../Explorer/Domains/DomainList';
|
||||
|
||||
const FILTER = "http_host = 'api.example.com'";
|
||||
|
||||
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
|
||||
__esModule: true,
|
||||
default: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
({
|
||||
onChange,
|
||||
onRun,
|
||||
}: {
|
||||
onChange: (query: string) => void;
|
||||
onRun?: (query: string) => void;
|
||||
}) => (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="query-search-type"
|
||||
onClick={(): void => onChange("http_host = 'api.example.com'")}
|
||||
>
|
||||
Type
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="query-search-run"
|
||||
onClick={(): void => onRun?.("http_host = 'api.example.com'")}
|
||||
>
|
||||
Run
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('container/Toolbar/Toolbar', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
jest.mock('../Explorer/Domains/DomainDetails/DomainDetails', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
// Under the test router it keeps rewriting the URL, which re-renders the page
|
||||
// without end.
|
||||
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
|
||||
useShareBuilderUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('DomainList', () => {
|
||||
it('runs the typed filter from the filter box', async () => {
|
||||
let listRequests = 0;
|
||||
server.use(
|
||||
rest.post('*/third-party-apis/overview/list', (_req, res, ctx) => {
|
||||
listRequests += 1;
|
||||
return res(ctx.status(200), ctx.json({ data: { data: { results: [] } } }));
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<DomainList />, undefined, {
|
||||
initialRoute: '/api-monitoring/explorer',
|
||||
});
|
||||
await waitFor(() => expect(listRequests).toBe(1));
|
||||
|
||||
await user.click(screen.getByTestId('query-search-type'));
|
||||
await user.click(screen.getByTestId('query-search-run'));
|
||||
|
||||
await waitFor(() => expect(listRequests).toBe(2));
|
||||
const [url] = safeNavigateMock.mock.calls[0];
|
||||
const compositeQuery = new URLSearchParams(String(url).split('?')[1]).get(
|
||||
'compositeQuery',
|
||||
);
|
||||
expect(decodeURIComponent(compositeQuery ?? '')).toContain(
|
||||
`"expression":"${FILTER}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useQueries } from 'react-query';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { ENDPOINT_STATS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import {
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
|
||||
extractPortAndEndpoint,
|
||||
@@ -13,13 +11,14 @@ import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
TagFilter,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
|
||||
import EndPointPicker from '../Explorer/Domains/DomainDetails/components/EndPointPicker';
|
||||
import EndPointDetails from '../Explorer/Domains/DomainDetails/EndPointDetails';
|
||||
import { FilterProviders } from './FilterProviders';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('react-query', () => ({
|
||||
@@ -27,32 +26,72 @@ jest.mock('react-query', () => ({
|
||||
useQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock useApiMonitoringParams hook
|
||||
jest.mock('container/ApiMonitoring/queryParams', () => ({
|
||||
useApiMonitoringParams: jest.fn().mockReturnValue([
|
||||
{
|
||||
showIP: true,
|
||||
selectedDomain: '',
|
||||
selectedView: 'all_endpoints',
|
||||
selectedEndPointName: '',
|
||||
groupBy: [],
|
||||
allEndpointsLocalFilters: undefined,
|
||||
endPointDetailsLocalFilters: undefined,
|
||||
modalTimeRange: undefined,
|
||||
selectedInterval: undefined,
|
||||
},
|
||||
jest.fn(),
|
||||
]),
|
||||
}));
|
||||
|
||||
jest.mock('container/ApiMonitoring/utils', () => ({
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY: jest.requireActual(
|
||||
'container/ApiMonitoring/utils',
|
||||
).END_POINT_DETAILS_QUERY_KEYS_ARRAY,
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY: [
|
||||
'endPointMetricsData',
|
||||
'endPointStatusCodeData',
|
||||
'endPointDropDownData',
|
||||
'endPointDependentServicesData',
|
||||
'endPointStatusCodeBarChartsData',
|
||||
'endPointStatusCodeLatencyBarChartsData',
|
||||
],
|
||||
extractPortAndEndpoint: jest.fn(),
|
||||
getHttpUrlExpression: jest.requireActual('container/ApiMonitoring/utils')
|
||||
.getHttpUrlExpression,
|
||||
getEndPointDetailsQueryPayload: jest.fn(),
|
||||
getLatencyOverTimeWidgetData: jest.fn(),
|
||||
getRateOverTimeWidgetData: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(({ onRun }) => (
|
||||
<div data-testid="query-builder-search">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="filter-change-button"
|
||||
onClick={(): void => onRun("test.key = 'test-value'")}
|
||||
>
|
||||
Change Filter
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
jest.mock(
|
||||
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(({ onChange }) => (
|
||||
<div data-testid="query-builder-search">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="filter-change-button"
|
||||
onClick={(): void =>
|
||||
onChange({
|
||||
items: [
|
||||
{
|
||||
id: 'test-filter',
|
||||
key: {
|
||||
key: 'test.key',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: 'test-value',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
})
|
||||
}
|
||||
>
|
||||
Change Filter
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock all child components to simplify testing
|
||||
jest.mock(
|
||||
@@ -68,15 +107,15 @@ jest.mock(
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'../Explorer/Domains/DomainDetails/components/EndPointPicker',
|
||||
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(({ onChange }) => (
|
||||
default: jest.fn().mockImplementation(({ setSelectedEndPointName }) => (
|
||||
<div data-testid="endpoints-dropdown">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="select-endpoint-button"
|
||||
onClick={(): void => onChange('/api/new-endpoint')}
|
||||
onClick={(): void => setSelectedEndPointName('/api/new-endpoint')}
|
||||
>
|
||||
Select Endpoint
|
||||
</button>
|
||||
@@ -133,23 +172,8 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
function renderStats(ui: JSX.Element, linkFilter = ''): void {
|
||||
render(
|
||||
<FilterProviders
|
||||
queryParamKey={ENDPOINT_STATS_EXPRESSION_KEY}
|
||||
searchParams={
|
||||
linkFilter
|
||||
? `${ENDPOINT_STATS_EXPRESSION_KEY}=${encodeURIComponent(linkFilter)}`
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{ui}
|
||||
</FilterProviders>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('EndPointDetails Component', () => {
|
||||
const mockQueryResults = Array(5).fill({
|
||||
const mockQueryResults = Array(6).fill({
|
||||
data: { data: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -160,6 +184,7 @@ describe('EndPointDetails Component', () => {
|
||||
domainName: 'test-domain',
|
||||
endPointName: '/api/test',
|
||||
setSelectedEndPointName: jest.fn(),
|
||||
initialFilters: { items: [], op: 'AND' } as TagFilter,
|
||||
timeRange: {
|
||||
startTime: 1609459200000,
|
||||
endTime: 1609545600000,
|
||||
@@ -172,7 +197,6 @@ describe('EndPointDetails Component', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetNuqsState();
|
||||
|
||||
(extractPortAndEndpoint as jest.Mock).mockReturnValue({
|
||||
port: '8080',
|
||||
@@ -185,6 +209,7 @@ describe('EndPointDetails Component', () => {
|
||||
{ id: 'query3', label: 'Query 3' },
|
||||
{ id: 'query4', label: 'Query 4' },
|
||||
{ id: 'query5', label: 'Query 5' },
|
||||
{ id: 'query6', label: 'Query 6' },
|
||||
]);
|
||||
|
||||
(getRateOverTimeWidgetData as jest.Mock).mockReturnValue({
|
||||
@@ -201,7 +226,7 @@ describe('EndPointDetails Component', () => {
|
||||
});
|
||||
|
||||
it('renders the component correctly', () => {
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
|
||||
// Check all major components are rendered
|
||||
expect(screen.getByTestId('query-builder-search')).toBeInTheDocument();
|
||||
@@ -221,29 +246,44 @@ describe('EndPointDetails Component', () => {
|
||||
});
|
||||
|
||||
it('calls getEndPointDetailsQueryPayload with correct parameters', () => {
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
|
||||
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
|
||||
'test-domain',
|
||||
mockProps.timeRange.startTime,
|
||||
mockProps.timeRange.endTime,
|
||||
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`,
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
|
||||
value: '/api/test',
|
||||
}),
|
||||
]),
|
||||
op: 'AND',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds endpoint filter to initial filters', () => {
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
|
||||
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
|
||||
value: '/api/test',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('updates filters when the search expression is run', async () => {
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
it('updates filters when QueryBuilderSearch changes', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
|
||||
// Trigger filter change
|
||||
fireEvent.click(screen.getByTestId('filter-change-button'));
|
||||
@@ -254,37 +294,19 @@ describe('EndPointDetails Component', () => {
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test' AND (test.key = 'test-value')`,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(getCurrentNuqsSearchParams().get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
|
||||
"test.key = 'test-value'",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the endpoint out of the editor and the picker options', () => {
|
||||
renderStats(<EndPointDetails {...mockProps} />, "service.name = 'checkout'");
|
||||
|
||||
expect(QuerySearch).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
queryData: expect.objectContaining({
|
||||
filter: { expression: "service.name = 'checkout'" },
|
||||
}),
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'test.key' }),
|
||||
value: 'test-value',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(EndPointPicker).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
filterExpression: "service.name = 'checkout'",
|
||||
value: '/api/test',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles endpoint dropdown selection', () => {
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
|
||||
// Trigger endpoint selection
|
||||
fireEvent.click(screen.getByTestId('select-endpoint-button'));
|
||||
@@ -296,50 +318,63 @@ describe('EndPointDetails Component', () => {
|
||||
});
|
||||
|
||||
it('does not display dependent services when service filter is applied', () => {
|
||||
renderStats(
|
||||
<EndPointDetails {...mockProps} />,
|
||||
"service.name = 'test-service'",
|
||||
);
|
||||
const propsWithServiceFilter = {
|
||||
...mockProps,
|
||||
initialFilters: {
|
||||
items: [
|
||||
{
|
||||
id: 'service-filter',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: 'test-service',
|
||||
},
|
||||
] as TagFilterItem[],
|
||||
op: 'AND',
|
||||
} as TagFilter,
|
||||
};
|
||||
|
||||
render(<EndPointDetails {...propsWithServiceFilter} />);
|
||||
|
||||
// Dependent services should not be displayed
|
||||
expect(screen.queryByTestId('dependent-services')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes the correct parameters to widget data generators', () => {
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
|
||||
expect(getRateOverTimeWidgetData).toHaveBeenCalledWith(
|
||||
'test-domain',
|
||||
'/api/test',
|
||||
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
|
||||
value: '/api/test',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getLatencyOverTimeWidgetData).toHaveBeenCalledWith(
|
||||
'test-domain',
|
||||
'/api/test',
|
||||
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the previous dependent services while a new filter loads', () => {
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
const queries = jest.mocked(useQueries).mock.calls[0][0];
|
||||
expect(
|
||||
queries.map(({ queryKey, keepPreviousData }) => [
|
||||
(queryKey as unknown[])[0],
|
||||
keepPreviousData,
|
||||
]),
|
||||
).toStrictEqual(
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY.map((key) => [
|
||||
key,
|
||||
key === REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
|
||||
]),
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
|
||||
value: '/api/test',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates correct query parameters for useQueries', () => {
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
|
||||
// Check if useQueries was called with correct parameters
|
||||
expect(useQueries).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import {
|
||||
GetMetricQueryRange,
|
||||
GetQueryResultsProps,
|
||||
} from 'lib/dashboard/getQueryResults';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import EndPointPicker from '../Explorer/Domains/DomainDetails/components/EndPointPicker';
|
||||
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
|
||||
|
||||
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||
GetMetricQueryRange: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetMetricQueryRange = jest.mocked(GetMetricQueryRange);
|
||||
|
||||
const rowsResponse = (urls: string[]): MetricQueryRangeSuccessResponse => ({
|
||||
statusCode: 200,
|
||||
message: 'success',
|
||||
error: null,
|
||||
payload: {
|
||||
data: {
|
||||
resultType: '',
|
||||
newResult: { data: { result: [], resultType: '' } },
|
||||
result: [
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'A',
|
||||
values: [],
|
||||
table: {
|
||||
columns: [],
|
||||
rows: urls.map((url) => ({
|
||||
data: { [SPAN_ATTRIBUTES.HTTP_URL]: url, A: 1 },
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
domainName: 'api.example.com',
|
||||
timeRange: { startTime: 1000, endTime: 2000 },
|
||||
filterExpression: "service.name = 'checkout'",
|
||||
value: '',
|
||||
};
|
||||
|
||||
describe('EndPointPicker', () => {
|
||||
beforeEach(() => {
|
||||
mockGetMetricQueryRange.mockResolvedValue(
|
||||
rowsResponse(['/v1/charges', '/v1/refunds']),
|
||||
);
|
||||
});
|
||||
|
||||
it('loads options for the domain scoped by the user filter', async () => {
|
||||
render(<EndPointPicker {...baseProps} onChange={jest.fn()} />);
|
||||
|
||||
await waitFor(() => expect(mockGetMetricQueryRange).toHaveBeenCalled());
|
||||
const payload: GetQueryResultsProps =
|
||||
mockGetMetricQueryRange.mock.calls[0][0];
|
||||
expect(payload.query.builder.queryData[0].filter?.expression).toBe(
|
||||
`http_host = 'api.example.com' AND kind_string = 'Client' AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS AND (service.name = 'checkout')`,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the picked endpoint', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const onChange = jest.fn() as jest.MockedFunction<
|
||||
(endPointName: string) => void
|
||||
>;
|
||||
render(<EndPointPicker {...baseProps} onChange={onChange} />);
|
||||
|
||||
await waitFor(() => expect(mockGetMetricQueryRange).toHaveBeenCalled());
|
||||
await user.click(screen.getByTestId('endpoint-picker'));
|
||||
await user.click(await screen.findByText('/v1/refunds'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('/v1/refunds');
|
||||
});
|
||||
|
||||
it('shows All endpoints when none is picked and clears through it', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const onChange = jest.fn() as jest.MockedFunction<
|
||||
(endPointName: string) => void
|
||||
>;
|
||||
const { rerender } = render(
|
||||
<EndPointPicker {...baseProps} onChange={onChange} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('endpoint-picker')).toHaveTextContent(
|
||||
'All endpoints',
|
||||
);
|
||||
|
||||
rerender(
|
||||
<EndPointPicker {...baseProps} value="/v1/charges" onChange={onChange} />,
|
||||
);
|
||||
await waitFor(() => expect(mockGetMetricQueryRange).toHaveBeenCalled());
|
||||
await user.click(screen.getByTestId('endpoint-picker'));
|
||||
await user.click(
|
||||
await screen.findByRole('option', { name: 'All endpoints' }),
|
||||
);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('shows the selected endpoint', async () => {
|
||||
render(
|
||||
<EndPointPicker {...baseProps} value="/v1/charges" onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('/v1/charges')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { getFormattedEndPointDropDownData } from 'container/ApiMonitoring/utils';
|
||||
|
||||
import EndPointsDropDown from '../Explorer/Domains/DomainDetails/components/EndPointsDropDown';
|
||||
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
|
||||
|
||||
// Mock the Select component from antd
|
||||
jest.mock('antd', () => {
|
||||
const originalModule = jest.requireActual('antd');
|
||||
return {
|
||||
...originalModule,
|
||||
Select: jest
|
||||
.fn()
|
||||
.mockImplementation(({ value, loading, onChange, options, onClear }) => (
|
||||
<div data-testid="mock-select">
|
||||
<div data-testid="select-value">{value}</div>
|
||||
<div data-testid="select-loading">
|
||||
{loading ? 'loading' : 'not-loading'}
|
||||
</div>
|
||||
<select
|
||||
data-testid="select-element"
|
||||
value={value || ''}
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{options?.map((option: { value: string; label: string; key: string }) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button data-testid="select-clear-button" type="button" onClick={onClear}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the utilities
|
||||
jest.mock('container/ApiMonitoring/utils', () => ({
|
||||
getFormattedEndPointDropDownData: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('EndPointsDropDown Component', () => {
|
||||
const mockEndPoints = [
|
||||
{ key: '1', value: '/api/endpoint1', label: '/api/endpoint1' },
|
||||
{ key: '2', value: '/api/endpoint2', label: '/api/endpoint2' },
|
||||
];
|
||||
|
||||
const mockSetSelectedEndPointName = jest.fn();
|
||||
|
||||
// Create a mock that satisfies the UseQueryResult interface
|
||||
const createMockQueryResult = (overrides: any = {}): any => ({
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
table: {
|
||||
rows: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
dataUpdatedAt: 0,
|
||||
error: null,
|
||||
errorUpdatedAt: 0,
|
||||
failureCount: 0,
|
||||
isError: false,
|
||||
isFetched: true,
|
||||
isFetchedAfterMount: true,
|
||||
isFetching: false,
|
||||
isIdle: false,
|
||||
isLoading: false,
|
||||
isLoadingError: false,
|
||||
isPlaceholderData: false,
|
||||
isPreviousData: false,
|
||||
isRefetchError: false,
|
||||
isRefetching: false,
|
||||
isStale: false,
|
||||
isSuccess: true,
|
||||
refetch: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
status: 'success',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
selectedEndPointName: '',
|
||||
setSelectedEndPointName: mockSetSelectedEndPointName,
|
||||
endPointDropDownDataQuery: createMockQueryResult(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(getFormattedEndPointDropDownData as jest.Mock).mockReturnValue(
|
||||
mockEndPoints,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the component correctly', () => {
|
||||
render(<EndPointsDropDown {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId('mock-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('select-loading')).toHaveTextContent('not-loading');
|
||||
});
|
||||
|
||||
it('shows loading state when data is loading', () => {
|
||||
const loadingProps = {
|
||||
...defaultProps,
|
||||
endPointDropDownDataQuery: createMockQueryResult({
|
||||
isLoading: true,
|
||||
}),
|
||||
};
|
||||
|
||||
render(<EndPointsDropDown {...loadingProps} />);
|
||||
|
||||
expect(screen.getByTestId('select-loading')).toHaveTextContent('loading');
|
||||
});
|
||||
|
||||
it('shows loading state when data is fetching', () => {
|
||||
const fetchingProps = {
|
||||
...defaultProps,
|
||||
endPointDropDownDataQuery: createMockQueryResult({
|
||||
isFetching: true,
|
||||
}),
|
||||
};
|
||||
|
||||
render(<EndPointsDropDown {...fetchingProps} />);
|
||||
|
||||
expect(screen.getByTestId('select-loading')).toHaveTextContent('loading');
|
||||
});
|
||||
|
||||
it('displays the selected endpoint', () => {
|
||||
const selectedProps = {
|
||||
...defaultProps,
|
||||
selectedEndPointName: '/api/endpoint1',
|
||||
};
|
||||
|
||||
render(<EndPointsDropDown {...selectedProps} />);
|
||||
|
||||
expect(screen.getByTestId('select-value')).toHaveTextContent(
|
||||
'/api/endpoint1',
|
||||
);
|
||||
});
|
||||
|
||||
it('calls setSelectedEndPointName when an option is selected', () => {
|
||||
render(<EndPointsDropDown {...defaultProps} />);
|
||||
|
||||
// Get the select element and change its value
|
||||
const selectElement = screen.getByTestId('select-element');
|
||||
fireEvent.change(selectElement, { target: { value: '/api/endpoint2' } });
|
||||
|
||||
expect(mockSetSelectedEndPointName).toHaveBeenCalledWith('/api/endpoint2');
|
||||
});
|
||||
|
||||
it('calls setSelectedEndPointName with empty string when cleared', () => {
|
||||
render(<EndPointsDropDown {...defaultProps} />);
|
||||
|
||||
// Click the clear button
|
||||
const clearButton = screen.getByTestId('select-clear-button');
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(mockSetSelectedEndPointName).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('passes dropdown style prop correctly', () => {
|
||||
const styleProps = {
|
||||
...defaultProps,
|
||||
dropdownStyle: { maxHeight: '200px' },
|
||||
};
|
||||
|
||||
render(<EndPointsDropDown {...styleProps} />);
|
||||
|
||||
// We can't easily test style props in our mock, but at least ensure the component rendered
|
||||
expect(screen.getByTestId('mock-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats data using the utility function', () => {
|
||||
const mockRows = [
|
||||
{ data: { [SPAN_ATTRIBUTES.HTTP_URL]: '/api/test', A: 10 } },
|
||||
];
|
||||
|
||||
const dataProps = {
|
||||
...defaultProps,
|
||||
endPointDropDownDataQuery: createMockQueryResult({
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
table: {
|
||||
rows: mockRows,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
render(<EndPointsDropDown {...dataProps} />);
|
||||
|
||||
expect(getFormattedEndPointDropDownData).toHaveBeenCalledWith(mockRows);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* V5 Migration Tests for Endpoint Dropdown Query
|
||||
*
|
||||
* These tests validate the migration from V4 to V5 format for
|
||||
* getEndPointDropDownQueryPayload (endpoint picker options):
|
||||
* These tests validate the migration from V4 to V5 format for the third payload
|
||||
* in getEndPointDetailsQueryPayload (endpoint dropdown data):
|
||||
* - Filter format change: filters.items[] → filter.expression
|
||||
* - Domain handling: http_host = '${domainName}'
|
||||
* - Kind filter: kind_string = 'Client'
|
||||
@@ -10,8 +10,9 @@
|
||||
* - Aggregation: count() expression
|
||||
* - GroupBy: http_url with type 'attribute'
|
||||
*/
|
||||
import { getEndPointDropDownQueryPayload } from 'container/ApiMonitoring/utils';
|
||||
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
|
||||
|
||||
@@ -19,16 +20,22 @@ describe('EndpointDropdown - V5 Migration Validation', () => {
|
||||
const mockDomainName = 'api.example.com';
|
||||
const mockStartTime = 1000;
|
||||
const mockEndTime = 2000;
|
||||
const emptyFilters = '';
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
describe('1. V5 Format Migration - Structure and Base Filters', () => {
|
||||
it('migrates to V5 format with correct filter expression structure, aggregations, and groupBy', () => {
|
||||
const dropdownQuery = getEndPointDropDownQueryPayload(
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
mockStartTime,
|
||||
mockEndTime,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
// Third payload is the endpoint dropdown query (index 2)
|
||||
const dropdownQuery = payload[2];
|
||||
const queryA = dropdownQuery.query.builder.queryData[0];
|
||||
|
||||
// CRITICAL V5 MIGRATION: filter.expression (not filters.items)
|
||||
@@ -70,21 +77,46 @@ describe('EndpointDropdown - V5 Migration Validation', () => {
|
||||
|
||||
describe('2. Custom Filters Integration', () => {
|
||||
it('merges custom filters into filter expression with AND logic', () => {
|
||||
const customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
key: {
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'production',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const dropdownQuery = getEndPointDropDownQueryPayload(
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
mockStartTime,
|
||||
mockEndTime,
|
||||
customFilters,
|
||||
);
|
||||
|
||||
const dropdownQuery = payload[2];
|
||||
const expression =
|
||||
dropdownQuery.query.builder.queryData[0].filter?.expression;
|
||||
|
||||
// Exact filter expression with custom filters merged
|
||||
expect(expression).toBe(
|
||||
`${SPAN_ATTRIBUTES.SERVER_NAME} = 'api.example.com' AND kind_string = 'Client' AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS AND (service.name = 'user-service' AND deployment.environment = 'production')`,
|
||||
`${SPAN_ATTRIBUTES.SERVER_NAME} = 'api.example.com' AND kind_string = 'Client' AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS service.name = 'user-service' AND deployment.environment = 'production'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { QuerySearchV2Provider } from 'components/QueryBuilderV2';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { onNuqsUrlUpdate } from 'tests/nuqs-helpers';
|
||||
|
||||
/** Mounts a drawer tab's filter provider over an in-memory URL. */
|
||||
export function FilterProviders({
|
||||
queryParamKey,
|
||||
initialExpression,
|
||||
searchParams = '',
|
||||
children,
|
||||
}: {
|
||||
queryParamKey: string;
|
||||
initialExpression?: string;
|
||||
searchParams?: string;
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<NuqsTestingAdapter
|
||||
searchParams={searchParams}
|
||||
onUrlUpdate={onNuqsUrlUpdate}
|
||||
rateLimitFactor={0}
|
||||
hasMemory
|
||||
>
|
||||
<QuerySearchV2Provider
|
||||
queryParamKey={queryParamKey}
|
||||
initialExpression={initialExpression}
|
||||
persistOnUnmount
|
||||
>
|
||||
{children}
|
||||
</QuerySearchV2Provider>
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,16 @@ import {
|
||||
getLatencyOverTimeWidgetData,
|
||||
getRateOverTimeWidgetData,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
describe('MetricOverTime - V5 Migration Validation', () => {
|
||||
const mockDomainName = 'api.example.com';
|
||||
const mockEndpointName = '/api/users';
|
||||
const emptyFilters = '';
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
describe('1. Rate Over Time - V5 Payload Structure', () => {
|
||||
it('generates V5 filter expression format (not V3 filters.items)', () => {
|
||||
@@ -46,8 +51,31 @@ describe('MetricOverTime - V5 Migration Validation', () => {
|
||||
});
|
||||
|
||||
it('merges custom filters into filter expression', () => {
|
||||
const customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
key: {
|
||||
key: 'deployment.environment',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'production',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const widget = getRateOverTimeWidgetData(
|
||||
mockDomainName,
|
||||
@@ -109,8 +137,21 @@ describe('MetricOverTime - V5 Migration Validation', () => {
|
||||
});
|
||||
|
||||
it('merges custom filters into filter expression', () => {
|
||||
const customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const widget = getLatencyOverTimeWidgetData(
|
||||
mockDomainName,
|
||||
@@ -122,7 +163,7 @@ describe('MetricOverTime - V5 Migration Validation', () => {
|
||||
|
||||
// Verify domain filter is present
|
||||
expect(queryData?.filter?.expression).toContain(
|
||||
`http_host = '${mockDomainName}' AND (service.name = 'user-service'`,
|
||||
`http_host = '${mockDomainName}' service.name = 'user-service'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getStatusCodeBarChartWidgetData,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import ErrorState from '../Explorer/Domains/DomainDetails/components/ErrorState';
|
||||
import StatusCodeBarCharts from '../Explorer/Domains/DomainDetails/components/StatusCodeBarCharts';
|
||||
@@ -155,6 +156,7 @@ jest.mock('antd', () => {
|
||||
|
||||
describe('StatusCodeBarCharts', () => {
|
||||
// Default props for tests
|
||||
const mockFilters: IBuilderQuery['filters'] = { items: [], op: 'AND' };
|
||||
const mockTimeRange = {
|
||||
startTime: 1609459200000,
|
||||
endTime: 1609545600000,
|
||||
@@ -249,7 +251,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filterExpression=""
|
||||
filters={mockFilters}
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -284,7 +286,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filterExpression=""
|
||||
filters={mockFilters}
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -326,7 +328,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filterExpression=""
|
||||
filters={mockFilters}
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -370,7 +372,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filterExpression=""
|
||||
filters={mockFilters}
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -417,7 +419,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filterExpression=""
|
||||
filters={mockFilters}
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -431,13 +433,26 @@ describe('StatusCodeBarCharts', () => {
|
||||
// but we've confirmed the function is mocked and ready to be tested
|
||||
expect(getStatusCodeBarChartWidgetData).toHaveBeenCalledWith(
|
||||
mockDomainName,
|
||||
'',
|
||||
expect.objectContaining({
|
||||
items: [],
|
||||
op: 'AND',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles widget generation with current filters', () => {
|
||||
// Arrange
|
||||
const mockCustomFilterExpression = "test-key = 'test-value'";
|
||||
const mockCustomFilters = {
|
||||
items: [
|
||||
{
|
||||
id: 'custom-filter',
|
||||
key: { key: 'test-key' },
|
||||
op: '=',
|
||||
value: 'test-value',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const mockData = {
|
||||
payload: mockFormattedData,
|
||||
@@ -465,7 +480,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filterExpression={mockCustomFilterExpression}
|
||||
filters={mockCustomFilters as IBuilderQuery['filters']}
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -474,7 +489,12 @@ describe('StatusCodeBarCharts', () => {
|
||||
// Assert widget creation was called with the correct parameters
|
||||
expect(getStatusCodeBarChartWidgetData).toHaveBeenCalledWith(
|
||||
mockDomainName,
|
||||
mockCustomFilterExpression,
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'custom-filter' }),
|
||||
]),
|
||||
op: 'AND',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* V5 Migration Tests for Status Code Bar Chart Queries
|
||||
*
|
||||
* These tests validate the migration to V5 format for the bar chart payloads
|
||||
* in getEndPointDetailsQueryPayload (4th and 5th payloads):
|
||||
* in getEndPointDetailsQueryPayload (5th and 6th payloads):
|
||||
* - Number of Calls Chart (count aggregation)
|
||||
* - Latency Chart (p99 aggregation)
|
||||
*
|
||||
@@ -15,12 +15,16 @@
|
||||
*/
|
||||
import { TraceAggregation } from 'api/v5/v5';
|
||||
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
const mockDomainName = '0.0.0.0';
|
||||
const mockStartTime = 1762573673000;
|
||||
const mockEndTime = 1762832873000;
|
||||
const emptyFilters = '';
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
describe('1. Number of Calls Chart - V5 Payload Structure', () => {
|
||||
it('generates correct V5 payload for count aggregation grouped by status code', () => {
|
||||
@@ -31,8 +35,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
// 4th payload (index 3) is the number of calls bar chart
|
||||
const callsChartQuery = payload[3];
|
||||
// 5th payload (index 4) is the number of calls bar chart
|
||||
const callsChartQuery = payload[4];
|
||||
const queryA = callsChartQuery.query.builder.queryData[0];
|
||||
|
||||
// V5 format: filter.expression (not filters.items)
|
||||
@@ -80,8 +84,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
// 5th payload (index 4) is the latency bar chart
|
||||
const latencyChartQuery = payload[4];
|
||||
// 6th payload (index 5) is the latency bar chart
|
||||
const latencyChartQuery = payload[5];
|
||||
const queryA = latencyChartQuery.query.builder.queryData[0];
|
||||
|
||||
// V5 format: filter.expression (not filters.items)
|
||||
@@ -126,8 +130,31 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
|
||||
describe('3. Custom Filters Integration', () => {
|
||||
it('merges custom filters into filter expression for both charts', () => {
|
||||
const customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
key: {
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'production',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
@@ -136,8 +163,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
customFilters,
|
||||
);
|
||||
|
||||
const callsChartQuery = payload[3];
|
||||
const latencyChartQuery = payload[4];
|
||||
const callsChartQuery = payload[4];
|
||||
const latencyChartQuery = payload[5];
|
||||
|
||||
const callsExpression =
|
||||
callsChartQuery.query.builder.queryData[0].filter?.expression;
|
||||
|
||||
@@ -13,12 +13,16 @@
|
||||
*/
|
||||
import { TraceAggregation } from 'api/v5/v5';
|
||||
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
describe('StatusCodeTable - V5 Migration Validation', () => {
|
||||
const mockDomainName = 'api.example.com';
|
||||
const mockStartTime = 1000;
|
||||
const mockEndTime = 2000;
|
||||
const emptyFilters = '';
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
describe('1. V5 Format Migration with Base Filters', () => {
|
||||
it('migrates to V5 format with correct filter expression structure and base filters', () => {
|
||||
@@ -105,8 +109,31 @@ describe('StatusCodeTable - V5 Migration Validation', () => {
|
||||
|
||||
describe('3. Custom Filters Integration', () => {
|
||||
it('merges custom filters into filter expression with AND logic', () => {
|
||||
const customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
key: {
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'production',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
|
||||
@@ -1,40 +1,31 @@
|
||||
import { BuilderQuery } from 'api/v5/v5';
|
||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
|
||||
import TopErrors from '../Explorer/Domains/DomainDetails/TopErrors';
|
||||
import { getTopErrorsQueryPayload } from '../utils';
|
||||
|
||||
interface QueryRangeRequestBody {
|
||||
compositeQuery: { queries: { spec: { filter: { expression: string } } }[] };
|
||||
}
|
||||
|
||||
// Mock the EndPointPicker component to avoid issues
|
||||
// Mock the EndPointsDropDown component to avoid issues
|
||||
jest.mock(
|
||||
'../Explorer/Domains/DomainDetails/components/EndPointPicker',
|
||||
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(({ onChange }): JSX.Element => (
|
||||
<div data-testid="endpoints-dropdown-mock">
|
||||
<select
|
||||
data-testid="endpoints-select"
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
>
|
||||
<option value="/api/test">/api/test</option>
|
||||
<option value="/api/new-endpoint">/api/new-endpoint</option>
|
||||
</select>
|
||||
</div>
|
||||
)),
|
||||
default: jest
|
||||
.fn()
|
||||
.mockImplementation(({ setSelectedEndPointName }): JSX.Element => (
|
||||
<div data-testid="endpoints-dropdown-mock">
|
||||
<select
|
||||
data-testid="endpoints-select"
|
||||
onChange={(e): void => setSelectedEndPointName(e.target.value)}
|
||||
>
|
||||
<option value="/api/test">/api/test</option>
|
||||
<option value="/api/new-endpoint">/api/new-endpoint</option>
|
||||
</select>
|
||||
</div>
|
||||
)),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -52,7 +43,10 @@ describe('TopErrors', () => {
|
||||
startTime: 1000000000,
|
||||
endTime: 1000010000,
|
||||
},
|
||||
initialFilterExpression: '',
|
||||
initialFilters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
};
|
||||
|
||||
// Helper function to wait for table data to load
|
||||
@@ -233,30 +227,22 @@ describe('TopErrors', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes the query to the endpoint picked in the picker', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const expressions: string[] = [];
|
||||
server.use(
|
||||
rest.post(V5_QUERY_RANGE_API_PATH, async (req) => {
|
||||
const body: QueryRangeRequestBody = await req.json();
|
||||
expressions.push(body.compositeQuery.queries[0].spec.filter.expression);
|
||||
}),
|
||||
);
|
||||
|
||||
it('updates endpoint filter when dropdown value changes', async () => {
|
||||
render(<TopErrors {...mockProps} />);
|
||||
await waitFor(() => expect(expressions).toHaveLength(1));
|
||||
expect(expressions[0]).not.toContain(`${SPAN_ATTRIBUTES.HTTP_URL} =`);
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByTestId('endpoints-select'),
|
||||
'/api/new-endpoint',
|
||||
);
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(expressions[expressions.length - 1]).toContain(
|
||||
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/new-endpoint'`,
|
||||
);
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the dropdown
|
||||
const dropdown = screen.getByRole('combobox');
|
||||
|
||||
// Mock the change
|
||||
fireEvent.change(dropdown, { target: { value: '/api/new-endpoint' } });
|
||||
|
||||
// Component should re-render with new filter
|
||||
expect(dropdown).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles status message toggle correctly', async () => {
|
||||
@@ -320,7 +306,7 @@ describe('TopErrors', () => {
|
||||
'test-domain',
|
||||
mockProps.timeRange.startTime,
|
||||
mockProps.timeRange.endTime,
|
||||
'',
|
||||
{ items: [], op: 'AND' },
|
||||
false,
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
ApiMonitoringParams,
|
||||
DEFAULT_PARAMS,
|
||||
getApiMonitoringParams,
|
||||
getLegacyFilterExpressions,
|
||||
setApiMonitoringParams,
|
||||
} from 'container/ApiMonitoring/queryParams';
|
||||
import { mockNavigate } from 'tests/router-mocks';
|
||||
@@ -53,50 +52,6 @@ describe('API Monitoring Query Params', () => {
|
||||
|
||||
expect(getApiMonitoringParams(search)).toStrictEqual(DEFAULT_PARAMS);
|
||||
});
|
||||
|
||||
it('converts filter items saved by older links into an expression', () => {
|
||||
const legacyParams = {
|
||||
selectedDomain: 'test-domain',
|
||||
allEndpointsLocalFilters: {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: 'a',
|
||||
key: { key: 'service.name', dataType: 'string', type: 'resource' },
|
||||
op: '=',
|
||||
value: 'frontend',
|
||||
},
|
||||
],
|
||||
},
|
||||
endPointDetailsLocalFilters: {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: 'b',
|
||||
key: { key: 'http.status_code', dataType: 'string', type: '' },
|
||||
op: '=',
|
||||
value: '500',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set(
|
||||
'apiMonitoringParams',
|
||||
encodeURIComponent(JSON.stringify(legacyParams)),
|
||||
);
|
||||
|
||||
const search = `?${urlParams.toString()}`;
|
||||
|
||||
expect(getLegacyFilterExpressions(search)).toStrictEqual({
|
||||
allEndpoints: "service.name = 'frontend'",
|
||||
endPointDetails: "http.status_code = '500'",
|
||||
});
|
||||
const result = getApiMonitoringParams(search);
|
||||
expect(result.selectedDomain).toBe('test-domain');
|
||||
expect(result).not.toHaveProperty('allEndpointsLocalFilters');
|
||||
expect(result).not.toHaveProperty('endPointDetailsLocalFilters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setApiMonitoringParams', () => {
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { ALL_ENDPOINTS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
|
||||
import {
|
||||
getApiMonitoringParams,
|
||||
useApiMonitoringParams,
|
||||
} from 'container/ApiMonitoring/queryParams';
|
||||
import { mockAppLocation, mockNavigate } from 'tests/router-mocks';
|
||||
import { act, renderHook } from 'tests/test-utils';
|
||||
|
||||
// The stub writes to the browser URL, which the hook reads back; the router's
|
||||
// own location (`useAppLocation`) stays at its first value.
|
||||
function renderParamsHook(): ReturnType<
|
||||
typeof renderHook<ReturnType<typeof useApiMonitoringParams>, unknown>
|
||||
> {
|
||||
mockNavigate().mockImplementation((to) => {
|
||||
const url = typeof to === 'string' ? to : `/${to.search ?? ''}`;
|
||||
window.history.pushState(null, '', url);
|
||||
});
|
||||
mockAppLocation();
|
||||
return renderHook(() => useApiMonitoringParams());
|
||||
}
|
||||
|
||||
describe('useApiMonitoringParams', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
it('keeps every write made before the next render', () => {
|
||||
const { result } = renderParamsHook();
|
||||
|
||||
act(() => {
|
||||
const [, setParams] = result.current;
|
||||
setParams({ selectedView: 'endpoint_stats' });
|
||||
setParams({ selectedInterval: '15m' });
|
||||
});
|
||||
|
||||
expect(getApiMonitoringParams(window.location.search)).toMatchObject({
|
||||
selectedView: 'endpoint_stats',
|
||||
selectedInterval: '15m',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a drawer filter written outside the router', () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`/?${ALL_ENDPOINTS_EXPRESSION_KEY}=${encodeURIComponent(
|
||||
"service.name = 'cart'",
|
||||
)}`,
|
||||
);
|
||||
const { result } = renderParamsHook();
|
||||
|
||||
act(() => {
|
||||
const [, setParams] = result.current;
|
||||
setParams({ selectedView: 'endpoint_stats' });
|
||||
});
|
||||
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
expect(search.get(ALL_ENDPOINTS_EXPRESSION_KEY)).toBe(
|
||||
"service.name = 'cart'",
|
||||
);
|
||||
expect(getApiMonitoringParams(window.location.search).selectedView).toBe(
|
||||
'endpoint_stats',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
// The drawer filters live in their own URL params, owned by `QuerySearchV2Provider`.
|
||||
export const ALL_ENDPOINTS_EXPRESSION_KEY =
|
||||
'apiMonitoringAllEndpointsExpression';
|
||||
export const ENDPOINT_STATS_EXPRESSION_KEY =
|
||||
'apiMonitoringEndpointStatsExpression';
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { omit } from 'lodash-es';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
|
||||
// --- Types for all API Monitoring query params ---
|
||||
export interface ApiMonitoringParams {
|
||||
@@ -13,6 +9,8 @@ export interface ApiMonitoringParams {
|
||||
selectedView?: string;
|
||||
selectedEndPointName?: string;
|
||||
groupBy?: string[];
|
||||
allEndpointsLocalFilters?: any;
|
||||
endPointDetailsLocalFilters?: any;
|
||||
modalTimeRange?: { startTime: number; endTime: number };
|
||||
selectedInterval?: string;
|
||||
// Add more params as needed
|
||||
@@ -24,6 +22,8 @@ export const DEFAULT_PARAMS: ApiMonitoringParams = {
|
||||
selectedView: 'all_endpoints',
|
||||
selectedEndPointName: '',
|
||||
groupBy: [],
|
||||
allEndpointsLocalFilters: undefined,
|
||||
endPointDetailsLocalFilters: undefined,
|
||||
modalTimeRange: undefined,
|
||||
selectedInterval: undefined,
|
||||
};
|
||||
@@ -35,50 +35,15 @@ function encodeParams(params: ApiMonitoringParams): string {
|
||||
return encodeURIComponent(JSON.stringify(params));
|
||||
}
|
||||
|
||||
// Older shared links carry the filters as TagFilter items.
|
||||
type LegacyApiMonitoringParams = ApiMonitoringParams & {
|
||||
allEndpointsLocalFilters?: TagFilter;
|
||||
endPointDetailsLocalFilters?: TagFilter;
|
||||
};
|
||||
|
||||
function parseParams(value: string | null): LegacyApiMonitoringParams | null {
|
||||
function decodeParams(value: string | null): ApiMonitoringParams {
|
||||
if (!value) {
|
||||
return null;
|
||||
return DEFAULT_PARAMS;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Drops the legacy filters, so the next write leaves them out of the URL.
|
||||
function decodeParams(value: string | null): ApiMonitoringParams {
|
||||
const parsed = parseParams(value);
|
||||
if (!parsed) {
|
||||
return DEFAULT_PARAMS;
|
||||
}
|
||||
return omit(parsed, [
|
||||
'allEndpointsLocalFilters',
|
||||
'endPointDetailsLocalFilters',
|
||||
]);
|
||||
}
|
||||
|
||||
export interface LegacyFilterExpressions {
|
||||
allEndpoints?: string;
|
||||
endPointDetails?: string;
|
||||
}
|
||||
|
||||
export function getLegacyFilterExpressions(
|
||||
search: string,
|
||||
): LegacyFilterExpressions {
|
||||
const parsed = parseParams(new URLSearchParams(search).get(PARAM_KEY));
|
||||
const toExpression = (filters?: TagFilter): string | undefined =>
|
||||
filters ? convertFiltersToExpression(filters).expression : undefined;
|
||||
return {
|
||||
allEndpoints: toExpression(parsed?.allEndpointsLocalFilters),
|
||||
endPointDetails: toExpression(parsed?.endPointDetailsLocalFilters),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Read query params from URL ---
|
||||
@@ -109,18 +74,11 @@ export function useApiMonitoringParams(): [
|
||||
const location = useAppLocation();
|
||||
const params = getApiMonitoringParams(location.search);
|
||||
|
||||
// Read the browser's URL, not the router's: several writes can land before
|
||||
// the next render, and the drawer filters are written by nuqs, which the
|
||||
// router's location does not see.
|
||||
const setParams = useCallback(
|
||||
(newParams: Partial<ApiMonitoringParams>, replace = false) => {
|
||||
setApiMonitoringParams(
|
||||
newParams,
|
||||
`?${getUnstableCurrentSearchParams().toString()}`,
|
||||
replace,
|
||||
);
|
||||
setApiMonitoringParams(newParams, location.search, replace);
|
||||
},
|
||||
[],
|
||||
[location.search],
|
||||
);
|
||||
|
||||
return [params, setParams];
|
||||
|
||||
@@ -3,8 +3,7 @@ import { Color } from '@signozhq/design-tokens';
|
||||
import { TableColumnType as ColumnType, Tooltip } from 'antd';
|
||||
import { Progress } from '@signozhq/ui/progress';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
FiltersType,
|
||||
IQuickFiltersConfig,
|
||||
@@ -57,28 +56,30 @@ export const getDisplayValue = (value: unknown): string =>
|
||||
isEmptyFilterValue(value) ? '-' : String(value);
|
||||
|
||||
export const getDomainNameFilterExpression = (domainName: string): string =>
|
||||
`${SPAN_ATTRIBUTES.SERVER_NAME} = ${formatValueForExpression(domainName)}`;
|
||||
`http_host = '${domainName}'`;
|
||||
|
||||
export const clientKindExpression = "kind_string = 'Client'";
|
||||
export const clientKindExpression = `kind_string = 'Client'`;
|
||||
|
||||
export const getHttpUrlExpression = (endPointName: string): string =>
|
||||
endPointName
|
||||
? `${SPAN_ATTRIBUTES.HTTP_URL} = ${formatValueForExpression(endPointName, '=')}`
|
||||
: '';
|
||||
/**
|
||||
* Converts filters to expression
|
||||
* @param filters Filters to convert
|
||||
* @param baseExpression Base expression to combine with filters
|
||||
* @returns Filter expression string
|
||||
*/
|
||||
export const convertFiltersWithUrlHandling = (
|
||||
filters: IBuilderQuery['filters'],
|
||||
baseExpression: string,
|
||||
): string => {
|
||||
if (!filters) {
|
||||
return baseExpression;
|
||||
}
|
||||
|
||||
// The domain (and endpoint) the drawer is locked to, built from the same
|
||||
// expressions as the requests' filters so the chip shows what runs.
|
||||
export const getDomainScopeExpression = (
|
||||
domainName: string,
|
||||
endPointName = '',
|
||||
): string =>
|
||||
[
|
||||
clientKindExpression,
|
||||
getDomainNameFilterExpression(domainName),
|
||||
getHttpUrlExpression(endPointName),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' AND ');
|
||||
const { filter } = convertFiltersToExpressionWithExistingQuery(
|
||||
filters,
|
||||
baseExpression,
|
||||
);
|
||||
return filter.expression;
|
||||
};
|
||||
|
||||
export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
{
|
||||
@@ -346,7 +347,7 @@ export const getDomainMetricsQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
filterExpression: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
): GetQueryResultsProps[] => [
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
@@ -367,9 +368,9 @@ export const getDomainMetricsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(domainName)} AND ${urlExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'A',
|
||||
@@ -395,9 +396,9 @@ export const getDomainMetricsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(domainName)}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'B',
|
||||
@@ -423,9 +424,9 @@ export const getDomainMetricsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(domainName)} AND has_error = true`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'C',
|
||||
@@ -451,9 +452,9 @@ export const getDomainMetricsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(domainName)}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'D',
|
||||
@@ -823,7 +824,7 @@ export const getEndPointsQueryPayload = (
|
||||
|
||||
function buildFilterExpression(
|
||||
domainName: string,
|
||||
filterExpression: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
showStatusCodeErrors: boolean,
|
||||
): string {
|
||||
const baseFilterParts = [
|
||||
@@ -835,22 +836,21 @@ function buildFilterExpression(
|
||||
if (showStatusCodeErrors) {
|
||||
baseFilterParts.push('status_message EXISTS');
|
||||
}
|
||||
return combineInitialAndUserExpression(
|
||||
baseFilterParts.join(' AND '),
|
||||
filterExpression,
|
||||
);
|
||||
const filterExpression = baseFilterParts.join(' AND ');
|
||||
|
||||
return convertFiltersWithUrlHandling(filters, filterExpression);
|
||||
}
|
||||
|
||||
export const getTopErrorsQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
filterExpression: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
showStatusCodeErrors = true,
|
||||
): QueryRangePayloadV5 => {
|
||||
const combinedExpression = buildFilterExpression(
|
||||
const filterExpression = buildFilterExpression(
|
||||
domainName,
|
||||
filterExpression,
|
||||
filters,
|
||||
showStatusCodeErrors,
|
||||
);
|
||||
|
||||
@@ -869,7 +869,7 @@ export const getTopErrorsQueryPayload = (
|
||||
stepInterval: 60,
|
||||
disabled: false,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
filter: { expression: combinedExpression },
|
||||
filter: { expression: filterExpression },
|
||||
groupBy: [
|
||||
{
|
||||
name: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
@@ -1367,14 +1367,15 @@ export const createFiltersForSelectedRowData = (
|
||||
|
||||
// First query payload for endpoint metrics
|
||||
// Second query payload for endpoint status code table
|
||||
// Third query payload for endpoint dependant services
|
||||
// Fourth query payload for endpoint response status count bar chart
|
||||
// Fifth query payload for endpoint response status code latency bar chart
|
||||
// Third query payload for endpoint dropdown selection
|
||||
// Fourth query payload for endpoint dependant services
|
||||
// Fifth query payload for endpoint response status count bar chart
|
||||
// Sixth query payload for endpoint response status code latency bar chart
|
||||
export const getEndPointDetailsQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
filterExpression: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
): GetQueryResultsProps[] => [
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
@@ -1393,11 +1394,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1423,11 +1424,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1453,11 +1454,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: true,
|
||||
expression: 'C',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND has_error = true`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1483,11 +1484,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'D',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1513,11 +1514,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: true,
|
||||
expression: 'E',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1585,11 +1586,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1621,11 +1622,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1659,11 +1660,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'C',
|
||||
@@ -1712,6 +1713,77 @@ export const getEndPointDetailsQueryPayload = (
|
||||
end,
|
||||
step: 60,
|
||||
},
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TABLE,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'count',
|
||||
aggregations: [
|
||||
{
|
||||
expression: 'count()',
|
||||
},
|
||||
],
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS`,
|
||||
),
|
||||
},
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: null,
|
||||
having: [],
|
||||
legend: '',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [
|
||||
{
|
||||
key: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
dataType: DataTypes.String,
|
||||
type: 'attribute',
|
||||
},
|
||||
],
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
|
||||
promql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
variables: {},
|
||||
formatForWeb: true,
|
||||
start,
|
||||
end,
|
||||
step: 60,
|
||||
},
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TABLE,
|
||||
@@ -1729,11 +1801,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1766,11 +1838,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1803,11 +1875,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'C',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1840,11 +1912,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: true,
|
||||
expression: 'D',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND has_error = true`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1919,11 +1991,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1990,11 +2062,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2046,82 +2118,100 @@ export const getEndPointDetailsQueryPayload = (
|
||||
},
|
||||
];
|
||||
|
||||
export const getEndPointDropDownQueryPayload = (
|
||||
export const getEndPointZeroStateQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
filterExpression: string,
|
||||
): GetQueryResultsProps => ({
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TABLE,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'count',
|
||||
aggregations: [
|
||||
{
|
||||
expression: 'count()',
|
||||
},
|
||||
],
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: null,
|
||||
having: [],
|
||||
legend: '',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [
|
||||
{
|
||||
key: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
): GetQueryResultsProps[] => [
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TABLE,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.String,
|
||||
type: 'attribute',
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
],
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: '3db61dd6',
|
||||
key: {
|
||||
key: SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
},
|
||||
{
|
||||
id: '212678b9',
|
||||
key: {
|
||||
key: 'kind_string',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: 'Client',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
legend: '',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [
|
||||
{
|
||||
key: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
],
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
|
||||
promql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
clickhouse_sql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
|
||||
promql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
variables: {},
|
||||
formatForWeb: true,
|
||||
start,
|
||||
end,
|
||||
step: 60,
|
||||
},
|
||||
variables: {},
|
||||
formatForWeb: true,
|
||||
start,
|
||||
end,
|
||||
step: 60,
|
||||
});
|
||||
];
|
||||
|
||||
interface EndPointMetricsResponseRow {
|
||||
data: {
|
||||
@@ -2279,8 +2369,8 @@ export const statusCodeWidgetInfo = [
|
||||
|
||||
interface EndPointDropDownResponseRow {
|
||||
data: {
|
||||
[SPAN_ATTRIBUTES.HTTP_URL]?: string;
|
||||
A?: number;
|
||||
[SPAN_ATTRIBUTES.HTTP_URL]: string;
|
||||
A: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2622,7 +2712,7 @@ export const groupStatusCodes = (
|
||||
|
||||
export const getStatusCodeBarChartWidgetData = (
|
||||
domainName: string,
|
||||
filterExpression: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
): Widgets => ({
|
||||
query: {
|
||||
builder: {
|
||||
@@ -2638,13 +2728,22 @@ export const getStatusCodeBarChartWidgetData = (
|
||||
dataSource: DataSource.TRACES,
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
getDomainNameFilterExpression(domainName),
|
||||
filterExpression,
|
||||
),
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: 'c6724407',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
},
|
||||
...(filters?.items || []),
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
@@ -2725,15 +2824,18 @@ export const getFormattedEndPointStatusCodeChartData = (
|
||||
export const END_POINT_DETAILS_QUERY_KEYS_ARRAY = [
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_METRICS_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_RATE_OVER_TIME_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_LATENCY_OVER_TIME_DATA,
|
||||
];
|
||||
|
||||
export const getAllEndpointsWidgetData = (
|
||||
groupBy: BaseAutocompleteData[],
|
||||
domainName: string,
|
||||
filterExpression: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): Widgets => {
|
||||
const isGroupedByAttribute = groupBy.length > 0;
|
||||
@@ -2755,11 +2857,11 @@ export const getAllEndpointsWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters,
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND http_url EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2787,11 +2889,11 @@ export const getAllEndpointsWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters,
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND http_url EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2819,11 +2921,11 @@ export const getAllEndpointsWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'C',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters,
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND http_url EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2851,11 +2953,11 @@ export const getAllEndpointsWidgetData = (
|
||||
disabled: true,
|
||||
expression: 'D',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters,
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND has_error = true AND http_url EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2973,26 +3075,38 @@ export const getAllEndpointsWidgetData = (
|
||||
return widget;
|
||||
};
|
||||
|
||||
const keysToRemove = [SPAN_ATTRIBUTES.HTTP_URL, 'key', 'A', 'B', 'C', 'F1'];
|
||||
const keysToRemove = [SPAN_ATTRIBUTES.HTTP_URL, 'A', 'B', 'C', 'F1'];
|
||||
|
||||
// A group without a value for a key is the set of spans missing that attribute.
|
||||
export const getGroupByExpressionFromRowData = (
|
||||
rowData: Record<string, string | number | boolean | null | undefined>,
|
||||
): string =>
|
||||
Object.keys(rowData)
|
||||
export const getGroupByFiltersFromGroupByValues = (
|
||||
rowData: any,
|
||||
groupBy: BaseAutocompleteData[],
|
||||
): IBuilderQuery['filters'] => {
|
||||
const items = Object.keys(rowData)
|
||||
.filter((key) => !keysToRemove.includes(key))
|
||||
.map((key) => {
|
||||
const value = rowData[key];
|
||||
return value === null || value === undefined
|
||||
? `${key} NOT EXISTS`
|
||||
: `${key} = ${formatValueForExpression(value)}`;
|
||||
})
|
||||
.join(' AND ');
|
||||
const groupByAttribute = groupBy.find((gb) => gb.key === key);
|
||||
return {
|
||||
id: groupByAttribute?.id || v4(),
|
||||
key: {
|
||||
dataType: groupByAttribute?.dataType || DataTypes.String,
|
||||
key: groupByAttribute?.key || key,
|
||||
type: groupByAttribute?.type || '',
|
||||
},
|
||||
op: '=', // operator for every attribute -> discuss
|
||||
value: rowData[key],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
op: 'AND',
|
||||
};
|
||||
};
|
||||
|
||||
export const getRateOverTimeWidgetData = (
|
||||
domainName: string,
|
||||
endPointName: string,
|
||||
filterExpression: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
): Widgets => {
|
||||
let legend = domainName;
|
||||
if (endPointName) {
|
||||
@@ -3016,9 +3130,9 @@ export const getRateOverTimeWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`http_host = '${domainName}'`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -3042,7 +3156,7 @@ export const getRateOverTimeWidgetData = (
|
||||
export const getLatencyOverTimeWidgetData = (
|
||||
domainName: string,
|
||||
endPointName: string,
|
||||
filterExpression: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
): Widgets => {
|
||||
let legend = domainName;
|
||||
if (endPointName) {
|
||||
@@ -3066,9 +3180,9 @@ export const getLatencyOverTimeWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`http_host = '${domainName}'`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import CreateAlert from './CreateAlert';
|
||||
|
||||
export default CreateAlert;
|
||||
@@ -23,11 +23,20 @@ export type JiraDeployment = AlertmanagertypesJiraDeploymentDTO;
|
||||
|
||||
export type KeysOfUnion<T> = T extends unknown ? keyof T : never;
|
||||
|
||||
/** Every key of every member, optional, so a form can hold any member's fields. */
|
||||
export type FlattenUnion<T> = {
|
||||
[K in KeysOfUnion<T>]?: T extends unknown
|
||||
? K extends keyof T
|
||||
? T[K]
|
||||
: never
|
||||
: never;
|
||||
};
|
||||
|
||||
export type JiraFormSpec = Omit<
|
||||
AlertmanagertypesChannelJiraConfigDTO,
|
||||
'auth'
|
||||
> & {
|
||||
auth?: AlertmanagertypesChannelJiraAuthDTO;
|
||||
auth?: FlattenUnion<AlertmanagertypesChannelJiraAuthDTO>;
|
||||
};
|
||||
|
||||
/** Merged so switching kind keeps the fields the kinds share. */
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
|
||||
import CreateAlertPage from 'pages/CreateAlert';
|
||||
import { act, fireEvent, render } from 'tests/test-utils';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { ALERT_TYPE_TO_TITLE, ALERT_TYPE_URL_MAP } from './constants';
|
||||
|
||||
jest
|
||||
.spyOn(usePrefillAlertConditions, 'usePrefillAlertConditions')
|
||||
.mockReturnValue({
|
||||
matchType: '3',
|
||||
op: '1',
|
||||
target: 100,
|
||||
targetUnit: 'rpm',
|
||||
});
|
||||
|
||||
let mockWindowOpen: jest.Mock;
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
function findLinkForAlertType(
|
||||
links: HTMLElement[],
|
||||
alertType: AlertTypes,
|
||||
): HTMLElement {
|
||||
const link = links.find(
|
||||
(el) =>
|
||||
el.closest('[data-testid]')?.getAttribute('data-testid') ===
|
||||
`alert-type-card-${alertType}`,
|
||||
);
|
||||
expect(link).toBeTruthy();
|
||||
return link as HTMLElement;
|
||||
}
|
||||
|
||||
function clickLinkAndVerifyRedirect(
|
||||
link: HTMLElement,
|
||||
expectedUrl: string,
|
||||
): void {
|
||||
fireEvent.click(link);
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(expectedUrl, '_blank');
|
||||
}
|
||||
describe('Alert rule documentation redirection', () => {
|
||||
let renderResult: ReturnType<typeof render>;
|
||||
|
||||
beforeAll(() => {
|
||||
mockWindowOpen = jest.fn();
|
||||
window.open = mockWindowOpen;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
act(() => {
|
||||
renderResult = render(
|
||||
<CreateAlertPage />,
|
||||
{},
|
||||
{
|
||||
initialRoute: ROUTES.ALERTS_NEW,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render alert type cards', () => {
|
||||
const { getByText, getAllByText } = renderResult;
|
||||
|
||||
// Check for the heading
|
||||
expect(getByText('choose_alert_type')).toBeInTheDocument();
|
||||
|
||||
// Check for alert type titles and descriptions
|
||||
Object.values(AlertTypes).forEach((alertType) => {
|
||||
const title = ALERT_TYPE_TO_TITLE[alertType];
|
||||
expect(getByText(title)).toBeInTheDocument();
|
||||
expect(getByText(`${title}_desc`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const clickHereLinks = getAllByText(
|
||||
'Click here to see how to create a sample alert.',
|
||||
);
|
||||
|
||||
expect(clickHereLinks).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should redirect to correct documentation for each alert type', () => {
|
||||
const { getAllByText } = renderResult;
|
||||
|
||||
const clickHereLinks = getAllByText(
|
||||
'Click here to see how to create a sample alert.',
|
||||
);
|
||||
const alertTypeCount = Object.keys(AlertTypes).length;
|
||||
|
||||
expect(clickHereLinks).toHaveLength(alertTypeCount);
|
||||
|
||||
Object.values(AlertTypes).forEach((alertType) => {
|
||||
const linkForAlertType = findLinkForAlertType(clickHereLinks, alertType);
|
||||
const expectedUrl = ALERT_TYPE_URL_MAP[alertType];
|
||||
|
||||
clickLinkAndVerifyRedirect(linkForAlertType, expectedUrl.selection);
|
||||
});
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledTimes(alertTypeCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Create alert page redirection', () => {
|
||||
Object.values(AlertTypes)
|
||||
.filter((type) => type !== AlertTypes.ANOMALY_BASED_ALERT)
|
||||
.forEach((alertType) => {
|
||||
it(`should redirect to create alert page for ${alertType} and "Check an example alert" should redirect to the correct documentation`, () => {
|
||||
const { getByRole } = render(
|
||||
<CreateAlertPage />,
|
||||
{},
|
||||
{
|
||||
initialRoute: `${ROUTES.ALERTS_NEW}?alertType=${alertType}&showClassicCreateAlertsPage=true`,
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(
|
||||
getByRole('button', {
|
||||
name: /alert setup guide/i,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
ALERT_TYPE_URL_MAP[alertType].creation,
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
|
||||
import CreateAlertPage from 'pages/CreateAlert';
|
||||
import { act, fireEvent, render } from 'tests/test-utils';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
|
||||
import { ALERT_TYPE_URL_MAP } from './constants';
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest
|
||||
.spyOn(usePrefillAlertConditions, 'usePrefillAlertConditions')
|
||||
.mockReturnValue({
|
||||
matchType: '3',
|
||||
op: '1',
|
||||
target: 100,
|
||||
targetUnit: 'rpm',
|
||||
});
|
||||
|
||||
describe('Anomaly Alert Documentation Redirection', () => {
|
||||
let mockWindowOpen: jest.Mock;
|
||||
|
||||
beforeAll(() => {
|
||||
mockWindowOpen = jest.fn();
|
||||
window.open = mockWindowOpen;
|
||||
});
|
||||
|
||||
it('should handle anomaly alert documentation redirection correctly', () => {
|
||||
// The setup guide button only exists in the classic form.
|
||||
const { getByRole } = render(<CreateAlertPage />, undefined, {
|
||||
initialRoute: `${ROUTES.ALERTS_NEW}?ruleType=anomaly_rule&showClassicCreateAlertsPage=true`,
|
||||
});
|
||||
|
||||
const alertType = AlertTypes.ANOMALY_BASED_ALERT;
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(
|
||||
getByRole('button', {
|
||||
name: /alert setup guide/i,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
ALERT_TYPE_URL_MAP[alertType].creation,
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
|
||||
import { useIsAnomalyDetectionAvailable } from 'container/CreateAlert/useIsAnomalyDetectionAvailable';
|
||||
import { useIsAnomalyDetectionAvailable } from 'container/CreateAlertV2/useIsAnomalyDetectionAvailable';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -23,9 +23,28 @@ jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('container/CreateAlert', () => ({
|
||||
jest.mock('container/FormAlertRules', () => ({
|
||||
__esModule: true,
|
||||
default: function MockCreateAlert({
|
||||
default: function MockFormAlertRules({
|
||||
alertType,
|
||||
}: {
|
||||
alertType: AlertTypes;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<h1>Form Alert Rules</h1>
|
||||
<p>{alertType}</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
AlertDetectionTypes: {
|
||||
THRESHOLD_ALERT: 'threshold_rule',
|
||||
ANOMALY_DETECTION_ALERT: 'anomaly_rule',
|
||||
},
|
||||
}));
|
||||
jest.mock('container/CreateAlertV2', () => ({
|
||||
__esModule: true,
|
||||
default: function MockCreateAlertV2({
|
||||
alertType,
|
||||
}: {
|
||||
alertType: AlertTypes;
|
||||
@@ -57,6 +76,7 @@ const mockGetUrlQuery = jest.fn();
|
||||
const mockSafeNavigate = jest.fn();
|
||||
const mockDeleteUrlQuery = jest.fn();
|
||||
|
||||
const FORM_ALERT_RULES_TEXT = 'Form Alert Rules';
|
||||
const CREATE_ALERT_V2_TEXT = 'Create Alert V2';
|
||||
|
||||
describe('CreateAlertRule', () => {
|
||||
@@ -75,6 +95,20 @@ describe('CreateAlertRule', () => {
|
||||
useAppContextSpy.mockReturnValue(getAppContextMockState());
|
||||
});
|
||||
|
||||
it('should render classic flow when showClassicCreateAlertsPage is true', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.showClassicCreateAlertsPage) {
|
||||
return 'true';
|
||||
}
|
||||
if (key === QueryParams.alertType) {
|
||||
return AlertTypes.METRICS_BASED_ALERT;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
render(<CreateAlertRule />);
|
||||
expect(screen.getByText(FORM_ALERT_RULES_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render new flow when alertType is provided', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.alertType) {
|
||||
@@ -90,6 +124,7 @@ describe('CreateAlertRule', () => {
|
||||
mockGetUrlQuery.mockReturnValue(null);
|
||||
useCompositeQueryParamSpy.mockReturnValue(null);
|
||||
render(<CreateAlertRule />);
|
||||
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(CREATE_ALERT_V2_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -114,6 +149,9 @@ describe('CreateAlertRule', () => {
|
||||
|
||||
it('should render new flow with the anomaly alert type when ruleType is anomaly_rule', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.showClassicCreateAlertsPage) {
|
||||
return 'false';
|
||||
}
|
||||
if (key === QueryParams.ruleType) {
|
||||
return 'anomaly_rule';
|
||||
}
|
||||
@@ -122,6 +160,7 @@ describe('CreateAlertRule', () => {
|
||||
render(<CreateAlertRule />);
|
||||
expect(screen.getByText(CREATE_ALERT_V2_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(AlertTypes.ANOMALY_BASED_ALERT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(FORM_ALERT_RULES_TEXT)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use alertType from URL when provided', () => {
|
||||
@@ -245,5 +284,27 @@ describe('CreateAlertRule', () => {
|
||||
);
|
||||
expect(mockSafeNavigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should navigate even when showClassicCreateAlertsPage flag is present', () => {
|
||||
mockGetUrlQuery.mockImplementation((key: string) => {
|
||||
if (key === QueryParams.showClassicCreateAlertsPage) {
|
||||
return 'true';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
render(<CreateAlertRule />);
|
||||
fireEvent.click(screen.getByText('metric_based_alert'));
|
||||
|
||||
expect(mockSetUrlQuery).toHaveBeenCalledWith(
|
||||
QueryParams.ruleType,
|
||||
'threshold_rule',
|
||||
);
|
||||
expect(mockSetUrlQuery).toHaveBeenCalledWith(
|
||||
QueryParams.alertType,
|
||||
AlertTypes.METRICS_BASED_ALERT,
|
||||
);
|
||||
expect(mockSafeNavigate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryPromQLData,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import {
|
||||
AlertDef,
|
||||
@@ -27,6 +28,7 @@ const defaultAnnotations = {
|
||||
|
||||
export const alertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
@@ -60,7 +62,8 @@ export const alertDefaults: AlertDef = {
|
||||
|
||||
export const anamolyAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
ruleType: RuletypesRuleTypeDTO.anomaly_rule,
|
||||
version: ENTITY_VERSION_V5,
|
||||
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
@@ -104,6 +107,7 @@ export const anamolyAlertDefaults: AlertDef = {
|
||||
|
||||
export const logAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.LOGS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
@@ -135,6 +139,7 @@ export const logAlertDefaults: AlertDef = {
|
||||
|
||||
export const traceAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.TRACES_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
@@ -166,6 +171,7 @@ export const traceAlertDefaults: AlertDef = {
|
||||
|
||||
export const exceptionAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.EXCEPTIONS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V5,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
@@ -195,6 +201,14 @@ export const exceptionAlertDefaults: AlertDef = {
|
||||
alert: '',
|
||||
};
|
||||
|
||||
export const ALERTS_VALUES_MAP: Record<AlertTypes, AlertDef> = {
|
||||
[AlertTypes.ANOMALY_BASED_ALERT]: anamolyAlertDefaults,
|
||||
[AlertTypes.METRICS_BASED_ALERT]: alertDefaults,
|
||||
[AlertTypes.LOGS_BASED_ALERT]: logAlertDefaults,
|
||||
[AlertTypes.TRACES_BASED_ALERT]: traceAlertDefaults,
|
||||
[AlertTypes.EXCEPTIONS_BASED_ALERT]: exceptionAlertDefaults,
|
||||
};
|
||||
|
||||
export const ALERT_TYPE_BREADCRUMB_TITLE: Record<AlertTypes, string> = {
|
||||
[AlertTypes.ANOMALY_BASED_ALERT]: 'Anomaly-Based Alert',
|
||||
[AlertTypes.METRICS_BASED_ALERT]: 'Metric-Based Alert',
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import { Form, Tabs, TabsProps } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { RuletypesRuleTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
|
||||
import AlertBreadcrumb from 'components/AlertBreadcrumb';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import CreateAlert from 'container/CreateAlert';
|
||||
import CreateAlertV2 from 'container/CreateAlertV2';
|
||||
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
@@ -15,20 +16,25 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { AlertListTabs } from 'pages/AlertList/types';
|
||||
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
|
||||
import { AlertTypes } from 'types/api/alerts/alertTypes';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
|
||||
import { ALERT_TYPE_VS_SOURCE_MAPPING } from './config';
|
||||
import { ALERT_TYPE_BREADCRUMB_TITLE } from './defaults';
|
||||
import { ALERTS_VALUES_MAP, ALERT_TYPE_BREADCRUMB_TITLE } from './defaults';
|
||||
import SelectAlertType from './SelectAlertType';
|
||||
|
||||
import './CreateAlertRule.styles.scss';
|
||||
|
||||
function CreateRules(): JSX.Element {
|
||||
const [formInstance] = Form.useForm();
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const queryParams = useUrlQuery();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const ruleTypeFromURL = queryParams.get(QueryParams.ruleType);
|
||||
const alertTypeFromURL = queryParams.get(QueryParams.alertType);
|
||||
const version = queryParams.get(QueryParams.version);
|
||||
const showClassicCreateAlertsPageFlag =
|
||||
queryParams.get(QueryParams.showClassicCreateAlertsPage) === 'true';
|
||||
|
||||
const isTypeSelectionMode =
|
||||
!alertTypeFromURL && !ruleTypeFromURL && !compositeQuery;
|
||||
@@ -40,7 +46,7 @@ function CreateRules(): JSX.Element {
|
||||
}, [isTypeSelectionMode]);
|
||||
|
||||
const alertType = useMemo(() => {
|
||||
if (ruleTypeFromURL === RuletypesRuleTypeDTO.anomaly_rule) {
|
||||
if (ruleTypeFromURL === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
|
||||
return AlertTypes.ANOMALY_BASED_ALERT;
|
||||
}
|
||||
if (!alertTypeFromURL) {
|
||||
@@ -53,6 +59,14 @@ function CreateRules(): JSX.Element {
|
||||
return alertTypeFromURL as AlertTypes;
|
||||
}, [alertTypeFromURL, ruleTypeFromURL, compositeQuery?.builder.queryData]);
|
||||
|
||||
const initialAlertValue: AlertDef = useMemo(
|
||||
() => ({
|
||||
...ALERTS_VALUES_MAP[alertType],
|
||||
version: version || ENTITY_VERSION_V5,
|
||||
}),
|
||||
[alertType, version],
|
||||
);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(tab: string): void => {
|
||||
queryParams.set('tab', tab);
|
||||
@@ -66,10 +80,13 @@ function CreateRules(): JSX.Element {
|
||||
const handleSelectType = useCallback(
|
||||
(type: AlertTypes, newTab?: boolean): void => {
|
||||
if (type === AlertTypes.ANOMALY_BASED_ALERT) {
|
||||
queryParams.set(QueryParams.ruleType, RuletypesRuleTypeDTO.anomaly_rule);
|
||||
queryParams.set(
|
||||
QueryParams.ruleType,
|
||||
AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
|
||||
);
|
||||
queryParams.set(QueryParams.alertType, AlertTypes.METRICS_BASED_ALERT);
|
||||
} else {
|
||||
queryParams.set(QueryParams.ruleType, RuletypesRuleTypeDTO.threshold_rule);
|
||||
queryParams.set(QueryParams.ruleType, AlertDetectionTypes.THRESHOLD_ALERT);
|
||||
queryParams.set(QueryParams.alertType, type);
|
||||
}
|
||||
|
||||
@@ -82,8 +99,26 @@ function CreateRules(): JSX.Element {
|
||||
if (isTypeSelectionMode) {
|
||||
return <SelectAlertType onSelect={handleSelectType} />;
|
||||
}
|
||||
return <CreateAlert alertType={alertType} />;
|
||||
}, [isTypeSelectionMode, handleSelectType, alertType]);
|
||||
|
||||
if (showClassicCreateAlertsPageFlag) {
|
||||
return (
|
||||
<FormAlertRules
|
||||
alertType={alertType}
|
||||
formInstance={formInstance}
|
||||
initialValue={initialAlertValue}
|
||||
ruleId=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <CreateAlertV2 alertType={alertType} />;
|
||||
}, [
|
||||
isTypeSelectionMode,
|
||||
handleSelectType,
|
||||
showClassicCreateAlertsPageFlag,
|
||||
alertType,
|
||||
formInstance,
|
||||
initialAlertValue,
|
||||
]);
|
||||
|
||||
const items: TabsProps['items'] = [
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user