Compare commits

..

24 Commits

Author SHA1 Message Date
Abhi Kumar
f7096dff9a feat(dashboard): show distribution percentiles as hoverable diamond markers
Markers on the same spot share one badge. The overflow boundary line is gone;
it read like a marker missing its badge.
2026-11-12 11:06:24 +05:30
Abhi Kumar
4ed16d1bfb test(dashboard): add required alias to distribution fixtures 2026-11-11 11:23:48 +05:30
Abhi Kumar
99a4322db6 feat(dashboard): show distribution tooltip as aligned per-group columns 2026-11-11 11:23:42 +05:30
Abhi Kumar
035c8960d8 feat(dashboard): hover distribution buckets like bar panel columns
The whole bucket column is the hover target and the vertically nearest group is
focused, so an empty column still gets a tooltip. The tooltip lists every group,
side by side included.
2026-11-09 15:28:19 +05:30
Abhi Kumar
ac982a3af6 chore(jest): stop ignoring every test path containing "dist"
An unanchored `dist` matched `distributionTooltipContent.test.ts`, so it never ran.
2026-11-09 15:28:19 +05:30
Abhi Kumar
646eef7fa2 feat(dashboard): draw distribution buckets equally wide with an x range
Bounds are server-chosen and usually exponential, so no single value scale fits them;
equal widths keep every bucket readable. The spec field is unreleased, so saved
`axes.x.scale` values are dropped rather than migrated.
2026-11-09 15:28:19 +05:30
Abhi Kumar
1c5483b166 fix(dashboard): keep distribution x-axis labels from overlapping
Ticks are thinned by each label's measured width; bound labels vary too much for a fixed spacing.
2026-11-09 15:28:19 +05:30
Abhi Kumar
90a4325000 test(dashboard): add distribution panel stories 2026-11-09 15:28:18 +05:30
Abhi Kumar
38c3793efd fix(dashboard): stop labelling a distribution's share axis past 100% 2026-11-09 15:28:03 +05:30
Abhi Kumar
428b89e419 feat(dashboard): point metric histograms at the distribution panel
Histogram and Distribution stay separate kinds. Their queries differ,
so neither switches into the other.
2026-11-09 15:28:02 +05:30
Abhi Kumar
9bad81f266 fix(query-range): request time_series for V1 histogram panels
They bin a time series in the browser; distribution now returns a different shape.
2026-11-09 15:28:02 +05:30
Abhi Kumar
b87d2dce5e feat(dashboard): drill down from a distribution bar
Other sums several groups, so its bars open no menu.
2026-11-09 15:28:02 +05:30
Abhi Kumar
1b23c2bde1 feat(dashboard): warn on distribution bounds and offer legend colours 2026-11-09 15:28:02 +05:30
Abhi Kumar
6eb6827e33 feat(dashboard): sum distribution groups past the top K into Other
The legend keeps every group; only the plot folds, so a toggle re-ranks without a refetch.
2026-11-09 15:28:02 +05:30
Abhi Kumar
d1cf477ca3 fix(dashboard): focus the hovered distribution group so its tooltip shows 2026-11-09 15:28:02 +05:30
Abhi Kumar
1ced5ce038 feat(dashboard): configure distribution percentile markers 2026-11-09 15:28:01 +05:30
Abhi Kumar
a11ed42748 feat(dashboard): add distribution percentile markers and tooltip
Percentiles interpolate within the bucket like histogram_quantile, over the visible groups.
2026-11-09 15:28:01 +05:30
Abhi Kumar
b68642c826 feat(query-builder): treat distribution like heatmap for bucket options
One isBucketCountingPanelType check replaces the heatmap-only gates.
2026-11-09 15:28:01 +05:30
Abhi Kumar
95d042d66e feat(dashboard): add distribution axes and stack controls 2026-11-09 15:28:01 +05:30
Abhi Kumar
c64ed62fd6 feat(dashboard): add distribution panel kind 2026-11-09 15:28:01 +05:30
Abhi Kumar
328227e3c4 feat(dashboard): add distribution chart
Bars carry their own widths, so a variable-width path builder replaces uPlot's uniform bars.
2026-11-09 15:28:01 +05:30
Abhi Kumar
2ef1f756f7 feat(dashboard): read distribution query results 2026-11-09 15:28:01 +05:30
Abhi Kumar
a19ad8a432 chore: regenerate openapi specs and api types 2026-11-09 15:28:01 +05:30
Abhi Kumar
2d958adc88 feat(dashboard): add distribution panel spec
Stack defaults to normal rather than StackMode's none, so the spec decodes over its defaults.
2026-11-09 15:28:00 +05:30
535 changed files with 16157 additions and 11155 deletions

13
.github/CODEOWNERS vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
});
});

View File

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

View File

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

View File

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

View File

@@ -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;
/**

View File

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

View File

@@ -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: '',

View File

@@ -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');
});

View File

@@ -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 '';
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ?? '')
) {

View File

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

View File

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

View File

@@ -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());

View File

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

View File

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

View File

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

View File

@@ -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>
);
}

View File

@@ -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'";

View File

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

View File

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

View File

@@ -53,6 +53,7 @@ export enum QueryParams {
variables = 'variables',
version = 'version',
source = 'source',
showClassicCreateAlertsPage = 'showClassicCreateAlertsPage',
isTestAlert = 'isTestAlert',
yAxisUnit = 'yAxisUnit',
ruleName = 'ruleName',

View File

@@ -340,6 +340,7 @@ export enum PANEL_TYPES {
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
DISTRIBUTION = 'distribution',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

View File

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

View File

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

View File

@@ -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']);
});
});

View File

@@ -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', () => ({

View File

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

View File

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

View File

@@ -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}
/>
)}
</>

View File

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

View File

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

View File

@@ -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} />;
}

View File

@@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'",
),
);
});
});

View File

@@ -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());
});
});

View File

@@ -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,
};
}

View File

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

View File

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

View File

@@ -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,
);
});
});

View File

@@ -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]);
}

View File

@@ -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'",
);
});
});
});

View File

@@ -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();
});
});

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -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'",
);
});
});

View File

@@ -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}"`,
);
});
});

View File

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

View File

@@ -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(),
);
});
});

View File

@@ -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);
});
});

View File

@@ -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'`,
);
});
});

View File

@@ -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>
);
}

View File

@@ -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'`,
);
});
});

View File

@@ -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',
}),
);
});
});

View File

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

View File

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

View File

@@ -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,
);

View File

@@ -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', () => {

View File

@@ -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',
);
});
});

View File

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

View File

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

View File

@@ -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: [],

View File

@@ -1,3 +0,0 @@
import CreateAlert from './CreateAlert';
export default CreateAlert;

View File

@@ -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. */

View File

@@ -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',
);
});
});
});

View File

@@ -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',
);
});
});

View File

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

View File

@@ -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();
});
});
});

View File

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

View File

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