mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-13 11:50:42 +00:00
Compare commits
54 Commits
feat/dashb
...
fix/safe-n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98a0d906ed | ||
|
|
20e6d44289 | ||
|
|
9fef8eea69 | ||
|
|
d00471c6b9 | ||
|
|
b91f7e9835 | ||
|
|
0b48326b10 | ||
|
|
95ee58eba5 | ||
|
|
9100d9aea6 | ||
|
|
3acfb0c8c0 | ||
|
|
75994b0d6f | ||
|
|
3bfb8708e9 | ||
|
|
876d84ac53 | ||
|
|
88e9ad73e0 | ||
|
|
7f5bd4d1cf | ||
|
|
86e0e4840b | ||
|
|
9ee433424d | ||
|
|
da8d66b5b9 | ||
|
|
f1d6c988c9 | ||
|
|
f02b5521c6 | ||
|
|
4b1b3092d5 | ||
|
|
d6017f88fd | ||
|
|
9d28014664 | ||
|
|
1997901102 | ||
|
|
84268d00c8 | ||
|
|
628e989bc0 | ||
|
|
74d272a945 | ||
|
|
f8421e81e9 | ||
|
|
e938a10ad5 | ||
|
|
93cc1957d4 | ||
|
|
313ec2d657 | ||
|
|
87bcd1b702 | ||
|
|
43cde62108 | ||
|
|
94367f4575 | ||
|
|
108b2d8694 | ||
|
|
fa6fda338f | ||
|
|
d1f4503b40 | ||
|
|
cbab3ca8d7 | ||
|
|
ab28e69397 | ||
|
|
d98fca08dd | ||
|
|
648c8d0740 | ||
|
|
e6c19dd15a | ||
|
|
ccfe8114f1 | ||
|
|
b87e2b6375 | ||
|
|
b45c4828ac | ||
|
|
40d3f9c81d | ||
|
|
c14d256e6f | ||
|
|
9a541c851f | ||
|
|
e0802b8274 | ||
|
|
add47f6516 | ||
|
|
2d3c4fd7d2 | ||
|
|
8f86b59822 | ||
|
|
e53d16157b | ||
|
|
83ebe26877 | ||
|
|
33d2ff917f |
13
.github/CODEOWNERS
vendored
13
.github/CODEOWNERS
vendored
@@ -194,8 +194,8 @@ go.mod @therealpandey
|
||||
/frontend/src/pages/EditRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AlertHistory/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertRule/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditAlertV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlert/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditAlert/ @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,6 +209,10 @@ 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
|
||||
@@ -217,6 +221,11 @@ go.mod @therealpandey
|
||||
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
|
||||
|
||||
## List filter + views (dashboards and alerts lists)
|
||||
/frontend/src/components/ListFilter/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/ListViews/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/listViews/ @SigNoz/pulse-frontend
|
||||
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
2
.github/workflows/build-community.yaml
vendored
2
.github/workflows/build-community.yaml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
GO_NAME: signoz-community
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: community-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
|
||||
2
.github/workflows/build-enterprise.yaml
vendored
2
.github/workflows/build-enterprise.yaml
vendored
@@ -89,7 +89,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
GO_BUILD_CONTEXT: ./cmd/enterprise
|
||||
|
||||
2
.github/workflows/build-staging.yaml
vendored
2
.github/workflows/build-staging.yaml
vendored
@@ -95,7 +95,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
GO_INPUT_ARTIFACT_CACHE_KEY: staging-jsbuild-${{ github.sha }}
|
||||
GO_INPUT_ARTIFACT_PATH: frontend/build
|
||||
GO_BUILD_CONTEXT: ./cmd/enterprise
|
||||
|
||||
10
.github/workflows/e2eci.yaml
vendored
10
.github/workflows/e2eci.yaml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
FORCE: ${{ contains(github.event.pull_request.labels.*.name, 'safe-to-e2e') }}
|
||||
run: |
|
||||
CHANGED_FILES=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename | select(startswith("tests/integration/") | not)')
|
||||
CHANGED_FILES=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename | select((startswith("tests/integration/") or startswith("frontend/src/api/generated/")) | not)')
|
||||
PATTERN='^(frontend/|tests/|\.github/workflows/e2eci\.yaml$|cmd/enterprise/Dockerfile\.with-web\.integration$)'
|
||||
|
||||
if [ "$FORCE" = "true" ]; then
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
- name: node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version: "22"
|
||||
- name: install-pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
matrix:
|
||||
project:
|
||||
- chromium
|
||||
shard: [1, 2, 3, 4]
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
needs: changes
|
||||
if: needs.changes.outputs.e2e == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
- name: node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version: "22"
|
||||
- name: install-pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
- name: playwright-test
|
||||
run: |
|
||||
cd tests/e2e && \
|
||||
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
|
||||
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/10
|
||||
- name: teardown-stack
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
20
.github/workflows/goci.yaml
vendored
20
.github/workflows/goci.yaml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_TEST_CONTEXT: ./...
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
fmt:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
lint:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
deps:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
PRIMUS_REF: main
|
||||
GO_VERSION: 1.24
|
||||
GO_VERSION: 1.25
|
||||
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.24"
|
||||
go-version: "1.25"
|
||||
- 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.24"
|
||||
go-version: "1.25"
|
||||
- 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.24"
|
||||
go-version: "1.25"
|
||||
- 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.24"
|
||||
go-version: "1.25"
|
||||
- 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.24"
|
||||
go-version: "1.25"
|
||||
- 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.24"
|
||||
go-version: "1.25"
|
||||
- name: generate-transaction-groups
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config transaction-groups
|
||||
|
||||
4
.github/workflows/gor-signoz-community.yaml
vendored
4
.github/workflows/gor-signoz-community.yaml
vendored
@@ -66,7 +66,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- 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.24"
|
||||
go-version: "1.25"
|
||||
|
||||
# copy the caches from build
|
||||
- name: get-sha
|
||||
|
||||
4
.github/workflows/gor-signoz.yaml
vendored
4
.github/workflows/gor-signoz.yaml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
- name: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.25"
|
||||
- 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.24"
|
||||
go-version: "1.25"
|
||||
|
||||
# copy the caches from build
|
||||
- name: get-sha
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"
|
||||
@@ -156,6 +157,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
if err := factories.Add(fileauditor.NewFactory(licensing, version.Info)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := factories.Add(kafkaauditor.NewFactory(licensing, version.Info)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return factories
|
||||
},
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, flagger pkgflagger.Flagger, licensing licensing.Licensing, telemetryStore telemetrystore.TelemetryStore, retentionGetter retention.Getter, orgGetter organization.Getter, zeus zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string) {
|
||||
|
||||
@@ -436,6 +436,7 @@ 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
|
||||
@@ -461,6 +462,18 @@ 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
31
ee/auditor/kafkaauditor/export.go
Normal file
31
ee/auditor/kafkaauditor/export.go
Normal file
@@ -0,0 +1,31 @@
|
||||
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
|
||||
}
|
||||
132
ee/auditor/kafkaauditor/provider.go
Normal file
132
ee/auditor/kafkaauditor/provider.go
Normal file
@@ -0,0 +1,132 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
227
ee/auditor/kafkaauditor/provider_test.go
Normal file
227
ee/auditor/kafkaauditor/provider_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package kafkaauditor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/twmb/franz-go/pkg/kerr"
|
||||
"github.com/twmb/franz-go/pkg/kfake"
|
||||
"github.com/twmb/franz-go/pkg/kgo"
|
||||
"go.opentelemetry.io/collector/pdata/plog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/types/audittypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
)
|
||||
|
||||
func newTestCluster(t *testing.T, topics ...string) *kfake.Cluster {
|
||||
t.Helper()
|
||||
|
||||
cluster, err := kfake.NewCluster(kfake.NumBrokers(1), kfake.SeedTopics(1, topics...))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(cluster.Close)
|
||||
|
||||
return cluster
|
||||
}
|
||||
|
||||
func newTestProvider(t *testing.T, brokers []string, topic string, compression string) *provider {
|
||||
t.Helper()
|
||||
|
||||
config := auditor.Config{
|
||||
Provider: "kafka",
|
||||
BufferSize: 10,
|
||||
BatchSize: 5,
|
||||
FlushInterval: 50 * time.Millisecond,
|
||||
Kafka: auditor.KafkaConfig{
|
||||
Brokers: brokers,
|
||||
Topic: topic,
|
||||
ClientID: "signoz-test",
|
||||
Compression: compression,
|
||||
ShutdownTimeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
auditor, err := newProvider(context.Background(), instrumentationtest.New().ToProviderSettings(), config, nil, version.Info)
|
||||
require.NoError(t, err)
|
||||
|
||||
return auditor.(*provider)
|
||||
}
|
||||
|
||||
func newTestEvent(resource coretypes.Resource, action coretypes.Verb, body string) audittypes.AuditEvent {
|
||||
return audittypes.AuditEvent{
|
||||
Timestamp: time.Now(),
|
||||
Body: body,
|
||||
EventName: audittypes.NewEventName(action),
|
||||
AuditAttributes: audittypes.AuditAttributes{
|
||||
Action: action,
|
||||
Outcome: audittypes.OutcomeSuccess,
|
||||
},
|
||||
ResourceAttributes: audittypes.ResourceAttributes{
|
||||
Resource: resource,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Polls the topic until the consumed records hold wantLogRecords audit log records in total.
|
||||
func consumeRecords(t *testing.T, brokers []string, topic string, wantLogRecords int) []*kgo.Record {
|
||||
t.Helper()
|
||||
|
||||
client, err := kgo.NewClient(
|
||||
kgo.SeedBrokers(brokers...),
|
||||
kgo.ConsumeTopics(topic),
|
||||
kgo.ConsumeResetOffset(kgo.NewOffset().AtStart()),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var records []*kgo.Record
|
||||
gotLogRecords := 0
|
||||
for gotLogRecords < wantLogRecords {
|
||||
fetches := client.PollFetches(ctx)
|
||||
require.NoError(t, ctx.Err(), "timed out waiting for %d log records, got %d", wantLogRecords, gotLogRecords)
|
||||
require.Empty(t, fetches.Errors())
|
||||
|
||||
for _, record := range fetches.Records() {
|
||||
logs, err := (&plog.ProtoUnmarshaler{}).UnmarshalLogs(record.Value)
|
||||
require.NoError(t, err)
|
||||
gotLogRecords += logs.LogRecordCount()
|
||||
records = append(records, record)
|
||||
}
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
func TestExport(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
compression string
|
||||
compressionType uint8
|
||||
}{
|
||||
{name: "CompressionNone", compression: "none", compressionType: 0},
|
||||
{name: "CompressionGzip", compression: "gzip", compressionType: 1},
|
||||
{name: "CompressionSnappy", compression: "snappy", compressionType: 2},
|
||||
{name: "CompressionLz4", compression: "lz4", compressionType: 3},
|
||||
{name: "CompressionZstd", compression: "zstd", compressionType: 4},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
cluster := newTestCluster(t, "tenant_audit")
|
||||
provider := newTestProvider(t, cluster.ListenAddrs(), "tenant_audit", testCase.compression)
|
||||
defer provider.client.Close()
|
||||
|
||||
events := []audittypes.AuditEvent{
|
||||
newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"),
|
||||
newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbCreate, "dashboard created"),
|
||||
newTestEvent(coretypes.ResourceMetaResourceDashboard, coretypes.VerbDelete, "dashboard deleted"),
|
||||
}
|
||||
require.NoError(t, provider.export(context.Background(), events))
|
||||
|
||||
records := consumeRecords(t, cluster.ListenAddrs(), "tenant_audit", len(events))
|
||||
require.Len(t, records, 1)
|
||||
assert.Equal(t, "tenant_audit", records[0].Topic)
|
||||
assert.Equal(t, testCase.compressionType, records[0].Attrs.CompressionType())
|
||||
|
||||
logs, err := (&plog.ProtoUnmarshaler{}).UnmarshalLogs(records[0].Value)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(events), logs.LogRecordCount())
|
||||
|
||||
serviceName, ok := logs.ResourceLogs().At(0).Resource().Attributes().Get("service.name")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "signoz", serviceName.Str())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExport_RecordTooLarge(t *testing.T) {
|
||||
cluster := newTestCluster(t, "large_audit")
|
||||
provider := newTestProvider(t, cluster.ListenAddrs(), "large_audit", "none")
|
||||
defer provider.client.Close()
|
||||
|
||||
event := newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, strings.Repeat("a", 2<<20))
|
||||
|
||||
err := provider.export(context.Background(), []audittypes.AuditEvent{event})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, kerr.MessageTooLarge))
|
||||
}
|
||||
|
||||
func TestExport_UnknownTopic(t *testing.T) {
|
||||
cluster := newTestCluster(t)
|
||||
provider := newTestProvider(t, cluster.ListenAddrs(), "missing_audit", "lz4")
|
||||
defer provider.client.Close()
|
||||
|
||||
err := provider.export(context.Background(), []audittypes.AuditEvent{newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, "user created")})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, kerr.UnknownTopicOrPartition))
|
||||
}
|
||||
|
||||
func TestExport_BrokerDown_RetriesUntilContextDone(t *testing.T) {
|
||||
cluster := newTestCluster(t, "down_audit")
|
||||
brokers := cluster.ListenAddrs()
|
||||
cluster.Close()
|
||||
|
||||
provider := newTestProvider(t, brokers, "down_audit", "lz4")
|
||||
defer provider.client.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
err := provider.export(ctx, []audittypes.AuditEvent{newTestEvent(coretypes.ResourceUser, coretypes.VerbCreate, "user created")})
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, context.DeadlineExceeded))
|
||||
assert.GreaterOrEqual(t, time.Since(start), 500*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestStop_DrainsQueuedEvents(t *testing.T) {
|
||||
cluster := newTestCluster(t, "drain_audit")
|
||||
provider := newTestProvider(t, cluster.ListenAddrs(), "drain_audit", "lz4")
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- provider.Start(context.Background()) }()
|
||||
<-provider.Healthy()
|
||||
|
||||
for range 7 {
|
||||
provider.server.Add(context.Background(), newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"))
|
||||
}
|
||||
|
||||
require.NoError(t, provider.Stop(context.Background()))
|
||||
require.NoError(t, <-done)
|
||||
|
||||
records := consumeRecords(t, cluster.ListenAddrs(), "drain_audit", 7)
|
||||
assert.NotEmpty(t, records)
|
||||
}
|
||||
|
||||
func TestStop_BrokerDown_ClosesAfterShutdownTimeout(t *testing.T) {
|
||||
cluster := newTestCluster(t, "stuck_audit")
|
||||
brokers := cluster.ListenAddrs()
|
||||
cluster.Close()
|
||||
|
||||
provider := newTestProvider(t, brokers, "stuck_audit", "lz4")
|
||||
provider.config.Kafka.ShutdownTimeout = 500 * time.Millisecond
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- provider.Start(context.Background()) }()
|
||||
<-provider.Healthy()
|
||||
|
||||
for range 7 {
|
||||
provider.server.Add(context.Background(), newTestEvent(coretypes.ResourceUser, coretypes.VerbUpdate, "user updated"))
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
require.NoError(t, provider.Stop(context.Background()))
|
||||
require.NoError(t, <-done)
|
||||
assert.GreaterOrEqual(t, time.Since(start), 500*time.Millisecond)
|
||||
assert.Less(t, time.Since(start), 5*time.Second)
|
||||
}
|
||||
@@ -34,8 +34,7 @@ type AnomalyRule struct {
|
||||
|
||||
provider anomaly.Provider
|
||||
|
||||
version string
|
||||
logger *slog.Logger
|
||||
logger *slog.Logger
|
||||
|
||||
seasonality anomaly.Seasonality
|
||||
}
|
||||
@@ -63,7 +62,6 @@ func NewAnomalyRule(
|
||||
r := AnomalyRule{
|
||||
BaseRule: baseRule,
|
||||
querier: querier,
|
||||
version: p.Version,
|
||||
logger: logger.With(slog.String("rule.id", id)),
|
||||
}
|
||||
|
||||
@@ -105,15 +103,15 @@ func (r *AnomalyRule) Type() ruletypes.RuleType {
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
|
||||
r.logger.InfoContext(
|
||||
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
|
||||
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()
|
||||
|
||||
r.logger.InfoContext(
|
||||
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
|
||||
slog.Int64("eval.window_ms", end-start),
|
||||
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(start),
|
||||
End: uint64(end),
|
||||
@@ -420,12 +418,10 @@ 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(),
|
||||
EvalWindow: r.EvalWindow(),
|
||||
Labels: r.Labels().Map(),
|
||||
Annotations: r.Annotations().Map(),
|
||||
PreferredChannels: r.PreferredChannels(),
|
||||
AlertName: valuer.NewDisplayName(r.Name()),
|
||||
RuleCondition: r.Condition(),
|
||||
Labels: r.Labels().Map(),
|
||||
Annotations: r.Annotations().Map(),
|
||||
}
|
||||
|
||||
byt, err := json.Marshal(ar)
|
||||
|
||||
@@ -54,9 +54,6 @@ 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{{
|
||||
@@ -157,10 +154,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
AlertOnAbsent: true,
|
||||
Target: &target,
|
||||
AlertOnAbsent: true,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
|
||||
@@ -18,15 +18,25 @@
|
||||
"tooltip_email_to": "Enter email addresses separated by commas.",
|
||||
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
|
||||
"field_jira_deployment": "Jira deployment",
|
||||
"option_jira_deployment_cloud": "Jira Cloud",
|
||||
"option_jira_deployment_selfhosted": "Self-hosted Jira",
|
||||
"jira_selfhosted_tip": "Works with Jira Data Center 8.14 and newer. Create the personal access token from a dedicated service user so issues aren't reported under a personal name.",
|
||||
"jira_selfhosted_tip_link": "Learn how",
|
||||
"field_jira_site": "Site URL",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
|
||||
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
|
||||
"tooltip_jira_site_selfhosted": "Your self-hosted Jira base URL, e.g. https://jira.your-company.com, or a proxy in front of it.",
|
||||
"jira_required_fields": "Site URL, project and issue type are required",
|
||||
"jira_cloud_credentials_required": "Email and API token are required for Jira Cloud",
|
||||
"jira_pat_required": "A personal access token is required for self-hosted Jira",
|
||||
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
|
||||
"jira_service_account_tip_link": "Learn how",
|
||||
"field_jira_email": "Email",
|
||||
"help_jira_email": "The Atlassian account email used for authentication.",
|
||||
"field_jira_api_token": "API token",
|
||||
"help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.",
|
||||
"field_jira_pat": "Personal access token",
|
||||
"help_jira_pat": "Create one in Jira under your profile, Personal Access Tokens. Sent as a Bearer header.",
|
||||
"field_jira_project": "Project key",
|
||||
"field_jira_issue_type": "Issue type",
|
||||
"help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.",
|
||||
@@ -34,6 +44,7 @@
|
||||
"help_jira_summary": "Template for the Jira issue title.",
|
||||
"field_jira_description": "Description",
|
||||
"help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.",
|
||||
"help_jira_description_selfhosted": "Template for the issue description. Rendered as Jira wiki markup with a status panel and links back to SigNoz.",
|
||||
"jira_advanced_section": "Advanced Options",
|
||||
"field_jira_priority": "Priority",
|
||||
"placeholder_jira_priority": "Leave empty to use the project default",
|
||||
|
||||
@@ -26,15 +26,25 @@
|
||||
"tooltip_email_to": "Enter email addresses separated by commas.",
|
||||
"tooltip_ms_teams_url": "The URL of the Microsoft Teams [webhook](https://support.microsoft.com/en-us/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) to send alerts to. Learn more about Microsoft Teams integration in the docs [here](https://signoz.io/docs/alerts-management/notification-channel/ms-teams/).",
|
||||
"tooltip_google_chat_url": "The URL of the Google Chat space [incoming webhook](https://developers.google.com/workspace/chat/quickstart/webhooks) to send alerts to.",
|
||||
"field_jira_deployment": "Jira deployment",
|
||||
"option_jira_deployment_cloud": "Jira Cloud",
|
||||
"option_jira_deployment_selfhosted": "Self-hosted Jira",
|
||||
"jira_selfhosted_tip": "Works with Jira Data Center 8.14 and newer. Create the personal access token from a dedicated service user so issues aren't reported under a personal name.",
|
||||
"jira_selfhosted_tip_link": "Learn how",
|
||||
"field_jira_site": "Site URL",
|
||||
"tooltip_jira_site": "Your Jira Cloud base URL, e.g. https://your-domain.atlassian.net, or a proxy in front of it.",
|
||||
"jira_required_fields": "Site URL, email, API token, project and issue type are required",
|
||||
"tooltip_jira_site_selfhosted": "Your self-hosted Jira base URL, e.g. https://jira.your-company.com, or a proxy in front of it.",
|
||||
"jira_required_fields": "Site URL, project and issue type are required",
|
||||
"jira_cloud_credentials_required": "Email and API token are required for Jira Cloud",
|
||||
"jira_pat_required": "A personal access token is required for self-hosted Jira",
|
||||
"jira_service_account_tip": "Recommended: use a Jira service account so alerts aren't reported under a personal name and the channel keeps working when someone leaves.",
|
||||
"jira_service_account_tip_link": "Learn how",
|
||||
"field_jira_email": "Email",
|
||||
"help_jira_email": "The Atlassian account email used for authentication.",
|
||||
"field_jira_api_token": "API token",
|
||||
"help_jira_api_token": "Create one at id.atlassian.com under Security → API tokens.",
|
||||
"field_jira_pat": "Personal access token",
|
||||
"help_jira_pat": "Create one in Jira under your profile, Personal Access Tokens. Sent as a Bearer header.",
|
||||
"field_jira_project": "Project key",
|
||||
"field_jira_issue_type": "Issue type",
|
||||
"help_jira_issue_type": "An issue type that exists in the project, e.g. Task, Bug or Incident.",
|
||||
@@ -42,6 +52,7 @@
|
||||
"help_jira_summary": "Template for the Jira issue title.",
|
||||
"field_jira_description": "Description",
|
||||
"help_jira_description": "Template for the issue description. Rendered as rich text with a status panel and links back to SigNoz.",
|
||||
"help_jira_description_selfhosted": "Template for the issue description. Rendered as Jira wiki markup with a status panel and links back to SigNoz.",
|
||||
"jira_advanced_section": "Advanced Options",
|
||||
"field_jira_priority": "Priority",
|
||||
"placeholder_jira_priority": "Leave empty to use the project default",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ErrorEvent, StackFrame } from '@sentry/react';
|
||||
import { AxiosError, AxiosHeaders } from 'axios';
|
||||
|
||||
import {
|
||||
hasNoAppFrames,
|
||||
isExpectedRequestFailure,
|
||||
isExternalIntegrationError,
|
||||
SENTRY_IGNORE_ERRORS,
|
||||
} from '../sentryFilters';
|
||||
@@ -138,3 +140,36 @@ describe('SENTRY_IGNORE_ERRORS', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpectedRequestFailure', () => {
|
||||
function axiosErrorWithStatus(status: number): AxiosError {
|
||||
const config = { headers: new AxiosHeaders() };
|
||||
return new AxiosError(
|
||||
`Request failed with status code ${status}`,
|
||||
AxiosError.ERR_BAD_REQUEST,
|
||||
config,
|
||||
undefined,
|
||||
{ status, statusText: '', headers: {}, config, data: {} },
|
||||
);
|
||||
}
|
||||
|
||||
it.each([401, 403])('drops a %i response', (status) => {
|
||||
expect(isExpectedRequestFailure(axiosErrorWithStatus(status))).toBe(true);
|
||||
});
|
||||
|
||||
it('drops a network error', () => {
|
||||
expect(
|
||||
isExpectedRequestFailure(
|
||||
new AxiosError('Network Error', AxiosError.ERR_NETWORK),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([400, 404, 500, 503])('keeps a %i response', (status) => {
|
||||
expect(isExpectedRequestFailure(axiosErrorWithStatus(status))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an error that is not from axios', () => {
|
||||
expect(isExpectedRequestFailure(new Error('Network Error'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ import defaultRoutes, {
|
||||
} from './routes';
|
||||
import {
|
||||
hasNoAppFrames,
|
||||
isExpectedRequestFailure,
|
||||
isExternalIntegrationError,
|
||||
SENTRY_IGNORE_ERRORS,
|
||||
} from './sentryFilters';
|
||||
@@ -341,6 +342,9 @@ function App(): JSX.Element {
|
||||
if (error?.name === 'AbortError') {
|
||||
return null;
|
||||
}
|
||||
if (isExpectedRequestFailure(hint?.originalException)) {
|
||||
return null;
|
||||
}
|
||||
// Ignore benign Monaco cancellation errors (name 'Canceled').
|
||||
if (error?.name === 'Canceled') {
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -14,6 +15,18 @@ export const SENTRY_IGNORE_ERRORS = [
|
||||
/Called on script loaded before session recording is available/,
|
||||
];
|
||||
|
||||
// Not UI bugs: 401/403 come from the session or role, ERR_NETWORK from a
|
||||
// dropped connection.
|
||||
export function isExpectedRequestFailure(error: unknown): boolean {
|
||||
if (!isAxiosError(error)) {
|
||||
return false;
|
||||
}
|
||||
const status = error.response?.status;
|
||||
return (
|
||||
error.code === AxiosError.ERR_NETWORK || status === 401 || status === 403
|
||||
);
|
||||
}
|
||||
|
||||
const APP_ASSETS_PATH = '/assets/';
|
||||
|
||||
// Frames without a position are `[native code]`, which says nothing about the
|
||||
|
||||
@@ -45,7 +45,6 @@ import type {
|
||||
GetRuleHistoryTopContributorsParams,
|
||||
GetRuleHistoryTopContributorsPathParameters,
|
||||
ListRuleViews200,
|
||||
ListRules200,
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
PatchRuleByID200,
|
||||
@@ -427,85 +426,6 @@ export const useUpdateRuleView = <
|
||||
> => {
|
||||
return useMutation(getUpdateRuleViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
export const listRules = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListRules200>({
|
||||
url: `/api/v2/rules`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListRulesQueryKey = () => {
|
||||
return [`/api/v2/rules`] as const;
|
||||
};
|
||||
|
||||
export const getListRulesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listRules>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof listRules>>, TError, TData>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListRulesQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRules>>> = ({
|
||||
signal,
|
||||
}) => listRules(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRules>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListRulesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listRules>>
|
||||
>;
|
||||
export type ListRulesQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
|
||||
export function useListRules<
|
||||
TData = Awaited<ReturnType<typeof listRules>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<Awaited<ReturnType<typeof listRules>>, TError, TData>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListRulesQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
export const invalidateListRules = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListRulesQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a new alert rule
|
||||
* @summary Create alert rule
|
||||
|
||||
@@ -829,6 +829,30 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
|
||||
jira = 'jira',
|
||||
}
|
||||
export interface AlertmanagertypesChannelJiraAuthDTO {
|
||||
/**
|
||||
* @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.
|
||||
*/
|
||||
apiToken?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The Atlassian account email used for basic auth. Required when deployment is cloud.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesJiraDeploymentDTO {
|
||||
cloud = 'cloud',
|
||||
selfhosted = 'selfhosted',
|
||||
}
|
||||
/**
|
||||
* Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives.
|
||||
*/
|
||||
@@ -844,26 +868,23 @@ export type AlertmanagertypesChannelJiraConfigDTOCustomFields = Alertmanagertype
|
||||
|
||||
export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
* @description The Atlassian API token paired with email. A secret; returned by create, update and fetch by ID, never by list.
|
||||
* @description The credentials: email and apiToken when deployment is cloud, pat when deployment is selfhosted.
|
||||
*/
|
||||
apiToken: string;
|
||||
auth: AlertmanagertypesChannelJiraAuthDTO;
|
||||
/**
|
||||
* @type object,null
|
||||
* @description Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives.
|
||||
*/
|
||||
customFields?: AlertmanagertypesChannelJiraConfigDTOCustomFields;
|
||||
/**
|
||||
* @description The Jira edition: cloud for an Atlassian-hosted site, selfhosted for Jira Server or Data Center.
|
||||
*/
|
||||
deployment: AlertmanagertypesJiraDeploymentDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description Alertmanager template for the issue description, rendered as markdown; defaults to the SigNoz Jira default.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The Atlassian account email used for basic auth.
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The issue type name, such as Bug.
|
||||
@@ -906,7 +927,7 @@ export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
* @description The base URL of the Jira Cloud site, for example https://example.atlassian.net. Only Jira Cloud is supported.
|
||||
* @description The base URL of the Jira site, for example https://example.atlassian.net or https://jira.example.com.
|
||||
*/
|
||||
site: string;
|
||||
/**
|
||||
@@ -1716,13 +1737,17 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
|
||||
* @description Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives.
|
||||
*/
|
||||
custom_fields?: AlertmanagertypesJiraReceiverConfigDTOCustomFields;
|
||||
/**
|
||||
* @description The Jira edition, cloud or selfhosted; defaults to cloud.
|
||||
*/
|
||||
deployment?: AlertmanagertypesJiraDeploymentDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description Alertmanager template for the issue description.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @description HTTP client settings; basic auth carries the account email and API token.
|
||||
* @description HTTP client settings; basic auth carries the account email and API token on cloud, authorization carries the Bearer personal access token on self-hosted.
|
||||
*/
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
@@ -1765,7 +1790,7 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
|
||||
send_resolved?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
* @description The Jira Cloud base URL.
|
||||
* @description The Jira base URL.
|
||||
*/
|
||||
site?: string;
|
||||
/**
|
||||
@@ -3579,6 +3604,7 @@ 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',
|
||||
@@ -5273,7 +5299,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5306,7 +5332,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5320,7 +5346,7 @@ export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. Requires limit. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -5665,7 +5691,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5698,7 +5724,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5712,7 +5738,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. Requires limit. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -5845,7 +5871,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5878,7 +5904,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5892,7 +5918,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. Requires limit. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -5944,7 +5970,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page.
|
||||
* @description The opaque nextCursor value from a previous raw result, used to fetch the next page. Honored only by raw requests whose order starts with timestamp, for logs followed by id in the same direction; every other request ignores it.
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
@@ -5977,7 +6003,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest.
|
||||
* @description The maximum number of rows for raw and trace requests, or of series or rows for grouped scalar and time_series requests, keeping the top entries by order. When 0, scalar and time_series requests are unlimited, raw requests ordered by timestamp return a page of 10000 rows to continue with nextCursor, and other raw and trace requests return 100 rows.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
@@ -5991,7 +6017,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of rows to skip before returning results. Prefer cursor for raw requests.
|
||||
* @description The number of rows for raw and trace requests, or of ordered series or rows for scalar and time_series requests, to skip before the page starts. Requires limit. On raw requests the skip counts from where a cursor resumes, and the skipped rows are still read, so prefer cursor for paging.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -6937,6 +6963,7 @@ export enum Querybuildertypesv5RequestTypeDTO {
|
||||
raw_stream = 'raw_stream',
|
||||
trace = 'trace',
|
||||
heatmap = 'heatmap',
|
||||
distribution = 'distribution',
|
||||
}
|
||||
/**
|
||||
* The query kind.
|
||||
@@ -7046,7 +7073,7 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
*/
|
||||
functions?: Querybuildertypesv5FunctionDTO[] | null;
|
||||
/**
|
||||
* @description Accepted but currently ignored; the computed values are returned unfiltered.
|
||||
* @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.
|
||||
*/
|
||||
having?: Querybuildertypesv5HavingDTO;
|
||||
/**
|
||||
@@ -7064,6 +7091,11 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
* @description The unique name of the formula, such as F1, used by selectedQueryName and by panels. Referencing a formula from another formula is not reliable.
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of ordered series or rows to skip before the page starts. Requires limit.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description The sort order of the formula result.
|
||||
@@ -7146,7 +7178,7 @@ export interface Querybuildertypesv5QueryBuilderTraceOperatorDTO {
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The number of raw results to skip before returning. Aggregation requests ignore it.
|
||||
* @description The number of raw or trace rows, or of ordered series or rows for time_series and scalar requests, to skip before the page starts. Requires limit.
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
@@ -10973,7 +11005,7 @@ export interface Querybuildertypesv5TimeSeriesValueDTO {
|
||||
value?: number;
|
||||
/**
|
||||
* @type array
|
||||
* @description The per-band counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
|
||||
* @description The per-bucket counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
|
||||
*/
|
||||
values?: number[];
|
||||
}
|
||||
@@ -11594,7 +11626,7 @@ export interface PromotetypesPostableIndexMaterializedPathsDTO {
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @description The ascending upper bounds of the value bands, set only for heatmap results; each point then carries one count per bound plus a trailing overflow count in values.
|
||||
* @description The ascending upper bounds of the value buckets, set for heatmap and distribution results; each point then carries one count per bound plus a trailing overflow count in values. The first bucket starts at -Inf when its bound is at or under zero, else at zero.
|
||||
*/
|
||||
buckets?: number[];
|
||||
/**
|
||||
@@ -11715,6 +11747,91 @@ export interface Querybuildertypesv5ColumnDescriptorDTO {
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One count per bound in meta.buckets plus a trailing overflow count, summed over every step of the requested time range.
|
||||
*/
|
||||
counts: number[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @description The group-by field values that identify this distribution.
|
||||
*/
|
||||
labels?: Querybuildertypesv5LabelDTO[];
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionPercentilesDTO {
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The 50th percentile.
|
||||
*/
|
||||
p50: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The 75th percentile.
|
||||
*/
|
||||
p75: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The 90th percentile.
|
||||
*/
|
||||
p90: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The 95th percentile.
|
||||
*/
|
||||
p95: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The 99th percentile.
|
||||
*/
|
||||
p99: number;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionAggregationDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The alias of the aggregation, or the internal name __result_N, with N its index, when no alias was given.
|
||||
*/
|
||||
alias: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One distribution per group-by combination.
|
||||
*/
|
||||
distributions: Querybuildertypesv5DistributionDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The zero-based position of the aggregation in the query.
|
||||
*/
|
||||
index: number;
|
||||
/**
|
||||
* @description Display metadata for the aggregation, including the bucket upper bounds the counts are positional against.
|
||||
*/
|
||||
meta?: Querybuildertypesv5AggregationMetaDTO;
|
||||
/**
|
||||
* @description Percentiles over the counts of every distribution added together, estimated the way histogram_quantile does by interpolating linearly within the bucket each rank falls in. Omitted when nothing was counted or meta.buckets is empty.
|
||||
*/
|
||||
percentiles?: Querybuildertypesv5DistributionPercentilesDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionDataDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One entry per aggregation of the query. The order is not guaranteed; match entries to aggregations by index.
|
||||
*/
|
||||
aggregations: Querybuildertypesv5DistributionAggregationDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the query that produced this result.
|
||||
*/
|
||||
queryName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step interval in seconds that was used for each query, keyed by query name.
|
||||
*/
|
||||
@@ -11970,21 +12087,28 @@ export type Querybuildertypesv5QueryDataDTO =
|
||||
| (Querybuildertypesv5TimeSeriesDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5ScalarDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5RawDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5DistributionDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
});
|
||||
@@ -12118,7 +12242,7 @@ export interface Querybuildertypesv5QueryRangeRequestDTO {
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series, ScalarData for scalar, or RawData for raw requests.
|
||||
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for distribution requests.
|
||||
*/
|
||||
export interface Querybuildertypesv5QueryRangeResponseDTO {
|
||||
/**
|
||||
@@ -12836,23 +12960,10 @@ 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.
|
||||
@@ -12872,17 +12983,6 @@ 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.
|
||||
*/
|
||||
@@ -12918,20 +13018,10 @@ 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.
|
||||
@@ -12941,11 +13031,6 @@ 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.
|
||||
*/
|
||||
@@ -12956,16 +13041,6 @@ 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 {
|
||||
@@ -13030,20 +13105,10 @@ 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.
|
||||
@@ -13058,11 +13123,6 @@ 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.
|
||||
*/
|
||||
@@ -13073,11 +13133,6 @@ 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.
|
||||
*/
|
||||
@@ -13091,11 +13146,6 @@ export interface RuletypesRuleDTO {
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The query payload version. Nothing reads it.
|
||||
*/
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export enum RuletypesThresholdKindDTO {
|
||||
@@ -16653,17 +16703,6 @@ export type UpdateRuleView200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRules200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: RuletypesRuleDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateRule201 = {
|
||||
data: RuletypesRuleDTO;
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,8 @@ 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;
|
||||
|
||||
1
frontend/src/assets/Logos/pixeltable.svg
Normal file
1
frontend/src/assets/Logos/pixeltable.svg
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 726 B |
@@ -1,28 +1,13 @@
|
||||
import { Checkbox, Select } from 'antd';
|
||||
import { Select } from 'antd';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { UpdatedWindow } from '../../types';
|
||||
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';
|
||||
|
||||
export interface CreatorOption {
|
||||
email: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const UPDATED_LABELS: Record<UpdatedWindow, string> = {
|
||||
any: 'Any time',
|
||||
today: 'Today',
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days',
|
||||
};
|
||||
|
||||
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
|
||||
const UPDATED_OPTIONS = UPDATED_WINDOWS.map((w) => ({
|
||||
value: w,
|
||||
label: UPDATED_LABELS[w],
|
||||
}));
|
||||
|
||||
interface Props {
|
||||
createdBy: string[];
|
||||
updated: UpdatedWindow;
|
||||
@@ -33,6 +18,8 @@ interface Props {
|
||||
onApply: () => void;
|
||||
// Clear all Created-by selections and run immediately.
|
||||
onClearCreatedBy: () => void;
|
||||
// Ids: `${testIdPrefix}-filter-created-by`, `-filter-updated`.
|
||||
testIdPrefix: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -44,6 +31,7 @@ function FilterChips({
|
||||
onUpdatedChange,
|
||||
onApply,
|
||||
onClearCreatedBy,
|
||||
testIdPrefix,
|
||||
disabled = false,
|
||||
}: Props): JSX.Element {
|
||||
const creatorOptionsData = creatorOptions.map((o) => ({
|
||||
@@ -59,30 +47,14 @@ function FilterChips({
|
||||
|
||||
return (
|
||||
<div className={styles.chips}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
allowClear
|
||||
className={cx(styles.select, styles.selectWide)}
|
||||
placeholder="Created by"
|
||||
<MultiSelectFilter
|
||||
value={createdBy}
|
||||
options={creatorOptionsData}
|
||||
optionFilterProp="label"
|
||||
maxTagCount={1}
|
||||
menuItemSelectedIcon={null}
|
||||
data-testid="dashboards-filter-created-by"
|
||||
placeholder="Created by"
|
||||
testId={`${testIdPrefix}-filter-created-by`}
|
||||
disabled={disabled}
|
||||
onClear={onClearCreatedBy}
|
||||
optionRender={(option): JSX.Element => (
|
||||
<div className={styles.creatorOption}>
|
||||
<Checkbox
|
||||
checked={createdBy.includes(option.value as string)}
|
||||
className={styles.creatorCheck}
|
||||
/>
|
||||
<span className={styles.creatorLabel}>{option.label}</span>
|
||||
</div>
|
||||
)}
|
||||
onChange={(value): void => onCreatedByChange(value as string[])}
|
||||
onChange={onCreatedByChange}
|
||||
onDropdownVisibleChange={runOnClose}
|
||||
/>
|
||||
<Select
|
||||
@@ -92,7 +64,7 @@ function FilterChips({
|
||||
value={updated}
|
||||
options={UPDATED_OPTIONS}
|
||||
optionFilterProp="label"
|
||||
data-testid="dashboards-filter-updated"
|
||||
data-testid={`${testIdPrefix}-filter-updated`}
|
||||
disabled={disabled}
|
||||
onChange={(value): void => onUpdatedChange(value as UpdatedWindow)}
|
||||
/>
|
||||
@@ -86,20 +86,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Created-by option: a presentational checkbox + label (antd handles the toggle).
|
||||
.creatorOption {
|
||||
// Multi-select option: a presentational checkbox + label (antd handles the toggle).
|
||||
.checkOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.creatorCheck {
|
||||
.checkOptionBox {
|
||||
// Flex, not block: a line box would shift with the check mark's baseline.
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.creatorLabel {
|
||||
.checkOptionLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--danger-foreground);
|
||||
}
|
||||
166
frontend/src/components/ListFilter/FilterZone/FilterZone.tsx
Normal file
166
frontend/src/components/ListFilter/FilterZone/FilterZone.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { X } from '@signozhq/icons';
|
||||
|
||||
import type { DslGrammar, SuggestionSource } from '../dsl/types';
|
||||
import SearchBar from '../SearchBar/SearchBar';
|
||||
import type {
|
||||
CreatorOption,
|
||||
FilterAppliedPayload,
|
||||
FilterAppliedType,
|
||||
} from '../types';
|
||||
import FilterChips from './FilterChips';
|
||||
import { useFilterZoneDraft } from './useFilterZoneDraft';
|
||||
|
||||
import styles from './FilterZone.module.scss';
|
||||
|
||||
interface Props {
|
||||
// The last-run query (source of truth for fetching + the dirty baseline).
|
||||
query: string;
|
||||
grammar: DslGrammar;
|
||||
// Ids: `${testIdPrefix}-search-zone`, `-list-search*`, `-filter-zone`,
|
||||
// `-filter-clear`, `-filter-created-by`, `-filter-updated`, `-query-error`.
|
||||
testIdPrefix: string;
|
||||
creatorOptions: CreatorOption[];
|
||||
source: SuggestionSource;
|
||||
// Commit (run) the draft — the only path that triggers a fetch.
|
||||
onQueryChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
// Rendered at the end of the search row (e.g. the New Dashboard action).
|
||||
rightSlot?: ReactNode;
|
||||
// Rendered in the filters row after the chips.
|
||||
extraFilters?: ReactNode;
|
||||
// Shows Clear even when the query is empty.
|
||||
hasExtraFilters?: boolean;
|
||||
// Called by Clear after it empties the query.
|
||||
onClearAll?: () => void;
|
||||
// Shown under the search row.
|
||||
error?: string;
|
||||
// Wraps the search box and the filter controls (e.g. in an authz tooltip).
|
||||
wrapControl?: (node: ReactElement) => ReactElement;
|
||||
onSubmitted?: (hasQuery: boolean) => void;
|
||||
onFilterApplied?: (
|
||||
filterType: FilterAppliedType,
|
||||
payload: FilterAppliedPayload,
|
||||
) => void;
|
||||
onCleared?: () => void;
|
||||
/** Filtering needs `list`: the box and chips edit a query that cannot be run. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const identity = (node: ReactElement): ReactElement => node;
|
||||
|
||||
// The filter command zone: a draft DSL query box (run with Cmd/Ctrl+Enter or the
|
||||
// Run button; a dirty dot signals unrun changes) plus Created-by / Updated chips.
|
||||
function FilterZone({
|
||||
query,
|
||||
grammar,
|
||||
testIdPrefix,
|
||||
creatorOptions,
|
||||
source,
|
||||
onQueryChange,
|
||||
placeholder,
|
||||
rightSlot,
|
||||
extraFilters,
|
||||
hasExtraFilters = false,
|
||||
onClearAll,
|
||||
error,
|
||||
wrapControl = identity,
|
||||
onSubmitted,
|
||||
onFilterApplied,
|
||||
onCleared,
|
||||
disabled = false,
|
||||
}: Props): JSX.Element {
|
||||
const {
|
||||
draft,
|
||||
setDraft,
|
||||
reflected,
|
||||
dirty,
|
||||
isEmpty,
|
||||
run,
|
||||
handleCreatedByChange,
|
||||
handleUpdatedChange,
|
||||
handleClearCreatedBy,
|
||||
handleClear,
|
||||
} = useFilterZoneDraft({
|
||||
query,
|
||||
onQueryChange,
|
||||
onFilterApplied,
|
||||
onCleared,
|
||||
onClearAll,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.filterZone}>
|
||||
<div className={styles.searchRow}>
|
||||
{wrapControl(
|
||||
<div
|
||||
className={styles.searchInput}
|
||||
data-testid={`${testIdPrefix}-search-zone`}
|
||||
>
|
||||
<SearchBar
|
||||
value={draft}
|
||||
grammar={grammar}
|
||||
testIdPrefix={`${testIdPrefix}-list-search`}
|
||||
placeholder={placeholder}
|
||||
source={source}
|
||||
dirty={dirty}
|
||||
disabled={disabled}
|
||||
onChange={setDraft}
|
||||
onSubmit={run}
|
||||
onSubmitted={onSubmitted}
|
||||
/>
|
||||
</div>,
|
||||
)}
|
||||
{rightSlot}
|
||||
</div>
|
||||
{error && (
|
||||
<Typography.Text
|
||||
className={styles.error}
|
||||
testId={`${testIdPrefix}-query-error`}
|
||||
>
|
||||
{error}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<div className={styles.filtersRow}>
|
||||
<Typography.Text className={styles.filtersLabel}>Filters</Typography.Text>
|
||||
{wrapControl(
|
||||
<div
|
||||
className={styles.filterControls}
|
||||
data-testid={`${testIdPrefix}-filter-zone`}
|
||||
>
|
||||
<FilterChips
|
||||
createdBy={reflected.createdBy}
|
||||
updated={reflected.updated}
|
||||
creatorOptions={creatorOptions}
|
||||
testIdPrefix={testIdPrefix}
|
||||
onCreatedByChange={handleCreatedByChange}
|
||||
onUpdatedChange={handleUpdatedChange}
|
||||
onApply={run}
|
||||
onClearCreatedBy={handleClearCreatedBy}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{extraFilters}
|
||||
{(!isEmpty || hasExtraFilters) && (
|
||||
<Button
|
||||
disabledTooltip={undefined}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<X size={12} />}
|
||||
disabled={disabled}
|
||||
onClick={handleClear}
|
||||
testId={`${testIdPrefix}-filter-clear`}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterZone;
|
||||
@@ -0,0 +1,72 @@
|
||||
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;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { act, renderHook } from 'tests/test-utils';
|
||||
|
||||
import { useFilterZoneDraft } from '../useFilterZoneDraft';
|
||||
|
||||
const BASE_QUERY = "name = 'x'";
|
||||
|
||||
type Args = Required<Parameters<typeof useFilterZoneDraft>[0]>;
|
||||
|
||||
function setup(query = BASE_QUERY): {
|
||||
result: { current: ReturnType<typeof useFilterZoneDraft> };
|
||||
rerender: (props: { query: string }) => void;
|
||||
onQueryChange: jest.MockedFunction<Args['onQueryChange']>;
|
||||
onFilterApplied: jest.MockedFunction<Args['onFilterApplied']>;
|
||||
onCleared: jest.MockedFunction<Args['onCleared']>;
|
||||
onClearAll: jest.MockedFunction<Args['onClearAll']>;
|
||||
} {
|
||||
const onQueryChange = jest.fn() as jest.MockedFunction<Args['onQueryChange']>;
|
||||
const onFilterApplied = jest.fn() as jest.MockedFunction<
|
||||
Args['onFilterApplied']
|
||||
>;
|
||||
const onCleared = jest.fn() as jest.MockedFunction<Args['onCleared']>;
|
||||
const onClearAll = jest.fn() as jest.MockedFunction<Args['onClearAll']>;
|
||||
const { result, rerender } = renderHook(
|
||||
({ query: q }: { query: string }) =>
|
||||
useFilterZoneDraft({
|
||||
query: q,
|
||||
onQueryChange,
|
||||
onFilterApplied,
|
||||
onCleared,
|
||||
onClearAll,
|
||||
}),
|
||||
{ initialProps: { query } },
|
||||
);
|
||||
return {
|
||||
result,
|
||||
rerender,
|
||||
onQueryChange,
|
||||
onFilterApplied,
|
||||
onCleared,
|
||||
onClearAll,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useFilterZoneDraft', () => {
|
||||
it('edits a draft without running it until run is called', () => {
|
||||
const { result, onQueryChange } = setup();
|
||||
|
||||
act(() => result.current.setDraft("name = 'y' "));
|
||||
|
||||
expect(result.current.dirty).toBe(true);
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => result.current.run());
|
||||
|
||||
expect(onQueryChange).toHaveBeenCalledWith("name = 'y'");
|
||||
});
|
||||
|
||||
it('does not run a draft that matches the last-run query', () => {
|
||||
const { result, onQueryChange } = setup();
|
||||
|
||||
act(() => result.current.run());
|
||||
|
||||
expect(result.current.dirty).toBe(false);
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resyncs the draft when the query changes from outside', () => {
|
||||
const { result, rerender } = setup();
|
||||
|
||||
act(() => result.current.setDraft("name = 'typing'"));
|
||||
rerender({ query: "name = 'from-view'" });
|
||||
|
||||
expect(result.current.draft).toBe("name = 'from-view'");
|
||||
expect(result.current.dirty).toBe(false);
|
||||
});
|
||||
|
||||
it('stages a created-by pick into the draft without running it', () => {
|
||||
const { result, onQueryChange, onFilterApplied } = setup();
|
||||
|
||||
act(() => result.current.handleCreatedByChange(['a@x.io', 'b@x.io']));
|
||||
|
||||
expect(result.current.draft).toBe(
|
||||
"name = 'x' AND created_by IN ['a@x.io', 'b@x.io']",
|
||||
);
|
||||
expect(result.current.reflected.createdBy).toStrictEqual([
|
||||
'a@x.io',
|
||||
'b@x.io',
|
||||
]);
|
||||
expect(onFilterApplied).toHaveBeenCalledWith('createdBy', { valueCount: 2 });
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs an updated-window pick immediately', () => {
|
||||
const { result, onQueryChange, onFilterApplied } = setup();
|
||||
|
||||
act(() => result.current.handleUpdatedChange('7d'));
|
||||
|
||||
expect(onFilterApplied).toHaveBeenCalledWith('updatedWindow', {});
|
||||
expect(onQueryChange).toHaveBeenCalledTimes(1);
|
||||
expect(onQueryChange.mock.calls[0][0]).toContain("name = 'x' AND updated_at");
|
||||
expect(result.current.reflected.updated).toBe('7d');
|
||||
});
|
||||
|
||||
it('drops the created-by clause and runs when created-by is cleared', () => {
|
||||
const { result, onQueryChange } = setup(
|
||||
"created_by = 'a@x.io' AND name = 'x'",
|
||||
);
|
||||
|
||||
act(() => result.current.handleClearCreatedBy());
|
||||
|
||||
expect(result.current.draft).toBe("name = 'x'");
|
||||
expect(onQueryChange).toHaveBeenCalledWith("name = 'x'");
|
||||
});
|
||||
|
||||
it('empties the draft and runs the empty query on clear', () => {
|
||||
const { result, onQueryChange, onCleared, onClearAll } = setup();
|
||||
|
||||
act(() => result.current.handleClear());
|
||||
|
||||
expect(result.current.draft).toBe('');
|
||||
expect(result.current.isEmpty).toBe(true);
|
||||
expect(onCleared).toHaveBeenCalledTimes(1);
|
||||
expect(onQueryChange).toHaveBeenCalledWith('');
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips re-running an already-empty query on clear', () => {
|
||||
const { result, onQueryChange, onClearAll } = setup('');
|
||||
|
||||
act(() => result.current.handleClear());
|
||||
|
||||
expect(onQueryChange).not.toHaveBeenCalled();
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
15
frontend/src/components/ListFilter/FilterZone/constants.ts
Normal file
15
frontend/src/components/ListFilter/FilterZone/constants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { UpdatedWindow } from '../dsl/types';
|
||||
|
||||
const UPDATED_LABELS: Record<UpdatedWindow, string> = {
|
||||
any: 'Any time',
|
||||
today: 'Today',
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days',
|
||||
};
|
||||
|
||||
const UPDATED_WINDOWS: UpdatedWindow[] = ['any', 'today', '7d', '30d'];
|
||||
|
||||
export const UPDATED_OPTIONS = UPDATED_WINDOWS.map((w) => ({
|
||||
value: w,
|
||||
label: UPDATED_LABELS[w],
|
||||
}));
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
createdByClause,
|
||||
parseReflectedClauses,
|
||||
type ReflectedClauses,
|
||||
spliceClause,
|
||||
updatedClause,
|
||||
} from '../dsl/filterQuery';
|
||||
import type { UpdatedWindow } from '../dsl/types';
|
||||
import type { FilterAppliedPayload, FilterAppliedType } from '../types';
|
||||
|
||||
interface Args {
|
||||
query: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
onFilterApplied?: (
|
||||
filterType: FilterAppliedType,
|
||||
payload: FilterAppliedPayload,
|
||||
) => void;
|
||||
onCleared?: () => void;
|
||||
onClearAll?: () => void;
|
||||
}
|
||||
|
||||
interface FilterZoneDraft {
|
||||
draft: string;
|
||||
setDraft: (value: string) => void;
|
||||
reflected: ReflectedClauses;
|
||||
dirty: boolean;
|
||||
isEmpty: boolean;
|
||||
run: () => void;
|
||||
handleCreatedByChange: (emails: string[]) => void;
|
||||
handleUpdatedChange: (window: UpdatedWindow) => void;
|
||||
handleClearCreatedBy: () => void;
|
||||
handleClear: () => void;
|
||||
}
|
||||
|
||||
// The query box is a DRAFT: typing and the Created-by / Updated dropdowns all edit
|
||||
// the draft (dropdowns splice their clause in); nothing fetches until the draft is
|
||||
// run. The draft re-syncs when the last-run query changes externally (view
|
||||
// select, back/forward).
|
||||
export function useFilterZoneDraft({
|
||||
query,
|
||||
onQueryChange,
|
||||
onFilterApplied,
|
||||
onCleared,
|
||||
onClearAll,
|
||||
}: Args): FilterZoneDraft {
|
||||
const [draft, setDraft] = useState(query);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(query);
|
||||
}, [query]);
|
||||
|
||||
const reflected = useMemo(() => parseReflectedClauses(draft), [draft]);
|
||||
const dirty = draft.trim() !== query.trim();
|
||||
const isEmpty = !draft.trim();
|
||||
|
||||
const commit = useCallback(
|
||||
(next: string): void => {
|
||||
const trimmed = next.trim();
|
||||
if (trimmed !== query) {
|
||||
onQueryChange(trimmed);
|
||||
}
|
||||
},
|
||||
[query, onQueryChange],
|
||||
);
|
||||
|
||||
const run = useCallback((): void => commit(draft), [commit, draft]);
|
||||
|
||||
// Created-by (multi-select) only STAGES its clause into the draft; the query runs
|
||||
// when the dropdown closes (FilterChips fires onApply), not on each pick.
|
||||
const handleCreatedByChange = useCallback(
|
||||
(emails: string[]): void => {
|
||||
onFilterApplied?.('createdBy', { valueCount: emails.length });
|
||||
setDraft((d) => spliceClause(d, 'created_by', createdByClause(emails)));
|
||||
},
|
||||
[onFilterApplied],
|
||||
);
|
||||
|
||||
// Updated (single-select) is a definitive single choice, so it splices AND runs
|
||||
// immediately (with the fresh value, avoiding the async draft-state lag).
|
||||
const handleUpdatedChange = useCallback(
|
||||
(window: UpdatedWindow): void => {
|
||||
onFilterApplied?.('updatedWindow', {});
|
||||
const next = spliceClause(draft, 'updated_at', updatedClause(window));
|
||||
setDraft(next);
|
||||
commit(next);
|
||||
},
|
||||
[draft, commit, onFilterApplied],
|
||||
);
|
||||
|
||||
// Clear-all on the Created-by select removes its clause and runs immediately.
|
||||
const handleClearCreatedBy = useCallback((): void => {
|
||||
const next = spliceClause(draft, 'created_by', null);
|
||||
setDraft(next);
|
||||
commit(next);
|
||||
}, [draft, commit]);
|
||||
|
||||
// Clear resets the draft and runs immediately (empty query).
|
||||
const handleClear = useCallback((): void => {
|
||||
onCleared?.();
|
||||
setDraft('');
|
||||
if (query !== '') {
|
||||
onQueryChange('');
|
||||
}
|
||||
onClearAll?.();
|
||||
}, [query, onQueryChange, onCleared, onClearAll]);
|
||||
|
||||
return {
|
||||
draft,
|
||||
setDraft,
|
||||
reflected,
|
||||
dirty,
|
||||
isEmpty,
|
||||
run,
|
||||
handleCreatedByChange,
|
||||
handleUpdatedChange,
|
||||
handleClearCreatedBy,
|
||||
handleClear,
|
||||
};
|
||||
}
|
||||
@@ -165,8 +165,9 @@
|
||||
|
||||
.leadIcon {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
top: 50%;
|
||||
left: 10px;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -176,8 +177,10 @@
|
||||
|
||||
.submit {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
top: 50%;
|
||||
display: flex;
|
||||
right: 6px;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -15,18 +15,20 @@ import CodeMirror, {
|
||||
Prec,
|
||||
type ReactCodeMirrorRef,
|
||||
} from '@uiw/react-codemirror';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPage/constants/events';
|
||||
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
|
||||
|
||||
import type { SuggestionSource } from '../../utils/dslSuggestions';
|
||||
import type { DslGrammar, SuggestionSource } from '../dsl/types';
|
||||
import { dslCompletionSource } from './dslCompletions';
|
||||
import { dslHighlight } from './dslHighlight';
|
||||
import { BASIC_SETUP, EMPTY_SOURCE, editorTheme } from './editorConfig';
|
||||
|
||||
import styles from './SearchBar.module.scss';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
grammar: DslGrammar;
|
||||
// Ids: `${testIdPrefix}`, `-submit`, `-dirty`.
|
||||
testIdPrefix: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder?: string;
|
||||
@@ -35,53 +37,20 @@ interface Props {
|
||||
dirty?: boolean;
|
||||
/** Filtering needs `list`: the draft edits a query that cannot be run. */
|
||||
disabled?: boolean;
|
||||
onSubmitted?: (hasQuery: boolean) => void;
|
||||
}
|
||||
|
||||
const EMPTY_SOURCE: SuggestionSource = {
|
||||
tagKeys: [],
|
||||
tagValuesByKey: {},
|
||||
creatorEmails: [],
|
||||
};
|
||||
|
||||
// Token colours come from `.cm-dsl-*` classes (SearchBar.module.scss); this only
|
||||
// handles layout — transparent background, monospace, single line.
|
||||
const editorTheme = EditorView.theme({
|
||||
'&': { fontSize: '13px', backgroundColor: 'transparent' },
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-content': {
|
||||
// Leave room for the overlaid lead icon (left) and Run button (right).
|
||||
padding: '8px 120px 8px 30px',
|
||||
fontFamily: "'Space Mono', monospace",
|
||||
color: 'var(--l1-foreground)',
|
||||
caretColor: 'var(--l1-foreground)',
|
||||
},
|
||||
'.cm-line': { padding: '0' },
|
||||
'.cm-scroller': { fontFamily: 'inherit', lineHeight: '1.5' },
|
||||
'.cm-placeholder': { color: 'var(--l3-foreground)' },
|
||||
});
|
||||
|
||||
const BASIC_SETUP = {
|
||||
lineNumbers: false,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
highlightActiveLineGutter: false,
|
||||
highlightSelectionMatches: false,
|
||||
bracketMatching: false,
|
||||
closeBrackets: false,
|
||||
autocompletion: false,
|
||||
// Use the browser's native caret so it blinks (a CM-drawn cursor needs extra
|
||||
// styling and doesn't blink by default).
|
||||
drawSelection: false,
|
||||
} as const;
|
||||
|
||||
function SearchBar({
|
||||
value,
|
||||
grammar,
|
||||
testIdPrefix,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder = "Filter with DSL (e.g. name CONTAINS 'foo')",
|
||||
source = EMPTY_SOURCE,
|
||||
dirty = false,
|
||||
disabled = false,
|
||||
onSubmitted,
|
||||
}: Props): JSX.Element {
|
||||
const isMac = getUserOperatingSystem() === UserOperatingSystem.MACOS;
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
@@ -89,12 +58,12 @@ function SearchBar({
|
||||
// Refs so the (memoised, stable) extensions always see the latest values.
|
||||
const sourceRef = useRef(source);
|
||||
sourceRef.current = source;
|
||||
const grammarRef = useRef(grammar);
|
||||
grammarRef.current = grammar;
|
||||
const handleSubmit = useCallback((): void => {
|
||||
void logEvent(DashboardListEvents.SearchExecuted, {
|
||||
hasQuery: !!value.trim(),
|
||||
});
|
||||
onSubmitted?.(!!value.trim());
|
||||
onSubmit();
|
||||
}, [onSubmit, value]);
|
||||
}, [onSubmit, onSubmitted, value]);
|
||||
const onSubmitRef = useRef(handleSubmit);
|
||||
onSubmitRef.current = handleSubmit;
|
||||
|
||||
@@ -110,7 +79,12 @@ function SearchBar({
|
||||
? []
|
||||
: [
|
||||
autocompletion({
|
||||
override: [dslCompletionSource(() => sourceRef.current)],
|
||||
override: [
|
||||
dslCompletionSource(
|
||||
() => sourceRef.current,
|
||||
() => grammarRef.current,
|
||||
),
|
||||
],
|
||||
closeOnBlur: true,
|
||||
activateOnTyping: true,
|
||||
maxRenderedOptions: 100,
|
||||
@@ -168,7 +142,7 @@ function SearchBar({
|
||||
basicSetup={BASIC_SETUP}
|
||||
indentWithTab={false}
|
||||
editable={!disabled}
|
||||
data-testid="dashboards-list-search"
|
||||
data-testid={testIdPrefix}
|
||||
onChange={(next): void => onChange(next.replace(/\n/g, ' '))}
|
||||
/>
|
||||
<span
|
||||
@@ -182,13 +156,13 @@ function SearchBar({
|
||||
size="sm"
|
||||
aria-label="Run search"
|
||||
disabled={disabled}
|
||||
testId="dashboards-list-search-submit"
|
||||
testId={`${testIdPrefix}-submit`}
|
||||
onClick={handleSubmit}
|
||||
prefix={
|
||||
dirty ? (
|
||||
<span
|
||||
className={styles.dirtyDot}
|
||||
data-testid="dashboards-list-search-dirty"
|
||||
data-testid={`${testIdPrefix}-dirty`}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
@@ -7,18 +7,21 @@ import {
|
||||
} from '@codemirror/autocomplete';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
|
||||
import {
|
||||
getSuggestions,
|
||||
type SuggestionSource,
|
||||
} from '../../utils/dslSuggestions';
|
||||
import { getSuggestions } from '../dsl/dslSuggestions';
|
||||
import type { DslGrammar, SuggestionSource } from '../dsl/types';
|
||||
|
||||
// `getSource` is a getter so the freshest tags/users (which load async) are read
|
||||
// on every keystroke.
|
||||
// Getters so the freshest keys/users (which load async) are read on every
|
||||
// keystroke.
|
||||
export const dslCompletionSource =
|
||||
(getSource: () => SuggestionSource) =>
|
||||
(getSource: () => SuggestionSource, getGrammar: () => DslGrammar) =>
|
||||
(context: CompletionContext): CompletionResult | null => {
|
||||
const query = context.state.doc.toString();
|
||||
const { items, ctx } = getSuggestions(query, context.pos, getSource());
|
||||
const { items, ctx } = getSuggestions(
|
||||
query,
|
||||
context.pos,
|
||||
getSource(),
|
||||
getGrammar(),
|
||||
);
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// CodeMirror syntax highlighting for the dashboards-list DSL, driven by the same
|
||||
// CodeMirror syntax highlighting for the list-filter DSL, driven by the same
|
||||
// tokenizer used for autocomplete (dslTokenizer). Each key / operator / value /
|
||||
// AND-OR span gets a `cm-dsl-*` class coloured in SearchBar.module.scss.
|
||||
import { RangeSetBuilder } from '@codemirror/state';
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type ViewUpdate,
|
||||
} from '@codemirror/view';
|
||||
|
||||
import { scanTerm, splitTopLevelTerms } from '../../utils/dslTokenizer';
|
||||
import { scanTerm, splitTopLevelTerms } from '../dsl/dslTokenizer';
|
||||
|
||||
const KEY = Decoration.mark({ class: 'cm-dsl-key' });
|
||||
const OP = Decoration.mark({ class: 'cm-dsl-op' });
|
||||
41
frontend/src/components/ListFilter/SearchBar/editorConfig.ts
Normal file
41
frontend/src/components/ListFilter/SearchBar/editorConfig.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
|
||||
import type { SuggestionSource } from '../dsl/types';
|
||||
|
||||
export const EMPTY_SOURCE: SuggestionSource = {
|
||||
dynamicKeys: [],
|
||||
valuesByKey: {},
|
||||
creatorEmails: [],
|
||||
};
|
||||
|
||||
// Token colours come from `.cm-dsl-*` classes (SearchBar.module.scss); this only
|
||||
// handles layout — transparent background, monospace, single line.
|
||||
export const editorTheme = EditorView.theme({
|
||||
'&': { fontSize: '13px', backgroundColor: 'transparent' },
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-content': {
|
||||
// Leave room for the overlaid lead icon (left) and Run button (right).
|
||||
padding: '8px 120px 8px 30px',
|
||||
fontFamily: "'Space Mono', monospace",
|
||||
color: 'var(--l1-foreground)',
|
||||
caretColor: 'var(--l1-foreground)',
|
||||
},
|
||||
'.cm-line': { padding: '0' },
|
||||
// Whole pixels: at 19.5px the placeholder line rounds 1px taller than text.
|
||||
'.cm-scroller': { fontFamily: 'inherit', lineHeight: '20px' },
|
||||
'.cm-placeholder': { color: 'var(--l3-foreground)' },
|
||||
});
|
||||
|
||||
export const BASIC_SETUP = {
|
||||
lineNumbers: false,
|
||||
foldGutter: false,
|
||||
highlightActiveLine: false,
|
||||
highlightActiveLineGutter: false,
|
||||
highlightSelectionMatches: false,
|
||||
bracketMatching: false,
|
||||
closeBrackets: false,
|
||||
autocompletion: false,
|
||||
// Use the browser's native caret so it blinks (a CM-drawn cursor needs extra
|
||||
// styling and doesn't blink by default).
|
||||
drawSelection: false,
|
||||
} as const;
|
||||
@@ -1,36 +1,21 @@
|
||||
// Frontend mirror of the backend dashboards-list filter DSL grammar
|
||||
// (pkg/types/dashboardtypes/list_filter.go). There is no keys/operators API, so
|
||||
// the valid keys, per-field operators, and value shapes are encoded here. Keep in
|
||||
// sync with the backend allow-lists.
|
||||
// Grammar-independent operator tables for the list-filter DSL. Key sets and
|
||||
// field types come from a DslGrammar config mirroring the backend allow-list
|
||||
// (e.g. pkg/types/dashboardtypes/list_filter.go); keep those configs in sync.
|
||||
import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants';
|
||||
|
||||
// Reserved keys the backend recognises as dashboard columns. Any other key is a
|
||||
// tag key. (`updated_by` and `source` are NOT queryable.)
|
||||
export const RESERVED_KEYS = [
|
||||
'name',
|
||||
'description',
|
||||
'created_by',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'locked',
|
||||
] as const;
|
||||
import type { DslField, DslFieldType, DslGrammar } from './types';
|
||||
|
||||
export type DslFieldType = 'string' | 'timestamp' | 'bool' | 'tag';
|
||||
export const findField = (
|
||||
grammar: DslGrammar,
|
||||
key: string,
|
||||
): DslField | undefined =>
|
||||
grammar.fields.find((f) => f.key === key.toLowerCase());
|
||||
|
||||
const RESERVED_FIELD_TYPES: Record<string, DslFieldType> = {
|
||||
name: 'string',
|
||||
description: 'string',
|
||||
created_by: 'string',
|
||||
created_at: 'timestamp',
|
||||
updated_at: 'timestamp',
|
||||
locked: 'bool',
|
||||
};
|
||||
// A reserved key maps to its column type; anything else is dynamic.
|
||||
export const classifyField = (grammar: DslGrammar, key: string): DslFieldType =>
|
||||
findField(grammar, key)?.type ?? 'dynamic';
|
||||
|
||||
// A reserved key maps to its column type; anything else is a tag.
|
||||
export const classifyField = (key: string): DslFieldType =>
|
||||
RESERVED_FIELD_TYPES[key.toLowerCase()] ?? 'tag';
|
||||
|
||||
// String/tag comparison operators, reusing the shared operator constants
|
||||
// String/dynamic comparison operators, reusing the shared operator constants
|
||||
// (REGEXP is allow-listed by the backend but unimplemented, so it is omitted).
|
||||
const STRING_OPS = [
|
||||
OPERATORS['='],
|
||||
@@ -45,10 +30,10 @@ const STRING_OPS = [
|
||||
negateOperator(OPERATORS.IN),
|
||||
];
|
||||
|
||||
// Valid operators per field type. Tags additionally support existence checks.
|
||||
// Valid operators per field type. Dynamic keys additionally support existence checks.
|
||||
export const OPERATOR_MATRIX: Record<DslFieldType, string[]> = {
|
||||
string: STRING_OPS,
|
||||
tag: [...STRING_OPS, OPERATORS.EXISTS, negateOperator(OPERATORS.EXISTS)],
|
||||
dynamic: [...STRING_OPS, OPERATORS.EXISTS, negateOperator(OPERATORS.EXISTS)],
|
||||
timestamp: [
|
||||
OPERATORS['='],
|
||||
OPERATORS['!='],
|
||||
@@ -60,6 +45,12 @@ export const OPERATOR_MATRIX: Record<DslFieldType, string[]> = {
|
||||
negateOperator(OPERATORS.BETWEEN),
|
||||
],
|
||||
bool: [OPERATORS['='], OPERATORS['!=']],
|
||||
enum: [
|
||||
OPERATORS['='],
|
||||
OPERATORS['!='],
|
||||
OPERATORS.IN,
|
||||
negateOperator(OPERATORS.IN),
|
||||
],
|
||||
};
|
||||
|
||||
// Operators that take no value (the clause is complete after the operator).
|
||||
@@ -1,16 +1,23 @@
|
||||
import { getSuggestions, type SuggestionSource } from './dslSuggestions';
|
||||
import { DASHBOARDS_DSL_GRAMMAR } from 'pages/DashboardsListPage/list.config';
|
||||
|
||||
import { getSuggestions } from './dslSuggestions';
|
||||
import type { SuggestionSource } from './types';
|
||||
|
||||
const source: SuggestionSource = {
|
||||
tagKeys: ['env', 'team'],
|
||||
tagValuesByKey: { env: ['prod', 'dev'] },
|
||||
dynamicKeys: ['env', 'team'],
|
||||
valuesByKey: { env: ['prod', 'dev'] },
|
||||
creatorEmails: ['alice@x.io', 'bob@x.io'],
|
||||
currentUserEmail: 'me@x.io',
|
||||
};
|
||||
|
||||
const labels = (q: string, caret: number): string[] =>
|
||||
getSuggestions(q, caret, source).items.map((i) => i.label);
|
||||
getSuggestions(q, caret, source, DASHBOARDS_DSL_GRAMMAR).items.map(
|
||||
(i) => i.label,
|
||||
);
|
||||
const inserts = (q: string, caret: number): string[] =>
|
||||
getSuggestions(q, caret, source).items.map((i) => i.insertText);
|
||||
getSuggestions(q, caret, source, DASHBOARDS_DSL_GRAMMAR).items.map(
|
||||
(i) => i.insertText,
|
||||
);
|
||||
|
||||
describe('getSuggestions — key stage', () => {
|
||||
it('suggests reserved + tag keys matching the partial', () => {
|
||||
@@ -61,7 +68,12 @@ describe('getSuggestions — operator stage', () => {
|
||||
|
||||
describe('getSuggestions — value stage', () => {
|
||||
it('suggests creator emails (current user first, labelled) for created_by', () => {
|
||||
const out = getSuggestions('created_by = ', 13, source);
|
||||
const out = getSuggestions(
|
||||
'created_by = ',
|
||||
13,
|
||||
source,
|
||||
DASHBOARDS_DSL_GRAMMAR,
|
||||
);
|
||||
expect(out.items[0].label).toBe('me@x.io (me)');
|
||||
expect(out.items.map((i) => i.insertText)).toContain("'alice@x.io' ");
|
||||
});
|
||||
@@ -86,7 +98,7 @@ describe('getSuggestions — value stage', () => {
|
||||
describe('getSuggestions — IN list values', () => {
|
||||
it('wraps a fresh IN value in a bracketed list, caret before the close', () => {
|
||||
const q = 'created_by IN ';
|
||||
const out = getSuggestions(q, q.length, source);
|
||||
const out = getSuggestions(q, q.length, source, DASHBOARDS_DSL_GRAMMAR);
|
||||
const alice = out.items.find((i) => i.label === 'alice@x.io');
|
||||
expect(alice?.insertText).toBe("['alice@x.io']");
|
||||
expect(alice?.caretOffset).toBe(1);
|
||||
@@ -100,7 +112,7 @@ describe('getSuggestions — IN list values', () => {
|
||||
it('appends to an open list and excludes already-entered values', () => {
|
||||
const q = "env IN ['prod', ]";
|
||||
const caret = q.indexOf(']'); // inside the brackets, after "'prod', "
|
||||
const out = getSuggestions(q, caret, source);
|
||||
const out = getSuggestions(q, caret, source, DASHBOARDS_DSL_GRAMMAR);
|
||||
expect(out.items.map((i) => i.label)).toStrictEqual(['dev']);
|
||||
const dev = out.items[0];
|
||||
// No closing bracket (the existing `]` stays); caret lands at the end.
|
||||
@@ -110,7 +122,7 @@ describe('getSuggestions — IN list values', () => {
|
||||
|
||||
it('treats a complete last literal (no trailing comma) as entered', () => {
|
||||
const q = "env IN ['prod'";
|
||||
const out = getSuggestions(q, q.length, source);
|
||||
const out = getSuggestions(q, q.length, source, DASHBOARDS_DSL_GRAMMAR);
|
||||
// 'prod' is committed; only 'dev' remains and it appends after it.
|
||||
expect(out.items.map((i) => i.label)).toStrictEqual(['dev']);
|
||||
expect(out.items[0].insertText).toBe("['prod', 'dev'");
|
||||
@@ -1,48 +1,29 @@
|
||||
// Stage-aware autocomplete for the dashboards-list DSL: as the caret moves, it
|
||||
// Stage-aware autocomplete for the list-filter DSL: as the caret moves, it
|
||||
// suggests keys, then operators (valid for that key's field type), then values
|
||||
// (drawn from the tags/users the API reports). Grammar lives in dslGrammar.ts;
|
||||
// caret/stage detection in dslTokenizer.ts.
|
||||
// (drawn from the dynamic keys/users the API reports). Operator tables live in
|
||||
// dslGrammar.ts; caret/stage detection in dslTokenizer.ts.
|
||||
import {
|
||||
classifyField,
|
||||
findField,
|
||||
LIST_OPERATORS,
|
||||
literal,
|
||||
OPERATOR_MATRIX,
|
||||
RESERVED_KEYS,
|
||||
VALUELESS_OPERATORS,
|
||||
} from './dslGrammar';
|
||||
import { type CaretContext, getCaretContext } from './dslTokenizer';
|
||||
import type {
|
||||
DslGrammar,
|
||||
Suggestion,
|
||||
SuggestionSource,
|
||||
SuggestionsResult,
|
||||
} from './types';
|
||||
|
||||
// Show the full key set (the popup scrolls); values are capped a bit higher too.
|
||||
const KEY_LIMIT = 100;
|
||||
const VALUE_LIMIT = 50;
|
||||
|
||||
export interface SuggestionSource {
|
||||
// Reserved column keys (from the list response, falling back to RESERVED_KEYS).
|
||||
reservedKeys?: string[];
|
||||
tagKeys: string[];
|
||||
// Known values per tag key (lower-cased key → values).
|
||||
tagValuesByKey: Record<string, string[]>;
|
||||
// Creator emails for `created_by` value suggestions.
|
||||
creatorEmails: string[];
|
||||
currentUserEmail?: string;
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
label: string;
|
||||
insertText: string;
|
||||
kind: 'key' | 'operator' | 'value' | 'connector';
|
||||
// Right-aligned hint in the popup — e.g. 'field' vs 'tag' so reserved columns
|
||||
// are distinguishable from org tag keys.
|
||||
detail?: string;
|
||||
// Chars to move the caret back from the end of `insertText` after applying.
|
||||
// Used to land the caret inside a `[...]` list so multi-select can continue.
|
||||
caretOffset?: number;
|
||||
}
|
||||
|
||||
export interface SuggestionsResult {
|
||||
items: Suggestion[];
|
||||
ctx: CaretContext;
|
||||
}
|
||||
// Reserved keys whose values are user emails.
|
||||
const USER_KEYS = new Set(['created_by', 'updated_by']);
|
||||
|
||||
const includesInsensitive = (haystack: string, needle: string): boolean =>
|
||||
haystack.toLowerCase().includes(needle.toLowerCase());
|
||||
@@ -52,17 +33,20 @@ const dedupe = (values: string[]): string[] => Array.from(new Set(values));
|
||||
const keySuggestions = (
|
||||
partial: string,
|
||||
source: SuggestionSource,
|
||||
grammar: DslGrammar,
|
||||
): Suggestion[] => {
|
||||
const reserved = source.reservedKeys ?? [...RESERVED_KEYS];
|
||||
const hidden = new Set(grammar.hiddenKeys ?? []);
|
||||
const known = grammar.fields.map((f) => f.key);
|
||||
const reserved = (source.reservedKeys ?? known).filter((k) => !hidden.has(k));
|
||||
const reservedSet = new Set(reserved.map((k) => k.toLowerCase()));
|
||||
// Show reserved keys in our defined (purpose) order — name, description, … —
|
||||
// rather than the API's alphabetical order; any extra API keys follow, then tags.
|
||||
const known = RESERVED_KEYS as readonly string[];
|
||||
// Show reserved keys in the grammar's (purpose) order — name, description, … —
|
||||
// rather than the API's alphabetical order; any extra API keys follow, then
|
||||
// dynamic keys.
|
||||
const orderedReserved = [
|
||||
...known.filter((k) => reserved.includes(k)),
|
||||
...reserved.filter((k) => !known.includes(k)),
|
||||
];
|
||||
const keys = dedupe([...orderedReserved, ...source.tagKeys]);
|
||||
const keys = dedupe([...orderedReserved, ...source.dynamicKeys]);
|
||||
const lower = partial.toLowerCase();
|
||||
return keys
|
||||
.filter((k) => includesInsensitive(k, partial) && k.toLowerCase() !== lower)
|
||||
@@ -71,16 +55,19 @@ const keySuggestions = (
|
||||
label: k,
|
||||
insertText: `${k} `,
|
||||
kind: 'key',
|
||||
// Distinguish built-in columns from org tag keys in the popup.
|
||||
detail: reservedSet.has(k.toLowerCase()) ? 'field' : 'tag',
|
||||
// Distinguish built-in columns from dynamic keys in the popup.
|
||||
detail: reservedSet.has(k.toLowerCase())
|
||||
? 'field'
|
||||
: grammar.dynamicKey.detail,
|
||||
}));
|
||||
};
|
||||
|
||||
const operatorSuggestions = (
|
||||
fieldKey: string,
|
||||
partial: string,
|
||||
grammar: DslGrammar,
|
||||
): Suggestion[] => {
|
||||
const ops = OPERATOR_MATRIX[classifyField(fieldKey)];
|
||||
const ops = OPERATOR_MATRIX[classifyField(grammar, fieldKey)];
|
||||
const upper = partial.toUpperCase();
|
||||
return ops
|
||||
.filter((op) => op.startsWith(upper))
|
||||
@@ -155,15 +142,39 @@ const parseListPartial = (partial: string): ListPartial => {
|
||||
};
|
||||
};
|
||||
|
||||
// Known values for a key: user emails, enum values, or the dynamic key's values
|
||||
// (a reserved dynamic key falls back to its prefixed alias, e.g. `labels.severity`).
|
||||
const knownValues = (
|
||||
key: string,
|
||||
source: SuggestionSource,
|
||||
grammar: DslGrammar,
|
||||
): string[] | null => {
|
||||
const type = classifyField(grammar, key);
|
||||
if (USER_KEYS.has(key) && type === 'string') {
|
||||
return source.currentUserEmail
|
||||
? dedupe([source.currentUserEmail, ...source.creatorEmails])
|
||||
: source.creatorEmails;
|
||||
}
|
||||
if (type === 'enum') {
|
||||
return [...(findField(grammar, key)?.values ?? [])];
|
||||
}
|
||||
if (type === 'dynamic') {
|
||||
const alias = `${grammar.dynamicKey.prefix}${key}`.toLowerCase();
|
||||
return source.valuesByKey[key] ?? source.valuesByKey[alias] ?? [];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const valueSuggestions = (
|
||||
ctx: CaretContext,
|
||||
source: SuggestionSource,
|
||||
grammar: DslGrammar,
|
||||
): Suggestion[] => {
|
||||
if (VALUELESS_OPERATORS.has(ctx.operator)) {
|
||||
return [];
|
||||
}
|
||||
const key = ctx.fieldKey.toLowerCase();
|
||||
const type = classifyField(ctx.fieldKey);
|
||||
const type = classifyField(grammar, ctx.fieldKey);
|
||||
|
||||
// List operators (`IN`/`NOT IN`) take a `[...]` list: parse what's already in
|
||||
// the brackets so we exclude entered values and append rather than replace.
|
||||
@@ -180,15 +191,8 @@ const valueSuggestions = (
|
||||
.map((v) => ({ label: v, insertText: `${v} `, kind: 'value' }));
|
||||
}
|
||||
|
||||
let raw: string[] = [];
|
||||
if (key === 'created_by') {
|
||||
const emails = source.currentUserEmail
|
||||
? dedupe([source.currentUserEmail, ...source.creatorEmails])
|
||||
: source.creatorEmails;
|
||||
raw = emails;
|
||||
} else if (type === 'tag') {
|
||||
raw = source.tagValuesByKey[key] ?? [];
|
||||
} else {
|
||||
const raw = knownValues(key, source, grammar);
|
||||
if (raw === null) {
|
||||
// name / description / timestamps have no known value set — free text.
|
||||
return [];
|
||||
}
|
||||
@@ -199,7 +203,7 @@ const valueSuggestions = (
|
||||
.slice(0, VALUE_LIMIT)
|
||||
.map((v) => {
|
||||
const label =
|
||||
v === source.currentUserEmail && key === 'created_by' ? `${v} (me)` : v;
|
||||
v === source.currentUserEmail && USER_KEYS.has(key) ? `${v} (me)` : v;
|
||||
if (isList) {
|
||||
const values = [...(list?.committed ?? []), v].map(literal).join(', ');
|
||||
return {
|
||||
@@ -239,15 +243,16 @@ export const getSuggestions = (
|
||||
query: string,
|
||||
caret: number,
|
||||
source: SuggestionSource,
|
||||
grammar: DslGrammar,
|
||||
): SuggestionsResult => {
|
||||
const ctx = getCaretContext(query, caret);
|
||||
let items: Suggestion[] = [];
|
||||
if (ctx.stage === 'key') {
|
||||
items = keySuggestions(ctx.partial, source);
|
||||
items = keySuggestions(ctx.partial, source, grammar);
|
||||
} else if (ctx.stage === 'operator') {
|
||||
items = operatorSuggestions(ctx.fieldKey, ctx.partial);
|
||||
items = operatorSuggestions(ctx.fieldKey, ctx.partial, grammar);
|
||||
} else if (ctx.stage === 'value') {
|
||||
items = valueSuggestions(ctx, source);
|
||||
items = valueSuggestions(ctx, source, grammar);
|
||||
} else if (ctx.stage === 'connector') {
|
||||
items = connectorSuggestions(ctx.partial);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Quote/paren/bracket-aware scanning of the dashboards-list filter DSL, shared by
|
||||
// Quote/paren/bracket-aware scanning of the list-filter DSL, shared by
|
||||
// the autocomplete engine (dslSuggestions) and the clause splicer (filterQuery).
|
||||
// The DSL is a boolean composition of `key OP value` terms joined by AND/OR.
|
||||
import {
|
||||
@@ -1,5 +1,5 @@
|
||||
// Pure, side-effect-free helpers over the backend list-filter DSL string
|
||||
// (`GET /api/v2/dashboards?query=...`). The query string is the single source of
|
||||
// (e.g. `GET /api/v2/dashboards?query=...`). The query string is the single source of
|
||||
// truth; these build/parse/splice clauses for it. Free of React so the same
|
||||
// logic backs live filtering, saved views, and built-in views.
|
||||
//
|
||||
@@ -7,11 +7,11 @@
|
||||
// name CONTAINS 'text'
|
||||
// created_by = 'a@b.com' | created_by IN ['a@b.com', 'c@d.com']
|
||||
// updated_at >= '2026-06-01T00:00:00.000Z' (RFC3339)
|
||||
// env = 'prod' (any non-reserved key is a tag filter)
|
||||
// env = 'prod' (any non-reserved key is a dynamic filter, e.g. a tag)
|
||||
// locked = true
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import type { UpdatedWindow } from '../types';
|
||||
import type { ClauseValue, UpdatedWindow } from './types';
|
||||
import { literal } from './dslGrammar';
|
||||
import { scanTerm, splitTopLevelTerms } from './dslTokenizer';
|
||||
|
||||
@@ -37,6 +37,16 @@ export const isQueryEmpty = (query: string): boolean => !query.trim();
|
||||
export const areQueriesEqual = (a: string, b: string): boolean =>
|
||||
a.trim() === b.trim();
|
||||
|
||||
export const clause = (key: string, op: string, value: ClauseValue): string => {
|
||||
if (typeof value === 'boolean') {
|
||||
return `${key} ${op} ${value}`;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return `${key} ${op} ${literal(value)}`;
|
||||
}
|
||||
return `${key} ${op} [${value.map(literal).join(', ')}]`;
|
||||
};
|
||||
|
||||
// The `created_by` clause for a set of emails (single `=`, multiple `IN [...]`).
|
||||
export const createdByClause = (emails: string[]): string | null => {
|
||||
if (emails.length === 0) {
|
||||
@@ -0,0 +1,69 @@
|
||||
import { RULES_DSL_GRAMMAR } from 'container/ListAlertRules/list.config';
|
||||
|
||||
import { getSuggestions } from './dslSuggestions';
|
||||
import type { SuggestionSource } from './types';
|
||||
|
||||
const source: SuggestionSource = {
|
||||
reservedKeys: [
|
||||
'alert_type',
|
||||
'created_at',
|
||||
'created_by',
|
||||
'labels.<key>',
|
||||
'name',
|
||||
'rule_type',
|
||||
'severity',
|
||||
'updated_at',
|
||||
'updated_by',
|
||||
],
|
||||
dynamicKeys: ['labels.team', 'labels.env'],
|
||||
valuesByKey: { 'labels.team': ['infra'], 'labels.severity': ['critical'] },
|
||||
creatorEmails: ['alice@x.io'],
|
||||
currentUserEmail: 'me@x.io',
|
||||
};
|
||||
|
||||
const suggest = (q: string): ReturnType<typeof getSuggestions>['items'] =>
|
||||
getSuggestions(q, q.length, source, RULES_DSL_GRAMMAR).items;
|
||||
const labels = (q: string): string[] => suggest(q).map((i) => i.label);
|
||||
|
||||
describe('getSuggestions — rules grammar', () => {
|
||||
it('orders reserved keys, hides labels.<key> and appends label keys', () => {
|
||||
expect(labels('')).toStrictEqual([
|
||||
'name',
|
||||
'severity',
|
||||
'alert_type',
|
||||
'rule_type',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'labels.team',
|
||||
'labels.env',
|
||||
]);
|
||||
expect(suggest('labels.t').map((i) => i.detail)).toStrictEqual(['label']);
|
||||
});
|
||||
|
||||
it('restricts enum keys to = != IN NOT IN and suggests their values', () => {
|
||||
expect(labels('rule_type ')).toStrictEqual([
|
||||
'rule_type =',
|
||||
'rule_type !=',
|
||||
'rule_type IN',
|
||||
'rule_type NOT IN',
|
||||
]);
|
||||
expect(labels('rule_type = ')).toStrictEqual([
|
||||
'threshold_rule',
|
||||
'promql_rule',
|
||||
'anomaly_rule',
|
||||
]);
|
||||
expect(labels("alert_type = 'LOGS")).toStrictEqual(['LOGS_BASED_ALERT']);
|
||||
});
|
||||
|
||||
it('gives severity and labels.* the label operators and values', () => {
|
||||
expect(labels('severity ')).toContain('severity EXISTS');
|
||||
expect(labels('severity = ')).toStrictEqual(['critical']);
|
||||
expect(labels('labels.team = ')).toStrictEqual(['infra']);
|
||||
});
|
||||
|
||||
it('suggests user emails for updated_by', () => {
|
||||
expect(labels('updated_by = ')).toStrictEqual(['me@x.io (me)', 'alice@x.io']);
|
||||
});
|
||||
});
|
||||
61
frontend/src/components/ListFilter/dsl/types.ts
Normal file
61
frontend/src/components/ListFilter/dsl/types.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { CaretContext } from './dslTokenizer';
|
||||
|
||||
// `dynamic` keys (tags, labels) take string ops plus EXISTS / NOT EXISTS.
|
||||
export type DslFieldType = 'string' | 'timestamp' | 'bool' | 'enum' | 'dynamic';
|
||||
|
||||
export interface DslField {
|
||||
key: string;
|
||||
type: DslFieldType;
|
||||
values?: readonly string[];
|
||||
}
|
||||
|
||||
// A list-filter DSL grammar, mirroring a backend `list_filter.go` allow-list.
|
||||
export interface DslGrammar {
|
||||
// Reserved fields in suggestion order. Any key not listed here is dynamic.
|
||||
fields: readonly DslField[];
|
||||
// `detail` labels dynamic keys in the popup. A reserved dynamic key (e.g.
|
||||
// rules' `severity`) aliases `${prefix}${key}` for value lookups.
|
||||
dynamicKey: { detail: string; prefix: string };
|
||||
// Advertised by the API but not filterable (e.g. `labels.<key>`).
|
||||
hiddenKeys?: readonly string[];
|
||||
}
|
||||
|
||||
export type ClauseValue = string | boolean | readonly string[];
|
||||
|
||||
export type ClauseBuilder<K extends string> = (
|
||||
key: K,
|
||||
op: string,
|
||||
value: ClauseValue,
|
||||
) => string;
|
||||
|
||||
export interface SuggestionSource {
|
||||
// Reserved keys from the list response, falling back to the grammar's.
|
||||
reservedKeys?: string[];
|
||||
// Dynamic keys exactly as typed (e.g. `team`, `labels.team`).
|
||||
dynamicKeys: string[];
|
||||
// Known values per dynamic key (lower-cased full key -> values).
|
||||
valuesByKey: Record<string, string[]>;
|
||||
// Values for `created_by` / `updated_by`.
|
||||
creatorEmails: string[];
|
||||
currentUserEmail?: string;
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
label: string;
|
||||
insertText: string;
|
||||
kind: 'key' | 'operator' | 'value' | 'connector';
|
||||
// Right-aligned hint in the popup — e.g. 'field' vs 'tag' so reserved columns
|
||||
// are distinguishable from dynamic keys.
|
||||
detail?: string;
|
||||
// Chars to move the caret back from the end of `insertText` after applying.
|
||||
// Used to land the caret inside a `[...]` list so multi-select can continue.
|
||||
caretOffset?: number;
|
||||
}
|
||||
|
||||
export interface SuggestionsResult {
|
||||
items: Suggestion[];
|
||||
ctx: CaretContext;
|
||||
}
|
||||
|
||||
// Relative "updated within" windows offered by the Updated filter dropdown.
|
||||
export type UpdatedWindow = 'any' | 'today' | '7d' | '30d';
|
||||
9
frontend/src/components/ListFilter/types.ts
Normal file
9
frontend/src/components/ListFilter/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface CreatorOption {
|
||||
email: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type FilterAppliedType = 'createdBy' | 'updatedWindow';
|
||||
|
||||
// Analytics payload for a chip change (`valueCount` for Created by).
|
||||
export type FilterAppliedPayload = Record<string, unknown>;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useListUsers } from 'api/generated/services/users';
|
||||
|
||||
import type { CreatorOption } from '../components/FilterZone/FilterChips';
|
||||
import type { CreatorOption } from './types';
|
||||
|
||||
interface Args {
|
||||
currentUserEmail: string;
|
||||
@@ -0,0 +1,46 @@
|
||||
.commandHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.headingBlock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dirtyDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--warning-background);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.countPill {
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,33 +1,40 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import NewDashboardButton from './NewDashboardButton';
|
||||
|
||||
import styles from './DashboardsList.module.scss';
|
||||
import styles from './ListCommandHeader.module.scss';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
count: number;
|
||||
isModified: boolean;
|
||||
onCreate: () => void;
|
||||
action?: ReactNode;
|
||||
// Ids: `${testIdPrefix}-list-heading`.
|
||||
testIdPrefix?: string;
|
||||
}
|
||||
|
||||
function CommandHeader({
|
||||
function ListCommandHeader({
|
||||
label,
|
||||
count,
|
||||
isModified,
|
||||
onCreate,
|
||||
action,
|
||||
testIdPrefix,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.commandHeader}>
|
||||
<div className={styles.headingBlock}>
|
||||
<Typography.Title className={styles.title}>{label}</Typography.Title>
|
||||
<Typography.Title
|
||||
className={styles.title}
|
||||
testId={testIdPrefix && `${testIdPrefix}-list-heading`}
|
||||
>
|
||||
{label}
|
||||
</Typography.Title>
|
||||
{isModified && <span className={styles.dirtyDot} title="Unsaved changes" />}
|
||||
<span className={styles.countPill}>{count}</span>
|
||||
</div>
|
||||
<div className={styles.grow} />
|
||||
<NewDashboardButton onClick={onCreate} />
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommandHeader;
|
||||
export default ListCommandHeader;
|
||||
@@ -2,20 +2,25 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { PanelLeftClose, PanelLeftOpen } from '@signozhq/icons';
|
||||
|
||||
import styles from './StatusBar.module.scss';
|
||||
import styles from './ListStatusBar.module.scss';
|
||||
|
||||
interface Props {
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
count: number;
|
||||
total: number;
|
||||
/** Plural noun for the counted items, e.g. "dashboards". */
|
||||
noun: string;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function StatusBar({
|
||||
function ListStatusBar({
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
count,
|
||||
total,
|
||||
noun,
|
||||
testId,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<div className={styles.statusBar}>
|
||||
@@ -27,15 +32,15 @@ function StatusBar({
|
||||
collapsed ? <PanelLeftOpen size={14} /> : <PanelLeftClose size={14} />
|
||||
}
|
||||
onClick={onToggleCollapse}
|
||||
testId="dashboards-rail-toggle"
|
||||
testId={testId}
|
||||
>
|
||||
{collapsed ? 'Expand' : 'Collapse'}
|
||||
</Button>
|
||||
<Typography.Text className={styles.count}>
|
||||
{count} of {total} dashboards
|
||||
{count} of {total} {noun}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatusBar;
|
||||
export default ListStatusBar;
|
||||
@@ -9,10 +9,6 @@
|
||||
transition: width 0.18s cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
|
||||
.denied {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
width: 0;
|
||||
border-right-color: transparent;
|
||||
@@ -195,30 +191,3 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.savePopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 280px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.saveTitle {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.saveLabel {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.saveActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
264
frontend/src/components/ListViews/ViewsRail/ViewsRail.tsx
Normal file
264
frontend/src/components/ListViews/ViewsRail/ViewsRail.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Bookmark, Plus, Search } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
|
||||
import ViewNamePopover from 'components/ViewNamePopover/ViewNamePopover';
|
||||
|
||||
import type { BuiltinListView, ViewSaveMode } from '../types';
|
||||
import ViewsRailDirtyPanel from './ViewsRailDirtyPanel';
|
||||
import ViewsRailItem from './ViewsRailItem';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
interface Props {
|
||||
activeViewId: string;
|
||||
builtinViews: readonly BuiltinListView[];
|
||||
customViews: { id: string; name: string }[];
|
||||
customViewsLoading: boolean;
|
||||
isCustomActive: boolean;
|
||||
isModified: boolean;
|
||||
collapsed?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onSave: (name: string) => void;
|
||||
onSaveChanges: () => void;
|
||||
onReset: () => void;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onRename: (id: string, name: string) => void;
|
||||
/** Prefixes the rail's test ids, e.g. `<prefix>-view-<id>`. */
|
||||
testIdPrefix: string;
|
||||
saveTestIdPrefix?: string;
|
||||
renameTestIdPrefix?: string;
|
||||
customSectionLabel?: string;
|
||||
deleteDescription?: string;
|
||||
/** Wraps the rail body, e.g. in an authz guard. */
|
||||
guard?: (content: ReactElement) => ReactElement;
|
||||
onViewSaved?: (mode: ViewSaveMode) => void;
|
||||
onViewRenamed?: () => void;
|
||||
onViewDeleted?: () => void;
|
||||
}
|
||||
|
||||
// Purely presentational — active view, dirty state, and handlers come from
|
||||
// `useListViews`.
|
||||
function ViewsRail({
|
||||
activeViewId,
|
||||
builtinViews,
|
||||
customViews,
|
||||
customViewsLoading,
|
||||
isCustomActive,
|
||||
isModified,
|
||||
collapsed = false,
|
||||
onSelect,
|
||||
onSave,
|
||||
onSaveChanges,
|
||||
onReset,
|
||||
onDelete,
|
||||
onRename,
|
||||
testIdPrefix,
|
||||
saveTestIdPrefix = 'save-view',
|
||||
renameTestIdPrefix = 'rename-view',
|
||||
customSectionLabel = 'My views',
|
||||
deleteDescription = 'This removes the saved view.',
|
||||
guard,
|
||||
onViewSaved,
|
||||
onViewRenamed,
|
||||
onViewDeleted,
|
||||
}: Props): JSX.Element {
|
||||
const [saveOpen, setSaveOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const { contextHolder, confirmDelete } = useDeleteConfirm();
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const matchesQuery = (label: string): boolean =>
|
||||
!q || label.toLowerCase().includes(q);
|
||||
|
||||
const personal = builtinViews.filter(
|
||||
(v) => v.section === 'personal' && matchesQuery(v.label),
|
||||
);
|
||||
const system = builtinViews.filter(
|
||||
(v) => v.section === 'system' && matchesQuery(v.label),
|
||||
);
|
||||
const custom = customViews.filter((v) => matchesQuery(v.name));
|
||||
const noMatches =
|
||||
!!q && personal.length === 0 && system.length === 0 && custom.length === 0;
|
||||
|
||||
const onConfirmDelete = useCallback(
|
||||
(id: string, label: string): void => {
|
||||
confirmDelete({
|
||||
title: (
|
||||
<Typography.Title level={5}>
|
||||
Delete the{' '}
|
||||
<Typography.Text className={styles.deleteName}>{label}</Typography.Text>{' '}
|
||||
view?
|
||||
</Typography.Title>
|
||||
),
|
||||
content: deleteDescription,
|
||||
// Return the delete promise so the modal's Delete button stays loading
|
||||
// until the backend call settles, then closes — matching dashboard delete.
|
||||
onConfirm: (): Promise<void> => {
|
||||
onViewDeleted?.();
|
||||
return onDelete(id);
|
||||
},
|
||||
});
|
||||
},
|
||||
[confirmDelete, deleteDescription, onDelete, onViewDeleted],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(id: string, name: string): void => {
|
||||
onViewRenamed?.();
|
||||
onRename(id, name);
|
||||
},
|
||||
[onRename, onViewRenamed],
|
||||
);
|
||||
|
||||
const handleSaveAsView = (name: string): void => {
|
||||
onViewSaved?.('new');
|
||||
onSave(name);
|
||||
};
|
||||
|
||||
const handleSaveViewChanges = (): void => {
|
||||
onViewSaved?.('update');
|
||||
onSaveChanges();
|
||||
};
|
||||
|
||||
const renderBuiltin = (v: BuiltinListView): JSX.Element => (
|
||||
<ViewsRailItem
|
||||
key={v.id}
|
||||
id={v.id}
|
||||
label={v.label}
|
||||
icon={v.icon}
|
||||
active={v.id === activeViewId}
|
||||
isModified={isModified}
|
||||
testIdPrefix={testIdPrefix}
|
||||
renameTestIdPrefix={renameTestIdPrefix}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<h4 className={styles.headerTitle}>Views</h4>
|
||||
<ViewNamePopover
|
||||
open={saveOpen}
|
||||
onOpenChange={setSaveOpen}
|
||||
onSubmit={handleSaveAsView}
|
||||
title="Save as view"
|
||||
confirmLabel="Save view"
|
||||
testIdPrefix={saveTestIdPrefix}
|
||||
tooltip="Save current filters as a view"
|
||||
trigger={
|
||||
<Button
|
||||
aria-label="Save current filters as a view"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
testId={`${testIdPrefix}-view-save-trigger`}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
value={query}
|
||||
placeholder="Filter views by name"
|
||||
prefix={<Search size={12} />}
|
||||
testId={`${testIdPrefix}-view-search`}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>): void =>
|
||||
setQuery(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.scroll}>
|
||||
{personal.length > 0 && (
|
||||
<>
|
||||
<div className={styles.groupLabel}>Personal</div>
|
||||
{personal.map(renderBuiltin)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{system.length > 0 && (
|
||||
<>
|
||||
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
|
||||
System
|
||||
</div>
|
||||
{system.map(renderBuiltin)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(!q || custom.length > 0) && (
|
||||
<>
|
||||
<div className={cx(styles.groupLabel, styles.groupLabelSpaced)}>
|
||||
{customSectionLabel}
|
||||
<Typography.Text className={styles.groupCount}>
|
||||
{customViews.length}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{customViewsLoading ? (
|
||||
<div className={styles.empty}>Loading views…</div>
|
||||
) : customViews.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
No saved views yet. Filter the list, then save it as a view.
|
||||
</div>
|
||||
) : (
|
||||
custom.map((v) => (
|
||||
<ViewsRailItem
|
||||
key={v.id}
|
||||
id={v.id}
|
||||
label={v.name}
|
||||
icon={Bookmark}
|
||||
active={v.id === activeViewId}
|
||||
isModified={isModified}
|
||||
testIdPrefix={testIdPrefix}
|
||||
renameTestIdPrefix={renameTestIdPrefix}
|
||||
onSelect={onSelect}
|
||||
onRename={handleRename}
|
||||
onDelete={onConfirmDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{noMatches && (
|
||||
<div className={styles.searchEmpty}>
|
||||
No views match “{query}”
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isModified && (
|
||||
<ViewsRailDirtyPanel
|
||||
isCustomActive={isCustomActive}
|
||||
testIdPrefix={testIdPrefix}
|
||||
onSaveChanges={handleSaveViewChanges}
|
||||
onSaveAs={(): void => setSaveOpen(true)}
|
||||
onReset={onReset}
|
||||
/>
|
||||
)}
|
||||
{contextHolder}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className={cx(styles.rail, { [styles.collapsed]: collapsed })}>
|
||||
{guard ? guard(content) : content}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewsRail;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
interface Props {
|
||||
isCustomActive: boolean;
|
||||
testIdPrefix: string;
|
||||
onSaveChanges: () => void;
|
||||
onSaveAs: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
function ViewsRailDirtyPanel({
|
||||
isCustomActive,
|
||||
testIdPrefix,
|
||||
onSaveChanges,
|
||||
onSaveAs,
|
||||
onReset,
|
||||
}: Props): JSX.Element {
|
||||
if (isCustomActive) {
|
||||
return (
|
||||
<div className={styles.dirtyPanel}>
|
||||
<div className={styles.dirtyTitle}>Unsaved changes</div>
|
||||
<div className={styles.dirtyActions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={onSaveChanges}
|
||||
testId={`${testIdPrefix}-view-save-changes`}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onSaveAs}
|
||||
testId={`${testIdPrefix}-view-save-as`}
|
||||
>
|
||||
Save as…
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
testId={`${testIdPrefix}-view-reset`}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.dirtyPanel, styles.dirtyPanelDefault)}>
|
||||
<div className={styles.dirtyTitle}>Filters active</div>
|
||||
<div className={styles.dirtyActions}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={onSaveAs}
|
||||
testId={`${testIdPrefix}-view-save-as-new`}
|
||||
>
|
||||
Save as new view
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
testId={`${testIdPrefix}-view-reset`}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewsRailDirtyPanel;
|
||||
105
frontend/src/components/ListViews/ViewsRail/ViewsRailItem.tsx
Normal file
105
frontend/src/components/ListViews/ViewsRail/ViewsRailItem.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { PenLine, Trash2 } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
import ViewNamePopover from 'components/ViewNamePopover/ViewNamePopover';
|
||||
|
||||
import type { ListViewIcon } from '../types';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ListViewIcon;
|
||||
active: boolean;
|
||||
isModified: boolean;
|
||||
testIdPrefix: string;
|
||||
renameTestIdPrefix: string;
|
||||
onSelect: (id: string) => void;
|
||||
/** Present only for custom (editable) views. */
|
||||
onRename?: (id: string, name: string) => void;
|
||||
onDelete?: (id: string, label: string) => void;
|
||||
}
|
||||
|
||||
function ViewsRailItem({
|
||||
id,
|
||||
label,
|
||||
icon: Icon,
|
||||
active,
|
||||
isModified,
|
||||
testIdPrefix,
|
||||
renameTestIdPrefix,
|
||||
onSelect,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: Props): JSX.Element {
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.row, { [styles.rowActive]: active })}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
width="100%"
|
||||
onClick={(): void => onSelect(id)}
|
||||
testId={`${testIdPrefix}-view-${id}`}
|
||||
prefix={<Icon size={16} className={styles.itemIcon} />}
|
||||
suffix={
|
||||
active && isModified ? (
|
||||
<div className={styles.dirtyDot} title="Unsaved changes" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
{onRename && onDelete && (
|
||||
<div className={styles.itemActions}>
|
||||
<ViewNamePopover
|
||||
open={renaming}
|
||||
onOpenChange={setRenaming}
|
||||
onSubmit={(name): void => onRename(id, name)}
|
||||
title="Rename view"
|
||||
confirmLabel="Rename"
|
||||
initialName={label}
|
||||
testIdPrefix={renameTestIdPrefix}
|
||||
tooltip="Rename view"
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Rename view"
|
||||
testId={`${testIdPrefix}-view-rename-${id}`}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<PenLine size={12} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Tooltip title="Delete view">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Delete view"
|
||||
testId={`${testIdPrefix}-view-delete-${id}`}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
onDelete(id, label);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewsRailItem;
|
||||
@@ -4,21 +4,21 @@ import { render, screen } from 'tests/test-utils';
|
||||
import { setupAuthzAllow } from 'lib/authz/utils/authz-test-utils';
|
||||
import { DashboardListPermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
|
||||
import type { BuiltinView } from '../../../utils/views';
|
||||
import ViewsRailGuard from 'pages/DashboardsListPage/components/DashboardsList/ViewsRailGuard';
|
||||
|
||||
import type { BuiltinListView } from '../../types';
|
||||
import ViewsRail from '../ViewsRail';
|
||||
|
||||
const BUILTIN_VIEWS: BuiltinView[] = [
|
||||
const BUILTIN_VIEWS: BuiltinListView[] = [
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All dashboards',
|
||||
icon: Bookmark,
|
||||
section: 'system',
|
||||
} as BuiltinView,
|
||||
},
|
||||
];
|
||||
|
||||
const CUSTOM_VIEWS = [
|
||||
{ id: 'view-1', name: 'My prod dashboards', query: '', userEmail: 'a@b.co' },
|
||||
];
|
||||
const CUSTOM_VIEWS = [{ id: 'view-1', name: 'My prod dashboards' }];
|
||||
|
||||
function renderRail(
|
||||
props?: Partial<React.ComponentProps<typeof ViewsRail>>,
|
||||
@@ -27,7 +27,7 @@ function renderRail(
|
||||
<ViewsRail
|
||||
activeViewId="view-1"
|
||||
builtinViews={BUILTIN_VIEWS}
|
||||
customViews={CUSTOM_VIEWS as never}
|
||||
customViews={CUSTOM_VIEWS}
|
||||
customViewsLoading={false}
|
||||
isCustomActive
|
||||
isModified
|
||||
@@ -37,6 +37,8 @@ function renderRail(
|
||||
onReset={jest.fn()}
|
||||
onDelete={jest.fn()}
|
||||
onRename={jest.fn()}
|
||||
testIdPrefix="dashboards"
|
||||
guard={(content): JSX.Element => <ViewsRailGuard>{content}</ViewsRailGuard>}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Bookmark, Pin } from '@signozhq/icons';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import type { BuiltinListView } from '../../types';
|
||||
import ViewsRail from '../ViewsRail';
|
||||
|
||||
const PREFIX = 'things';
|
||||
|
||||
const BUILTIN_VIEWS: BuiltinListView[] = [
|
||||
{ id: 'mine', label: 'My things', icon: Pin, section: 'personal' },
|
||||
{ id: 'all', label: 'All things', icon: Bookmark, section: 'system' },
|
||||
];
|
||||
|
||||
const CUSTOM_VIEWS = [
|
||||
{ id: 'view-1', name: 'Prod things' },
|
||||
{ id: 'view-2', name: 'Staging things' },
|
||||
];
|
||||
|
||||
type Props = React.ComponentProps<typeof ViewsRail>;
|
||||
|
||||
function renderRail(props?: Partial<Props>): Props {
|
||||
const allProps: Props = {
|
||||
activeViewId: 'all',
|
||||
builtinViews: BUILTIN_VIEWS,
|
||||
customViews: CUSTOM_VIEWS,
|
||||
customViewsLoading: false,
|
||||
isCustomActive: false,
|
||||
isModified: false,
|
||||
onSelect: jest.fn(),
|
||||
onSave: jest.fn(),
|
||||
onSaveChanges: jest.fn(),
|
||||
onReset: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
onRename: jest.fn(),
|
||||
testIdPrefix: PREFIX,
|
||||
...props,
|
||||
};
|
||||
render(<ViewsRail {...allProps} />);
|
||||
return allProps;
|
||||
}
|
||||
|
||||
describe('ViewsRail', () => {
|
||||
it('groups built-in views by section and custom views under their label', () => {
|
||||
renderRail({ customSectionLabel: 'Team views' });
|
||||
|
||||
expect(screen.getByText('Personal')).toBeInTheDocument();
|
||||
expect(screen.getByText('System')).toBeInTheDocument();
|
||||
expect(screen.getByText('Team views')).toBeInTheDocument();
|
||||
['mine', 'all', 'view-1', 'view-2'].forEach((id) =>
|
||||
expect(screen.getByTestId(`${PREFIX}-view-${id}`)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('selects a view by id', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const props = renderRail();
|
||||
|
||||
await user.click(screen.getByTestId(`${PREFIX}-view-view-2`));
|
||||
|
||||
expect(props.onSelect).toHaveBeenCalledWith('view-2');
|
||||
});
|
||||
|
||||
it('filters views by name and says when none match', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderRail();
|
||||
const search = screen.getByTestId(`${PREFIX}-view-search`);
|
||||
|
||||
await user.type(search, 'prod');
|
||||
|
||||
expect(screen.getByTestId(`${PREFIX}-view-view-1`)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(`${PREFIX}-view-view-2`)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(`${PREFIX}-view-all`)).not.toBeInTheDocument();
|
||||
|
||||
await user.clear(search);
|
||||
await user.type(search, 'nothing');
|
||||
|
||||
expect(screen.getByText(/No views match/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading and empty states for custom views', () => {
|
||||
const { unmount } = render(
|
||||
<ViewsRail
|
||||
activeViewId="all"
|
||||
builtinViews={BUILTIN_VIEWS}
|
||||
customViews={[]}
|
||||
customViewsLoading
|
||||
isCustomActive={false}
|
||||
isModified={false}
|
||||
onSelect={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
onSaveChanges={jest.fn()}
|
||||
onReset={jest.fn()}
|
||||
onDelete={jest.fn()}
|
||||
onRename={jest.fn()}
|
||||
testIdPrefix={PREFIX}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Loading views…')).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderRail({ customViews: [] });
|
||||
expect(screen.getByText(/No saved views yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the unsaved-changes panel while filters match the view', () => {
|
||||
renderRail();
|
||||
|
||||
expect(screen.queryByTestId(`${PREFIX}-view-reset`)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers save, save as and reset for a modified custom view', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onViewSaved = jest.fn();
|
||||
const props = renderRail({
|
||||
activeViewId: 'view-1',
|
||||
isCustomActive: true,
|
||||
isModified: true,
|
||||
onViewSaved,
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId(`${PREFIX}-view-save-changes`));
|
||||
expect(onViewSaved).toHaveBeenCalledWith('update');
|
||||
expect(props.onSaveChanges).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(screen.getByTestId(`${PREFIX}-view-save-as`)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId(`${PREFIX}-view-reset`));
|
||||
expect(props.onReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('offers only save as new for a modified built-in view', () => {
|
||||
renderRail({ isModified: true });
|
||||
|
||||
expect(screen.getByTestId(`${PREFIX}-view-save-as-new`)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(`${PREFIX}-view-save-changes`),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('wraps its body in the guard it is given', () => {
|
||||
renderRail({
|
||||
guard: (content): JSX.Element => (
|
||||
<div data-testid="rail-guard">{content}</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('rail-guard')).toContainElement(
|
||||
screen.getByTestId(`${PREFIX}-view-all`),
|
||||
);
|
||||
});
|
||||
});
|
||||
43
frontend/src/components/ListViews/types.ts
Normal file
43
frontend/src/components/ListViews/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Pin } from '@signozhq/icons';
|
||||
|
||||
// All @signozhq icons share this component type.
|
||||
export type ListViewIcon = typeof Pin;
|
||||
|
||||
export interface BuiltinListView {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ListViewIcon;
|
||||
section: 'personal' | 'system';
|
||||
}
|
||||
|
||||
export interface EmptyStateCopy {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// `clientFilter` narrows the rows the server already returned.
|
||||
export interface BuiltinViewConfig<
|
||||
F,
|
||||
Ctx = undefined,
|
||||
Row = never,
|
||||
> extends BuiltinListView {
|
||||
filter: (current: F, ctx: Ctx) => F;
|
||||
clientFilter?: (rows: Row[], ctx: Ctx) => Row[];
|
||||
emptyState?: EmptyStateCopy;
|
||||
}
|
||||
|
||||
export interface SavedListView<F> {
|
||||
id: string;
|
||||
name: string;
|
||||
filter: F;
|
||||
}
|
||||
|
||||
export interface SavedListViewsApi<F> {
|
||||
views: SavedListView<F>[];
|
||||
isLoading: boolean;
|
||||
createView: (name: string, filter: F) => Promise<SavedListView<F> | null>;
|
||||
updateView: (id: string, name: string, filter: F) => void;
|
||||
deleteView: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export type ViewSaveMode = 'new' | 'update';
|
||||
@@ -18,71 +18,51 @@
|
||||
right: -2px;
|
||||
width: 240px;
|
||||
|
||||
[data-format-menu='back'],
|
||||
[data-format-menu='font-option'],
|
||||
[data-format-menu='font-size'],
|
||||
[data-format-menu='edit-columns'] {
|
||||
--button-justify-content: flex-start;
|
||||
}
|
||||
|
||||
[data-format-menu='font-option'],
|
||||
[data-format-menu='font-size'],
|
||||
[data-format-menu='edit-columns'] {
|
||||
--button-label-min-width: 100%;
|
||||
--button-font-size: 13px;
|
||||
--button-variant-link-font-weight: 400;
|
||||
--button-size-line-height: 20px;
|
||||
}
|
||||
|
||||
[data-format-menu='back'] {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
[data-format-menu='font-option'],
|
||||
[data-format-menu='font-size'] {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.menu-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.font-size-dropdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.14px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background-color: unset !important;
|
||||
.horizontal-line {
|
||||
margin-inline: -12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.option-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
justify-content: space-between;
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal; /* 142.857% */
|
||||
letter-spacing: 0.14px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.text:hover {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.option-btn:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,31 +82,6 @@
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
height: 20px;
|
||||
padding: 4px 0px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: none !important;
|
||||
.font-value {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.icon {
|
||||
}
|
||||
}
|
||||
|
||||
.value:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
@@ -198,90 +153,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.max-lines-per-row-input {
|
||||
display: flex;
|
||||
|
||||
.ant-input-number-handler-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-input-number {
|
||||
min-width: 36px;
|
||||
width: auto;
|
||||
border-right: none;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
text-align: center;
|
||||
height: 26px;
|
||||
border-radius: 0;
|
||||
|
||||
&:active,
|
||||
&:focus {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input-number-focused {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ant-input-number-input-wrap {
|
||||
input {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
|
||||
&:active,
|
||||
&:focus {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.periscope-btn {
|
||||
box-shadow: none;
|
||||
padding: 6px 12px;
|
||||
height: 26px;
|
||||
|
||||
border-radius: 0px 1px 1px 0px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-columns-container {
|
||||
padding: 12px;
|
||||
|
||||
.edit-columns-btn {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
padding: 4px 0px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: none !important;
|
||||
|
||||
.edit-columns-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-columns-btn:hover {
|
||||
background-color: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.add-new-column-container {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, InputNumber, Popover, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { InputNumber } from '@signozhq/ui/input-number';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
LogViewMode,
|
||||
@@ -11,13 +12,15 @@ import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Minus,
|
||||
Plus,
|
||||
SlidersVertical,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import './LogsFormatOptionsMenu.styles.scss';
|
||||
|
||||
const MIN_LINES_PER_ROW = 1;
|
||||
const MAX_LINES_PER_ROW = 10;
|
||||
const FONT_SIZES = [FontSize.SMALL, FontSize.MEDIUM, FontSize.LARGE];
|
||||
|
||||
interface LogsFormatOptionsMenuProps {
|
||||
items: any;
|
||||
selectedOptionFormat: any;
|
||||
@@ -62,25 +65,11 @@ function OptionsMenu({
|
||||
onChange(key);
|
||||
};
|
||||
|
||||
const incrementMaxLinesPerRow = (): void => {
|
||||
if (maxLinesPerRow < 10) {
|
||||
setMaxLinesPerRow(maxLinesPerRow + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const decrementMaxLinesPerRow = (): void => {
|
||||
if (maxLinesPerRow > 1) {
|
||||
setMaxLinesPerRow(maxLinesPerRow - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinesPerRowChange = (maxLinesPerRow: number | null): void => {
|
||||
if (
|
||||
maxLinesPerRow &&
|
||||
Number.isInteger(maxLinesNumber) &&
|
||||
maxLinesPerRow > 1
|
||||
) {
|
||||
setMaxLinesPerRow(maxLinesPerRow);
|
||||
const handleLinesPerRowChange = (value: number | null): void => {
|
||||
if (value !== null) {
|
||||
setMaxLinesPerRow(
|
||||
Math.min(Math.max(value, MIN_LINES_PER_ROW), MAX_LINES_PER_ROW),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -111,51 +100,36 @@ function OptionsMenu({
|
||||
{isFontSizeOptionsOpen ? (
|
||||
<div className="font-size-dropdown">
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
width="100%"
|
||||
data-format-menu="back"
|
||||
testId="font-size-back"
|
||||
prefix={<ChevronLeft size={14} />}
|
||||
onClick={(): void => setIsFontSizeOptionsOpen(false)}
|
||||
className="back-btn"
|
||||
type="text"
|
||||
>
|
||||
<ChevronLeft size={14} className="icon" />
|
||||
<Typography.Text className="text">Select font size</Typography.Text>
|
||||
Select font size
|
||||
</Button>
|
||||
<div className="horizontal-line" />
|
||||
<div className="content">
|
||||
<Button
|
||||
onClick={(): void => {
|
||||
setFontSizeValue(FontSize.SMALL);
|
||||
}}
|
||||
className="option-btn"
|
||||
type="text"
|
||||
>
|
||||
<Typography.Text className="text">{FontSize.SMALL}</Typography.Text>
|
||||
{fontSizeValue === FontSize.SMALL && (
|
||||
<Check size={14} className="icon" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(): void => {
|
||||
setFontSizeValue(FontSize.MEDIUM);
|
||||
}}
|
||||
className="option-btn"
|
||||
type="text"
|
||||
>
|
||||
<Typography.Text className="text">{FontSize.MEDIUM}</Typography.Text>
|
||||
{fontSizeValue === FontSize.MEDIUM && (
|
||||
<Check size={14} className="icon" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(): void => {
|
||||
setFontSizeValue(FontSize.LARGE);
|
||||
}}
|
||||
className="option-btn"
|
||||
type="text"
|
||||
>
|
||||
<Typography.Text className="text">{FontSize.LARGE}</Typography.Text>
|
||||
{fontSizeValue === FontSize.LARGE && (
|
||||
<Check size={14} className="icon" />
|
||||
)}
|
||||
</Button>
|
||||
{FONT_SIZES.map((size) => (
|
||||
<Button
|
||||
key={size}
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
width="100%"
|
||||
data-format-menu="font-option"
|
||||
testId={`font-size-option-${size}`}
|
||||
onClick={(): void => setFontSizeValue(size)}
|
||||
>
|
||||
<span className="menu-row">
|
||||
{size}
|
||||
{fontSizeValue === size && <Check size={14} />}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -163,14 +137,20 @@ function OptionsMenu({
|
||||
<div className="font-size-container">
|
||||
<div className="title">Font Size</div>
|
||||
<Button
|
||||
className="value"
|
||||
type="text"
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
width="100%"
|
||||
data-format-menu="font-size"
|
||||
testId="font-size-select"
|
||||
onClick={(): void => {
|
||||
setIsFontSizeOptionsOpen(true);
|
||||
}}
|
||||
>
|
||||
<Typography.Text className="font-value">{fontSizeValue}</Typography.Text>
|
||||
<ChevronRight size={14} className="icon" />
|
||||
<span className="menu-row">
|
||||
{fontSizeValue}
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="horizontal-line" />
|
||||
@@ -200,30 +180,20 @@ function OptionsMenu({
|
||||
<div className="horizontal-line" />
|
||||
<div className="max-lines-per-row">
|
||||
<div className="title"> max lines per row </div>
|
||||
<div className="raw-format max-lines-per-row-input">
|
||||
<button
|
||||
type="button"
|
||||
className="periscope-btn"
|
||||
onClick={decrementMaxLinesPerRow}
|
||||
>
|
||||
{' '}
|
||||
<Minus size={12} />{' '}
|
||||
</button>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={maxLinesPerRow}
|
||||
onChange={handleLinesPerRowChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="periscope-btn"
|
||||
onClick={incrementMaxLinesPerRow}
|
||||
>
|
||||
{' '}
|
||||
<Plus size={12} />{' '}
|
||||
</button>
|
||||
</div>
|
||||
<InputNumber
|
||||
controls
|
||||
mode="spinner"
|
||||
size="small"
|
||||
min={MIN_LINES_PER_ROW}
|
||||
max={MAX_LINES_PER_ROW}
|
||||
precision={0}
|
||||
value={maxLinesPerRow}
|
||||
onChange={handleLinesPerRowChange}
|
||||
aria-label="Max lines per row"
|
||||
decrementAriaLabel="Fewer lines per row"
|
||||
incrementAriaLabel="More lines per row"
|
||||
testId="max-lines-per-row"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -233,15 +203,18 @@ function OptionsMenu({
|
||||
<div className="horizontal-line" />
|
||||
<div className="edit-columns-container">
|
||||
<Button
|
||||
className="edit-columns-btn"
|
||||
type="text"
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
width="100%"
|
||||
data-format-menu="edit-columns"
|
||||
onClick={handleEditColumns}
|
||||
data-testid="periscope-btn-edit-columns"
|
||||
testId="periscope-btn-edit-columns"
|
||||
>
|
||||
<Typography.Text className="edit-columns-text">
|
||||
<span className="menu-row">
|
||||
Edit columns
|
||||
</Typography.Text>
|
||||
<ChevronRight size={14} className="icon" />
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -280,10 +253,15 @@ function LogsFormatOptionsMenu({
|
||||
>
|
||||
<Tooltip title="Options">
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
icon={<SlidersVertical size="md" />}
|
||||
data-testid="periscope-btn-format-options"
|
||||
/>
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Options"
|
||||
testId="periscope-btn-format-options"
|
||||
>
|
||||
<SlidersVertical size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { fireEvent, render, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import LogsFormatOptionsMenu from '../LogsFormatOptionsMenu';
|
||||
|
||||
@@ -107,18 +113,12 @@ describe('LogsFormatOptionsMenu (unit)', () => {
|
||||
});
|
||||
|
||||
it('increments max-lines and calls onChange', async () => {
|
||||
const { maxLinesOnChange } = setup();
|
||||
const { maxLinesOnChange, getByTestId } = setup();
|
||||
|
||||
// Increment max lines
|
||||
const input = document.querySelector(
|
||||
'.max-lines-per-row-input input',
|
||||
) as HTMLInputElement;
|
||||
const input = getByTestId('max-lines-per-row') as HTMLInputElement;
|
||||
const initial = Number(input.value);
|
||||
const buttons = document.querySelectorAll(
|
||||
'.max-lines-per-row-input .periscope-btn',
|
||||
);
|
||||
const incrementBtn = buttons[1] as HTMLElement;
|
||||
fireEvent.click(incrementBtn);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'More lines per row' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Number(input.value)).toBe(initial + 1);
|
||||
@@ -128,28 +128,47 @@ describe('LogsFormatOptionsMenu (unit)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps a typed max-lines value to 10', async () => {
|
||||
const { maxLinesOnChange, getByTestId } = setup();
|
||||
const input = getByTestId('max-lines-per-row') as HTMLInputElement;
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, '15');
|
||||
await user.tab();
|
||||
|
||||
await waitFor(() => expect(input.value).toBe('10'));
|
||||
expect(maxLinesOnChange).toHaveBeenLastCalledWith(10);
|
||||
});
|
||||
|
||||
it('stops max-lines at 10', async () => {
|
||||
const { getByTestId } = setup();
|
||||
const input = getByTestId('max-lines-per-row') as HTMLInputElement;
|
||||
const increment = screen.getByRole('button', { name: 'More lines per row' });
|
||||
const user = userEvent.setup();
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await user.click(increment);
|
||||
}
|
||||
|
||||
await waitFor(() => expect(input.value).toBe('10'));
|
||||
});
|
||||
|
||||
it('changes font size to MEDIUM and calls onChange', async () => {
|
||||
const { fontSizeOnChange } = setup();
|
||||
const { fontSizeOnChange, getByTestId } = setup();
|
||||
// Open font dropdown
|
||||
const fontButton = document.querySelector(
|
||||
'.font-size-container .value',
|
||||
) as HTMLElement;
|
||||
fireEvent.click(fontButton);
|
||||
fireEvent.click(getByTestId('font-size-select'));
|
||||
|
||||
// Choose MEDIUM
|
||||
const optionButtons = Array.from(
|
||||
document.querySelectorAll('.font-size-dropdown .option-btn'),
|
||||
);
|
||||
const mediumBtn = optionButtons[1] as HTMLElement;
|
||||
fireEvent.click(mediumBtn);
|
||||
fireEvent.click(getByTestId(`font-size-option-${FontSize.MEDIUM}`));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelectorAll('.font-size-dropdown .option-btn .icon'),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
(optionButtons[1] as Element).querySelector('.icon'),
|
||||
getByTestId(`font-size-option-${FontSize.MEDIUM}`).querySelector('svg'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
getByTestId(`font-size-option-${FontSize.SMALL}`).querySelector('svg'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(fontSizeOnChange).toHaveBeenCalledWith(FontSize.MEDIUM);
|
||||
|
||||
@@ -48,22 +48,59 @@ export function QuerySearchV2Provider({
|
||||
}, [initialExpression, store]);
|
||||
|
||||
const isInitialized = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isInitialized.current && urlExpression) {
|
||||
const cleanedExpression = getUserExpressionFromCombined(
|
||||
initialExpression,
|
||||
urlExpression,
|
||||
);
|
||||
store.getState().initializeFromUrl(cleanedExpression);
|
||||
isInitialized.current = true;
|
||||
}
|
||||
}, [urlExpression, initialExpression, store]);
|
||||
|
||||
const lastCommittedRef = useRef(committedExpression);
|
||||
useEffect(() => {
|
||||
if (isInitialized.current || !urlExpression) {
|
||||
setUrlExpression(committedExpression || null);
|
||||
return;
|
||||
}
|
||||
}, [committedExpression, setUrlExpression, urlExpression]);
|
||||
isInitialized.current = true;
|
||||
store
|
||||
.getState()
|
||||
.initializeFromUrl(
|
||||
getUserExpressionFromCombined(initialExpression, urlExpression),
|
||||
);
|
||||
}, [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;
|
||||
}
|
||||
lastCommittedRef.current = committedExpression;
|
||||
isInitialized.current = true;
|
||||
const next = committedExpression || null;
|
||||
if (next === urlExpression) {
|
||||
return;
|
||||
}
|
||||
void setUrlExpression(next);
|
||||
}, [committedExpression, urlExpression, setUrlExpression]);
|
||||
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { act, renderHook } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
useExpression,
|
||||
useInitialExpression,
|
||||
useInputExpression,
|
||||
useQuerySearchInitialExpressionProp,
|
||||
useQuerySearchOnChange,
|
||||
useQuerySearchOnRun,
|
||||
@@ -34,10 +35,22 @@ 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;
|
||||
@@ -46,6 +59,7 @@ function useTestHooks(): {
|
||||
expression: useExpression(),
|
||||
userExpression: useUserExpression(),
|
||||
initialExpression: useInitialExpression(),
|
||||
inputExpression: useInputExpression(),
|
||||
querySearchInitialExpressionProp: useQuerySearchInitialExpressionProp(),
|
||||
onChange: useQuerySearchOnChange(),
|
||||
onRun: useQuerySearchOnRun(),
|
||||
@@ -56,6 +70,7 @@ describe('QuerySearchExpressionProvider', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUrlValue = null;
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
it('should provide initial context values', () => {
|
||||
@@ -110,6 +125,96 @@ describe('QuerySearchExpressionProvider', () => {
|
||||
expect(result.current.expression).toBe('status = 500');
|
||||
});
|
||||
|
||||
it('follows Back and Forward from the browser URL', () => {
|
||||
mockUrlValue = 'status = 500';
|
||||
const { result } = renderHook(() => useTestHooks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
goTo('status = 404');
|
||||
|
||||
expect(result.current.userExpression).toBe('status = 404');
|
||||
expect(mockSetQueryState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the filter on Back to a URL without the param', () => {
|
||||
mockUrlValue = 'status = 500';
|
||||
const { result } = renderHook(() => useTestHooks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
goTo(null);
|
||||
|
||||
expect(result.current.userExpression).toBe('');
|
||||
expect(mockSetQueryState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps text typed but not run when Back changes another param', () => {
|
||||
mockUrlValue = 'status = 500';
|
||||
const { result } = renderHook(() => useTestHooks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
act(() => {
|
||||
result.current.onChange('status = 404');
|
||||
});
|
||||
|
||||
goTo('status = 500');
|
||||
|
||||
expect(result.current.inputExpression).toBe('status = 404');
|
||||
});
|
||||
|
||||
it('writes a run filter to the URL once and ignores it coming back', () => {
|
||||
const { result, rerender } = renderHook(() => useTestHooks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onRun('status = 500');
|
||||
});
|
||||
expect(mockSetQueryState).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetQueryState).toHaveBeenLastCalledWith('status = 500');
|
||||
|
||||
mockUrlValue = 'status = 500';
|
||||
rerender();
|
||||
|
||||
expect(result.current.userExpression).toBe('status = 500');
|
||||
expect(mockSetQueryState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps a run filter when the URL briefly reports the value it replaced', () => {
|
||||
mockUrlValue = 'status = 500';
|
||||
const { result, rerender } = renderHook(() => useTestHooks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onRun('status = 404');
|
||||
});
|
||||
mockUrlValue = 'status = 404';
|
||||
rerender();
|
||||
mockUrlValue = 'status = 500';
|
||||
rerender();
|
||||
|
||||
expect(result.current.userExpression).toBe('status = 404');
|
||||
});
|
||||
|
||||
it('keeps a newer run when a late URL value arrives after an empty mount', () => {
|
||||
const { result, rerender } = renderHook(() => useTestHooks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onRun('status = 500');
|
||||
});
|
||||
act(() => {
|
||||
result.current.onRun('status = 404');
|
||||
});
|
||||
mockUrlValue = 'status = 500';
|
||||
rerender();
|
||||
|
||||
expect(result.current.userExpression).toBe('status = 404');
|
||||
});
|
||||
|
||||
it('should throw error when used outside provider', () => {
|
||||
expect(() => {
|
||||
renderHook(() => useExpression());
|
||||
|
||||
@@ -15,11 +15,41 @@ $max-recents-shown: 5;
|
||||
Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', sans-serif;
|
||||
|
||||
.query-where-clause-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
// single-line editor: 36px .cm-line + 1px .cm-content border on each side
|
||||
--button-height: 38px;
|
||||
|
||||
.query-where-clause-editor-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.query-where-clause-editor-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
// The field draws one outline around the scope chip and the editor, so
|
||||
// the editor inside draws none
|
||||
&.hasScopeChip {
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding-left: var(--spacing-3);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l1-background);
|
||||
--query-builder-v2-border-color: transparent;
|
||||
--query-builder-v2-background-color: transparent;
|
||||
|
||||
.query-where-clause-editor {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.query-search-initial-scope-label {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CircleCheck, Info, TriangleAlert, Filter } from '@signozhq/icons';
|
||||
import {
|
||||
CircleCheck,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
Filter,
|
||||
Play,
|
||||
} from '@signozhq/icons';
|
||||
import {
|
||||
autocompletion,
|
||||
closeCompletion,
|
||||
@@ -16,6 +22,7 @@ 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,
|
||||
@@ -59,6 +66,7 @@ import {
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS,
|
||||
SUGGESTIONS_SECTION,
|
||||
} from './constants';
|
||||
import { ScopeChip } from './ScopeChip/ScopeChip';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
dedupeOptionsByLabel,
|
||||
@@ -105,9 +113,14 @@ 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,
|
||||
@@ -125,10 +138,13 @@ function QuerySearch({
|
||||
queryData,
|
||||
dataSource,
|
||||
onRun,
|
||||
showRunButton,
|
||||
isLoadingQueries,
|
||||
signalSource,
|
||||
hardcodedAttributeKeys,
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
initialExpression,
|
||||
showScopeChip: showScopeChipProp,
|
||||
metricNamespace,
|
||||
valueSuggestionsOverride,
|
||||
}: QuerySearchProps): JSX.Element {
|
||||
@@ -148,6 +164,7 @@ function QuerySearch({
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
|
||||
const isScopedFilter = initialExpression !== undefined;
|
||||
const showScopeChip = !!showScopeChipProp && !!initialExpression;
|
||||
|
||||
const validateExpressionForEditor = useCallback(
|
||||
(editorDoc: string): void => {
|
||||
@@ -173,6 +190,18 @@ 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;
|
||||
@@ -1557,169 +1586,182 @@ function QuerySearch({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="query-where-clause-editor-container">
|
||||
{isScopedFilter ? (
|
||||
<Tooltip title={initialExpression || ''} placement="left">
|
||||
<div className="query-search-initial-scope-label">
|
||||
<Filter
|
||||
<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
|
||||
size={14}
|
||||
style={{
|
||||
opacity: 0.9,
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</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,
|
||||
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', {
|
||||
<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 && !showScopeChip,
|
||||
})}
|
||||
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}
|
||||
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>
|
||||
</div>
|
||||
</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>
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -1813,12 +1855,15 @@ 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;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Lock } from '@signozhq/icons';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
|
||||
import styles from './ScopeChip.module.scss';
|
||||
|
||||
interface ScopeChipProps {
|
||||
expression: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The locked half of the filter. It reads like the editor beside it: the last
|
||||
* clause carries the relation, the ones scoping it are counted instead.
|
||||
*/
|
||||
export function ScopeChip({ expression }: ScopeChipProps): JSX.Element {
|
||||
const clauses = useMemo(() => extractQueryPairs(expression), [expression]);
|
||||
const relation = clauses[clauses.length - 1];
|
||||
|
||||
return (
|
||||
<Tooltip title={expression} side="top">
|
||||
<span className={styles.scopeChip} data-testid="query-search-scope-chip">
|
||||
<Lock size={12} className={styles.scopeIcon} />
|
||||
<span className={styles.scopeExpression}>
|
||||
{relation ? (
|
||||
<>
|
||||
<span className={styles.scopeKey}>{relation.key}</span>
|
||||
<span className={styles.scopeOperator}>{` ${relation.operator} `}</span>
|
||||
<span className={styles.scopeValue}>{relation.value}</span>
|
||||
</>
|
||||
) : (
|
||||
expression
|
||||
)}
|
||||
</span>
|
||||
{clauses.length > 1 && (
|
||||
<span className={styles.scopeMore}>{`+${clauses.length - 1}`}</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -224,6 +224,134 @@ 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'";
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
.savePopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 280px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.saveTitle {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.saveLabel {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l3-foreground);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.saveActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
import styles from './ViewNamePopover.module.scss';
|
||||
|
||||
export const VIEW_NAME_MAX_LENGTH = 64;
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum LOCALSTORAGE {
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
|
||||
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
|
||||
AI_OBSERVABILITY_PANEL_COLUMNS = 'AI_OBSERVABILITY_PANEL_COLUMNS',
|
||||
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
@@ -44,6 +45,7 @@ export enum LOCALSTORAGE {
|
||||
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
ALERT_RULES_LIST_VIEWS = 'ALERT_RULES_LIST_VIEWS',
|
||||
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
|
||||
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ export enum QueryParams {
|
||||
variables = 'variables',
|
||||
version = 'version',
|
||||
source = 'source',
|
||||
showClassicCreateAlertsPage = 'showClassicCreateAlertsPage',
|
||||
isTestAlert = 'isTestAlert',
|
||||
yAxisUnit = 'yAxisUnit',
|
||||
ruleName = 'ruleName',
|
||||
|
||||
@@ -26,7 +26,6 @@ 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',
|
||||
@@ -70,8 +69,6 @@ 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:
|
||||
|
||||
@@ -48,18 +48,19 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
|
||||
import { getOpenResourceTarget } from './utils/getOpenResourceTarget';
|
||||
import {
|
||||
getPanelTypeForRequestType,
|
||||
requestTypeFromActionQuery,
|
||||
} from './utils/getPanelTypeForRequestType';
|
||||
import { isOnResourcePage } from './utils/isOnResourcePage';
|
||||
import { openSavedViewByKey } from './utils/openSavedView';
|
||||
import {
|
||||
isSavedViewOpenAction,
|
||||
resolveOpenResourceType,
|
||||
resolveResourceId,
|
||||
resolveSavedViewSourceHint,
|
||||
} from './utils/resolveOpenResource';
|
||||
import { ResourceType, resourceRoute } from './utils/resourceRoute';
|
||||
import { ResourceType } from './utils/resourceRoute';
|
||||
|
||||
import styles from './ActionsSection.module.scss';
|
||||
|
||||
@@ -397,7 +398,7 @@ export default function ActionsSection({
|
||||
actions,
|
||||
messageId,
|
||||
}: ActionsSectionProps): JSX.Element | null {
|
||||
const { pathname } = useAppLocation();
|
||||
const { pathname, search } = useAppLocation();
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
|
||||
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
|
||||
@@ -493,23 +494,17 @@ export default function ActionsSection({
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceType = resolveOpenResourceType(action);
|
||||
const resourceId = resolveResourceId(action);
|
||||
if (!resourceType || !resourceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = resourceRoute(resourceType, resourceId);
|
||||
if (!path) {
|
||||
const target = getOpenResourceTarget(action);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
void logEvent(AIAssistantEvents.ResourceOpened, {
|
||||
threadId,
|
||||
messageId,
|
||||
targetModule: targetModuleForResource(resourceType),
|
||||
resourceId,
|
||||
targetModule: targetModuleForResource(target.resourceType),
|
||||
resourceId: target.resourceId,
|
||||
});
|
||||
navigate(path);
|
||||
navigate(target.path);
|
||||
};
|
||||
|
||||
const handleClick = (key: string, action: MessageActionDTO): void => {
|
||||
@@ -607,6 +602,10 @@ export default function ActionsSection({
|
||||
// is still surfaced visually via the suffix pill below.
|
||||
|
||||
const tooltip = isError ? result.error : (action.tooltip ?? undefined);
|
||||
const openTarget = getOpenResourceTarget(action);
|
||||
const isViewingResource =
|
||||
openTarget !== null &&
|
||||
isOnResourcePage(openTarget.path, { pathname, search });
|
||||
|
||||
let icon: JSX.Element;
|
||||
if (isSuccess) {
|
||||
@@ -619,12 +618,16 @@ export default function ActionsSection({
|
||||
|
||||
const chip = (
|
||||
<Button
|
||||
disabledTooltip="This action already ran"
|
||||
disabledTooltip={
|
||||
isViewingResource
|
||||
? "You're already viewing this page"
|
||||
: 'This action already ran'
|
||||
}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => handleClick(key, action)}
|
||||
disabled={isSuccess}
|
||||
disabled={isSuccess || isViewingResource}
|
||||
loading={isLoading}
|
||||
aria-label={action.label}
|
||||
testId={`ai-action-chip-${action.kind}`}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
MessageActionDTO,
|
||||
MessageActionKindDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { getCurrentLocation, navigate } from 'lib/router/navigation';
|
||||
import { act, render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { AIAssistantEvents } from '../../../events';
|
||||
import ActionsSection from '../ActionsSection';
|
||||
|
||||
jest.mock('api/common/logEvent');
|
||||
|
||||
const mockLogEvent = jest.mocked(logEvent);
|
||||
|
||||
const OPEN_ALERT: MessageActionDTO = {
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open alert',
|
||||
resourceType: 'alert',
|
||||
resourceId: 'rule-1',
|
||||
actionMetadataId: 'meta-1',
|
||||
};
|
||||
|
||||
const OPEN_ALERT_CHIP = 'ai-action-chip-open_resource';
|
||||
|
||||
describe('ActionsSection open_resource', () => {
|
||||
beforeEach(() => {
|
||||
mockLogEvent.mockClear();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/alerts/edit?ruleId=rule-1&tab=1',
|
||||
'/alerts/overview?ruleId=rule-1&relativeTime=6h',
|
||||
])('disables Open alert while that alert is open at %s', async (route) => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ActionsSection actions={[OPEN_ALERT]} messageId="message-1" />,
|
||||
undefined,
|
||||
{ initialRoute: route },
|
||||
);
|
||||
|
||||
const chip = screen.getByTestId(OPEN_ALERT_CHIP);
|
||||
// With a disabled tooltip the button is aria-disabled so the tooltip stays reachable.
|
||||
expect(chip).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
await user.click(chip);
|
||||
|
||||
expect(`${getCurrentLocation().pathname}${getCurrentLocation().search}`).toBe(
|
||||
route,
|
||||
);
|
||||
expect(mockLogEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['/alerts/edit?ruleId=rule-2', '/alerts/history?ruleId=rule-1'])(
|
||||
'opens the alert from %s',
|
||||
async (route) => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ActionsSection actions={[OPEN_ALERT]} messageId="message-2" />,
|
||||
undefined,
|
||||
{ initialRoute: route },
|
||||
);
|
||||
|
||||
const chip = screen.getByTestId(OPEN_ALERT_CHIP);
|
||||
expect(chip).not.toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
await user.click(chip);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
`${getCurrentLocation().pathname}${getCurrentLocation().search}`,
|
||||
).toBe('/alerts/edit?ruleId=rule-1'),
|
||||
);
|
||||
expect(mockLogEvent).toHaveBeenCalledWith(
|
||||
AIAssistantEvents.ResourceOpened,
|
||||
expect.objectContaining({ resourceId: 'rule-1' }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('enables Open alert again after navigating away from that alert', () => {
|
||||
render(
|
||||
<ActionsSection actions={[OPEN_ALERT]} messageId="message-3" />,
|
||||
undefined,
|
||||
{ initialRoute: '/alerts/edit?ruleId=rule-1' },
|
||||
);
|
||||
expect(screen.getByTestId(OPEN_ALERT_CHIP)).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
|
||||
act(() => navigate('/alerts/edit?ruleId=rule-2'));
|
||||
|
||||
expect(screen.getByTestId(OPEN_ALERT_CHIP)).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
type MessageActionDTO,
|
||||
MessageActionKindDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import {
|
||||
isSavedViewOpenAction,
|
||||
resolveOpenResourceType,
|
||||
resolveResourceId,
|
||||
} from './resolveOpenResource';
|
||||
import { resourceRoute } from './resourceRoute';
|
||||
|
||||
export interface OpenResourceTarget {
|
||||
path: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
}
|
||||
|
||||
// Saved views return null so they're never disabled: reopening one resets the explorer to the saved query.
|
||||
export function getOpenResourceTarget(
|
||||
action: MessageActionDTO,
|
||||
): OpenResourceTarget | null {
|
||||
if (
|
||||
action.kind !== MessageActionKindDTO.open_resource ||
|
||||
isSavedViewOpenAction(action)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const resourceType = resolveOpenResourceType(action);
|
||||
const resourceId = resolveResourceId(action);
|
||||
if (!resourceType || !resourceId) {
|
||||
return null;
|
||||
}
|
||||
const path = resourceRoute(resourceType, resourceId);
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
return { path, resourceType, resourceId };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import type { AppLocation } from 'lib/router/types';
|
||||
|
||||
// The alert details Overview tab renders the same editor as the edit route.
|
||||
const EQUIVALENT_PATHNAMES: Record<string, string[]> = {
|
||||
[ROUTES.EDIT_ALERTS]: [ROUTES.ALERT_OVERVIEW],
|
||||
};
|
||||
|
||||
// Extra params on the current page (time range, view options) don't make it a different resource.
|
||||
export function isOnResourcePage(
|
||||
path: string,
|
||||
location: Pick<AppLocation, 'pathname' | 'search'>,
|
||||
): boolean {
|
||||
const [pathname, query = ''] = path.split('?');
|
||||
const pathnames = [pathname, ...(EQUIVALENT_PATHNAMES[pathname] ?? [])];
|
||||
if (!pathnames.includes(location.pathname)) {
|
||||
return false;
|
||||
}
|
||||
const current = new URLSearchParams(location.search);
|
||||
return [...new URLSearchParams(query)].every(
|
||||
([key, value]) => current.get(key) === value,
|
||||
);
|
||||
}
|
||||
@@ -21,14 +21,15 @@ import {
|
||||
useListDashboardsForUserV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
getListRulesQueryKey,
|
||||
useListRules,
|
||||
getListRulesV3QueryKey,
|
||||
useListRulesV3,
|
||||
} from 'api/generated/services/rules';
|
||||
import type {
|
||||
DashboardtypesListedDashboardForUserV2DTO,
|
||||
ListDashboardsForUserV2200,
|
||||
ListDashboardsForUserV2Params,
|
||||
ListRules200,
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useQueryService } from 'hooks/useQueryService';
|
||||
@@ -179,6 +180,9 @@ 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 {
|
||||
@@ -755,7 +759,7 @@ export default function ChatInput({
|
||||
data: alertsResponse,
|
||||
isLoading: isAlertsLoading,
|
||||
isError: isAlertsError,
|
||||
} = useListRules({
|
||||
} = useListRulesV3(ALERT_RULE_LIST_PARAMS, {
|
||||
query: {
|
||||
enabled: isContextPickerOpen && activeContextCategory === 'Alerts',
|
||||
staleTime: Infinity,
|
||||
@@ -804,10 +808,10 @@ export default function ChatInput({
|
||||
}
|
||||
}
|
||||
if (ctx.type === 'alert' && ctx.resourceId) {
|
||||
const cached = queryClient.getQueryData<ListRules200>(
|
||||
getListRulesQueryKey(),
|
||||
const cached = queryClient.getQueryData<ListRulesV3200>(
|
||||
getListRulesV3QueryKey(ALERT_RULE_LIST_PARAMS),
|
||||
);
|
||||
const rule = cached?.data?.find((r) => r.id === ctx.resourceId);
|
||||
const rule = cached?.data.rules.find((r) => r.id === ctx.resourceId);
|
||||
if (rule?.alert) {
|
||||
return rule.alert;
|
||||
}
|
||||
@@ -835,8 +839,8 @@ export default function ChatInput({
|
||||
value: dashboardTitle(dashboard),
|
||||
})) ?? [],
|
||||
Alerts:
|
||||
alertsResponse?.data
|
||||
?.filter((alertRule) => Boolean(alertRule.alert))
|
||||
alertsResponse?.data.rules
|
||||
.filter((alertRule) => Boolean(alertRule.alert))
|
||||
.map((alertRule) => ({
|
||||
id: alertRule.id,
|
||||
value: alertRule.alert,
|
||||
@@ -1059,6 +1063,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useListDashboardsForUserV2: (): unknown => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useQueryService', () => ({
|
||||
useQueryService: (): unknown => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useSpeechRecognition', () => ({
|
||||
useSpeechRecognition: (): unknown => ({
|
||||
isListening: false,
|
||||
isSupported: false,
|
||||
permission: 'prompt',
|
||||
start: jest.fn(),
|
||||
discard: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
|
||||
useAIAssistantAnalyticsContext: (): unknown => ({
|
||||
threadId: undefined,
|
||||
page: '/',
|
||||
mode: 'sidepane',
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import ChatInput from '../ChatInput';
|
||||
|
||||
describe('ChatInput — alerts context picker', () => {
|
||||
it('lists the rules from one v3 rules request', async () => {
|
||||
const [first, second] = alertRulesFixture;
|
||||
const requests: string[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
requests.push(req.url.search);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response([first, second], req.url.searchParams)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ChatInput onSend={jest.fn()} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
|
||||
await userEvent.click(await screen.findByTestId('ai-context-tab-Alerts'));
|
||||
|
||||
await expect(screen.findByText(first.alert)).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText(second.alert)).toBeInTheDocument();
|
||||
expect(requests).toStrictEqual(['?limit=200']);
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,12 @@ jest.mock('api/generated/services/dashboard', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
|
||||
getListRulesQueryKey: (): string[] => ['rules'],
|
||||
useListRulesV3: (): unknown => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
getListRulesV3QueryKey: (): string[] => ['rules'],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useQueryService', () => ({
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import {
|
||||
getAllEndpointsWidgetData,
|
||||
getGroupByFiltersFromGroupByValues,
|
||||
getGroupByExpressionFromRowData,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import GridCard from 'container/WidgetCard/Card';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
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,
|
||||
@@ -23,8 +27,6 @@ function AllEndPoints({
|
||||
groupBy,
|
||||
setGroupBy,
|
||||
timeRange,
|
||||
initialFilters,
|
||||
setInitialFiltersEndPointStats,
|
||||
}: {
|
||||
domainName: string;
|
||||
setSelectedEndPointName: (name: string) => void;
|
||||
@@ -35,113 +37,50 @@ function AllEndPoints({
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
initialFilters: IBuilderQuery['filters'];
|
||||
setInitialFiltersEndPointStats: (filters: IBuilderQuery['filters']) => void;
|
||||
}): JSX.Element {
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
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 }[]
|
||||
>([]);
|
||||
const [, setEndPointStatsExpression] = useQueryState(
|
||||
ENDPOINT_STATS_EXPRESSION_KEY,
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
// --- FILTERS STATE SYNC ---
|
||||
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 };
|
||||
const {
|
||||
expression: filterExpression,
|
||||
initialExpression: scopeExpression,
|
||||
queryData,
|
||||
onDraftChange,
|
||||
onRun,
|
||||
onBlur: handleFilterBlur,
|
||||
} = useFilterExpression({
|
||||
groupBy,
|
||||
});
|
||||
|
||||
// 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, filters),
|
||||
[groupBy, domainName, filters],
|
||||
() => 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],
|
||||
);
|
||||
|
||||
// --- GROUP BY STATE SYNC (existing) ---
|
||||
const handleGroupByChange = useCallback(
|
||||
(value: IBuilderQuery['groupBy']) => {
|
||||
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('');
|
||||
setGroupBy(value);
|
||||
// Update params
|
||||
setParams({ groupBy: newGroupBy.map((g) => g.key) });
|
||||
setParams({ groupBy: value.map((g) => g.key) });
|
||||
},
|
||||
[groupByFiltersData, setGroupBy, allAvailableGroupByOptions, setParams],
|
||||
[setGroupBy, setParams],
|
||||
);
|
||||
|
||||
// Sync params to local groupBy state on param change
|
||||
@@ -154,77 +93,31 @@ function AllEndPoints({
|
||||
groupBy.map((g) => g.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, allAvailableGroupByOptions, groupBy, setGroupBy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupByFiltersData?.payload) {
|
||||
// Update dropdown options
|
||||
setGroupByOptions(
|
||||
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
|
||||
value: filter.key,
|
||||
label: filter.key,
|
||||
})) || [],
|
||||
setGroupBy(
|
||||
params.groupBy.map((key) => chooseAutocompleteFromCustomValue([], 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]);
|
||||
}, [params.groupBy, groupBy, setGroupBy]);
|
||||
|
||||
const onRowClick = useCallback(
|
||||
(props: any): void => {
|
||||
setSelectedEndPointName(props[SPAN_ATTRIBUTES.HTTP_URL] as string);
|
||||
setSelectedView(VIEWS.ENDPOINT_STATS);
|
||||
const initialItems = [
|
||||
...(filters?.items || []),
|
||||
...(getGroupByFiltersFromGroupByValues(props, groupBy)?.items || []),
|
||||
];
|
||||
setInitialFiltersEndPointStats({
|
||||
items: initialItems,
|
||||
op: 'AND',
|
||||
});
|
||||
const statsExpression = combineInitialAndUserExpression(
|
||||
getGroupByExpressionFromRowData(props),
|
||||
filterExpression,
|
||||
);
|
||||
void setEndPointStatsExpression(statsExpression || null);
|
||||
setParams({
|
||||
selectedEndPointName: props[SPAN_ATTRIBUTES.HTTP_URL] as string,
|
||||
selectedView: VIEWS.ENDPOINT_STATS,
|
||||
endPointDetailsLocalFilters: {
|
||||
items: initialItems,
|
||||
op: 'AND',
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
filters,
|
||||
setInitialFiltersEndPointStats,
|
||||
filterExpression,
|
||||
setEndPointStatsExpression,
|
||||
setSelectedEndPointName,
|
||||
setSelectedView,
|
||||
groupBy,
|
||||
setParams,
|
||||
],
|
||||
);
|
||||
@@ -232,28 +125,28 @@ function AllEndPoints({
|
||||
return (
|
||||
<div className="all-endpoints-container">
|
||||
<div className="all-endpoints-header">
|
||||
<div className="filter-container">
|
||||
<QueryBuilderSearchV2
|
||||
query={query}
|
||||
onChange={handleFilterChange}
|
||||
<div className="filter-container" onBlur={handleFilterBlur}>
|
||||
<QuerySearch
|
||||
queryData={queryData}
|
||||
onChange={onDraftChange}
|
||||
onRun={handleRun}
|
||||
showRunButton
|
||||
isLoadingQueries={isFetchingTable}
|
||||
initialExpression={scopeExpression}
|
||||
showScopeChip
|
||||
dataSource={DataSource.TRACES}
|
||||
placeholder="Search for filters..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group-by-container">
|
||||
<div className="group-by-label"> Group by </div>
|
||||
<Select
|
||||
className="group-by-select"
|
||||
loading={isLoadingGroupByFilters}
|
||||
mode="multiple"
|
||||
value={groupBy}
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
placeholder="Search for attribute"
|
||||
options={groupByOptions}
|
||||
<GroupByFilter
|
||||
query={queryData}
|
||||
onChange={handleGroupByChange}
|
||||
onSearch={(value: string): void => setGroupBySearchValue(value)}
|
||||
/>{' '}
|
||||
disabled={false}
|
||||
placeholder="Search for attribute"
|
||||
/>
|
||||
</div>
|
||||
<div className="endpoints-table-container">
|
||||
<GridCard
|
||||
|
||||
@@ -195,16 +195,6 @@
|
||||
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 {
|
||||
@@ -365,16 +355,9 @@
|
||||
.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 {
|
||||
@@ -416,29 +399,8 @@
|
||||
|
||||
.endpoint-details-filters-container {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ant-select-item,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -17,7 +18,12 @@ 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';
|
||||
@@ -34,14 +40,14 @@ function DomainDetails({
|
||||
selectedDomainIndex,
|
||||
setSelectedDomainIndex,
|
||||
domainListLength,
|
||||
domainListFilters,
|
||||
domainListFilterExpression,
|
||||
}: {
|
||||
domainData: any;
|
||||
handleClose: () => void;
|
||||
selectedDomainIndex: number;
|
||||
setSelectedDomainIndex: (index: number) => void;
|
||||
domainListLength: number;
|
||||
domainListFilters: IBuilderQuery['filters'];
|
||||
domainListFilterExpression: string;
|
||||
}): JSX.Element {
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
const [selectedView, setSelectedView] = useState<VIEWS>(
|
||||
@@ -53,8 +59,16 @@ function DomainDetails({
|
||||
const [endPointsGroupBy, setEndPointsGroupBy] = useState<
|
||||
IBuilderQuery['groupBy']
|
||||
>([]);
|
||||
const [initialFiltersEndPointStats, setInitialFiltersEndPointStats] =
|
||||
useState<IBuilderQuery['filters']>(domainListFilters);
|
||||
|
||||
const domainName = domainData?.domainName ?? '';
|
||||
const allEndpointsScope = useMemo(
|
||||
() => getDomainScopeExpression(domainName),
|
||||
[domainName],
|
||||
);
|
||||
const endPointStatsScope = useMemo(
|
||||
() => getDomainScopeExpression(domainName, selectedEndPointName),
|
||||
[domainName, selectedEndPointName],
|
||||
);
|
||||
|
||||
const handleTabChange = (value: string): void => {
|
||||
setSelectedView(value as VIEWS);
|
||||
@@ -222,7 +236,7 @@ function DomainDetails({
|
||||
<>
|
||||
<DomainMetrics
|
||||
domainName={domainData.domainName}
|
||||
domainListFilters={domainListFilters}
|
||||
domainListFilterExpression={domainListFilterExpression}
|
||||
timeRange={modalTimeRange}
|
||||
/>
|
||||
<div className="views-tabs-container">
|
||||
@@ -253,34 +267,43 @@ function DomainDetails({
|
||||
/>
|
||||
</div>
|
||||
{selectedView === VIEW_TYPES.ALL_ENDPOINTS && (
|
||||
<AllEndPoints
|
||||
domainName={domainData.domainName}
|
||||
setSelectedEndPointName={handleEndPointChange}
|
||||
setSelectedView={setSelectedView}
|
||||
groupBy={endPointsGroupBy}
|
||||
setGroupBy={setEndPointsGroupBy}
|
||||
timeRange={modalTimeRange}
|
||||
initialFilters={domainListFilters}
|
||||
setInitialFiltersEndPointStats={setInitialFiltersEndPointStats}
|
||||
/>
|
||||
<QuerySearchV2Provider
|
||||
queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}
|
||||
initialExpression={allEndpointsScope}
|
||||
persistOnUnmount
|
||||
>
|
||||
<AllEndPoints
|
||||
domainName={domainData.domainName}
|
||||
setSelectedEndPointName={handleEndPointChange}
|
||||
setSelectedView={setSelectedView}
|
||||
groupBy={endPointsGroupBy}
|
||||
setGroupBy={setEndPointsGroupBy}
|
||||
timeRange={modalTimeRange}
|
||||
/>
|
||||
</QuerySearchV2Provider>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.ENDPOINT_STATS && (
|
||||
<EndPointDetails
|
||||
domainName={domainData.domainName}
|
||||
endPointName={selectedEndPointName}
|
||||
setSelectedEndPointName={handleEndPointChange}
|
||||
initialFilters={initialFiltersEndPointStats}
|
||||
timeRange={modalTimeRange}
|
||||
handleTimeChange={handleTimeChange}
|
||||
/>
|
||||
<QuerySearchV2Provider
|
||||
queryParamKey={ENDPOINT_STATS_EXPRESSION_KEY}
|
||||
initialExpression={endPointStatsScope}
|
||||
persistOnUnmount
|
||||
>
|
||||
<EndPointDetails
|
||||
domainName={domainData.domainName}
|
||||
endPointName={selectedEndPointName}
|
||||
setSelectedEndPointName={handleEndPointChange}
|
||||
timeRange={modalTimeRange}
|
||||
handleTimeChange={handleTimeChange}
|
||||
/>
|
||||
</QuerySearchV2Provider>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.TOP_ERRORS && (
|
||||
<TopErrors
|
||||
domainName={domainData.domainName}
|
||||
timeRange={modalTimeRange}
|
||||
initialFilters={domainListFilters}
|
||||
initialFilterExpression={domainListFilterExpression}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
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,36 +19,28 @@ 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 EndPointsDropDown from './components/EndPointsDropDown';
|
||||
import EndPointPicker from './components/EndPointPicker';
|
||||
import MetricOverTimeGraph from './components/MetricOverTimeGraph';
|
||||
import StatusCodeBarCharts from './components/StatusCodeBarCharts';
|
||||
import StatusCodeTable from './components/StatusCodeTable';
|
||||
import { SPAN_ATTRIBUTES } from './constants';
|
||||
|
||||
const httpUrlKey = {
|
||||
dataType: DataTypes.String,
|
||||
key: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
type: 'tag',
|
||||
};
|
||||
import { useFilterExpression } from './hooks/useFilterExpression';
|
||||
import { useWidgetQueries } from './hooks/useWidgetQueries';
|
||||
|
||||
function EndPointDetails({
|
||||
domainName,
|
||||
endPointName,
|
||||
setSelectedEndPointName,
|
||||
initialFilters,
|
||||
timeRange,
|
||||
handleTimeChange,
|
||||
}: {
|
||||
domainName: string;
|
||||
endPointName: string;
|
||||
setSelectedEndPointName: (value: string) => void;
|
||||
initialFilters: IBuilderQuery['filters'];
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
@@ -59,159 +51,66 @@ function EndPointDetails({
|
||||
) => void;
|
||||
}): JSX.Element {
|
||||
const { startTime: minTime, endTime: maxTime } = timeRange;
|
||||
const [params, setParams] = useApiMonitoringParams();
|
||||
|
||||
const currentQuery = initialQueriesMap[DataSource.TRACES];
|
||||
// Search filters; the endpoint filter is combined in at query time
|
||||
const {
|
||||
expression: filterExpression,
|
||||
initialExpression: scopeExpression,
|
||||
queryData,
|
||||
onDraftChange,
|
||||
onRun,
|
||||
onBlur: handleFilterBlur,
|
||||
} = useFilterExpression();
|
||||
|
||||
// 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 httpUrlExpression = useMemo(
|
||||
() => getHttpUrlExpression(endPointName),
|
||||
[endPointName],
|
||||
);
|
||||
|
||||
const updatedCurrentQuery = useMemo(
|
||||
() => ({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
dataSource: DataSource.TRACES,
|
||||
filters, // Use the local filters state
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
[filters, currentQuery],
|
||||
const scopedExpression = useMemo(
|
||||
() => combineInitialAndUserExpression(httpUrlExpression, filterExpression),
|
||||
[httpUrlExpression, filterExpression],
|
||||
);
|
||||
|
||||
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
|
||||
|
||||
const isServicesFilterApplied = useMemo(
|
||||
() => filters?.items?.some((item) => item.key?.key === 'service.name'),
|
||||
[filters],
|
||||
() =>
|
||||
extractQueryPairs(filterExpression).some(
|
||||
(pair) => pair.key === 'service.name',
|
||||
),
|
||||
[filterExpression],
|
||||
);
|
||||
|
||||
const endPointDetailsQueryPayload = useMemo(
|
||||
() => getEndPointDetailsQueryPayload(domainName, minTime, maxTime, filters),
|
||||
[domainName, filters, minTime, maxTime],
|
||||
() =>
|
||||
getEndPointDetailsQueryPayload(
|
||||
domainName,
|
||||
minTime,
|
||||
maxTime,
|
||||
scopedExpression,
|
||||
),
|
||||
[domainName, scopedExpression, 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) => {
|
||||
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,
|
||||
};
|
||||
}),
|
||||
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,
|
||||
})),
|
||||
);
|
||||
|
||||
const [
|
||||
endPointMetricsDataQuery,
|
||||
endPointStatusCodeDataQuery,
|
||||
endPointDropDownDataQuery,
|
||||
endPointDependentServicesDataQuery,
|
||||
endPointStatusCodeBarChartsDataQuery,
|
||||
endPointStatusCodeLatencyBarChartsDataQuery,
|
||||
@@ -222,7 +121,6 @@ function EndPointDetails({
|
||||
endPointDetailsDataQueries[2],
|
||||
endPointDetailsDataQueries[3],
|
||||
endPointDetailsDataQueries[4],
|
||||
endPointDetailsDataQueries[5],
|
||||
],
|
||||
[endPointDetailsDataQueries],
|
||||
);
|
||||
@@ -234,10 +132,32 @@ function EndPointDetails({
|
||||
|
||||
const [rateOverTimeWidget, latencyOverTimeWidget] = useMemo(
|
||||
() => [
|
||||
getRateOverTimeWidgetData(domainName, endPointName, filters),
|
||||
getLatencyOverTimeWidgetData(domainName, endPointName, filters),
|
||||
getRateOverTimeWidgetData(domainName, endPointName, scopedExpression),
|
||||
getLatencyOverTimeWidgetData(domainName, endPointName, scopedExpression),
|
||||
],
|
||||
[domainName, endPointName, filters], // Use combinedFilters
|
||||
[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],
|
||||
);
|
||||
|
||||
// // [TODO] Fix this later
|
||||
@@ -257,19 +177,23 @@ function EndPointDetails({
|
||||
return (
|
||||
<div className="endpoint-details-container">
|
||||
<div className="endpoint-details-filters-container">
|
||||
<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}
|
||||
<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}
|
||||
placeholder="Search for filters..."
|
||||
/>
|
||||
</div>
|
||||
@@ -299,7 +223,7 @@ function EndPointDetails({
|
||||
endPointStatusCodeLatencyBarChartsDataQuery
|
||||
}
|
||||
domainName={domainName}
|
||||
filters={filters}
|
||||
filterExpression={scopedExpression}
|
||||
timeRange={timeRange}
|
||||
onDragSelect={onDragSelect}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { QueryFunctionContext, useQueries, useQuery } from 'react-query';
|
||||
import { QueryFunctionContext, useQuery } from 'react-query';
|
||||
import { Spin, Table, Tooltip } from 'antd';
|
||||
import { Info, Loader } from '@signozhq/icons';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
@@ -7,41 +7,36 @@ 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,
|
||||
getEndPointDetailsQueryPayload,
|
||||
getHttpUrlExpression,
|
||||
getTopErrorsColumnsConfig,
|
||||
getTopErrorsCoRelationQueryFilters,
|
||||
getTopErrorsQueryPayload,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
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 { SuccessResponseV2 } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
|
||||
|
||||
import EndPointsDropDown from './components/EndPointsDropDown';
|
||||
import EndPointPicker from './components/EndPointPicker';
|
||||
import ErrorState from './components/ErrorState';
|
||||
import { SPAN_ATTRIBUTES } from './constants';
|
||||
|
||||
function TopErrors({
|
||||
domainName,
|
||||
timeRange,
|
||||
initialFilters,
|
||||
initialFilterExpression,
|
||||
}: {
|
||||
domainName: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
initialFilters: IBuilderQuery['filters'];
|
||||
initialFilterExpression: string;
|
||||
}): JSX.Element {
|
||||
const { startTime: minTime, endTime: maxTime } = timeRange;
|
||||
|
||||
@@ -55,27 +50,10 @@ function TopErrors({
|
||||
domainName,
|
||||
minTime,
|
||||
maxTime,
|
||||
{
|
||||
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',
|
||||
},
|
||||
combineInitialAndUserExpression(
|
||||
getHttpUrlExpression(endPointName),
|
||||
initialFilterExpression,
|
||||
),
|
||||
showStatusCodeErrors,
|
||||
),
|
||||
[
|
||||
@@ -83,7 +61,7 @@ function TopErrors({
|
||||
endPointName,
|
||||
minTime,
|
||||
maxTime,
|
||||
initialFilters,
|
||||
initialFilterExpression,
|
||||
showStatusCodeErrors,
|
||||
],
|
||||
);
|
||||
@@ -120,35 +98,6 @@ 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) {
|
||||
@@ -162,14 +111,13 @@ function TopErrors({
|
||||
return (
|
||||
<div className="all-endpoints-container">
|
||||
<div className="top-errors-dropdown-container">
|
||||
<div className="endpoint-details-filters-container-dropdown">
|
||||
<EndPointsDropDown
|
||||
selectedEndPointName={endPointName}
|
||||
setSelectedEndPointName={setSelectedEndPointName}
|
||||
endPointDropDownDataQuery={endPointDropDownDataQuery}
|
||||
parentContainerDiv=".endpoint-details-filters-container"
|
||||
/>
|
||||
</div>
|
||||
<EndPointPicker
|
||||
domainName={domainName}
|
||||
timeRange={timeRange}
|
||||
filterExpression=""
|
||||
value={endPointName}
|
||||
onChange={setSelectedEndPointName}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Switch
|
||||
color="primary"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Skeleton, Table, TablePaginationConfig } from 'antd';
|
||||
import { Table, TablePaginationConfig } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
@@ -54,10 +54,6 @@ function DependentServices({
|
||||
[isExpanded, dependentServicesData.length],
|
||||
);
|
||||
|
||||
if (isLoading || isRefetching) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <ErrorState refetch={refetch} />;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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';
|
||||
@@ -34,10 +33,7 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
|
||||
startTime: 1758259531000,
|
||||
endTime: 1758261331000,
|
||||
},
|
||||
domainListFilters: {
|
||||
items: [],
|
||||
op: 'AND' as const,
|
||||
} as IBuilderQuery['filters'],
|
||||
domainListFilterExpression: '',
|
||||
};
|
||||
|
||||
const mockSuccessResponse = {
|
||||
@@ -174,35 +170,12 @@ describe('DomainMetrics - V5 Query Payload Tests', () => {
|
||||
it('includes custom filters in filter expressions', async () => {
|
||||
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
|
||||
|
||||
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,
|
||||
};
|
||||
const customFilterExpression =
|
||||
"service.name = 'my-service' AND deployment.environment = 'production'";
|
||||
|
||||
renderComponent({
|
||||
...mockProps,
|
||||
domainListFilters: customFilters,
|
||||
domainListFilterExpression: customFilterExpression,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -14,18 +14,17 @@ 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,
|
||||
domainListFilters,
|
||||
domainListFilterExpression,
|
||||
}: {
|
||||
domainName: string;
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
domainListFilters: IBuilderQuery['filters'];
|
||||
domainListFilterExpression: string;
|
||||
}): JSX.Element {
|
||||
const { startTime: minTime, endTime: maxTime } = timeRange;
|
||||
|
||||
@@ -35,9 +34,9 @@ function DomainMetrics({
|
||||
domainName,
|
||||
minTime,
|
||||
maxTime,
|
||||
domainListFilters,
|
||||
domainListFilterExpression,
|
||||
),
|
||||
[domainName, minTime, maxTime, domainListFilters],
|
||||
[domainName, minTime, maxTime, domainListFilterExpression],
|
||||
);
|
||||
|
||||
// Since only one query here
|
||||
|
||||
@@ -114,10 +114,7 @@ describe('EndPointMetrics - V5 Query Payload Tests', () => {
|
||||
const domainName = 'api.example.com';
|
||||
const startTime = 1758259531000;
|
||||
const endTime = 1758261331000;
|
||||
const filters = {
|
||||
items: [],
|
||||
op: 'AND' as const,
|
||||
};
|
||||
const filters = '';
|
||||
|
||||
// Get the actual payload that would be generated
|
||||
const payloads = getEndPointDetailsQueryPayload(
|
||||
@@ -237,31 +234,8 @@ describe('EndPointMetrics - V5 Query Payload Tests', () => {
|
||||
it('includes custom domainListFilters in all query expressions', async () => {
|
||||
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
|
||||
|
||||
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 customFilters =
|
||||
"service.name = 'payment-service' AND deployment.environment = 'staging'";
|
||||
|
||||
const payloads = getEndPointDetailsQueryPayload(
|
||||
'api.internal.com',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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;
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Select } from 'antd';
|
||||
import { getFormattedEndPointDropDownData } from 'container/ApiMonitoring/utils';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
|
||||
interface EndPointsDropDownProps {
|
||||
selectedEndPointName?: string;
|
||||
setSelectedEndPointName: (value: string) => void;
|
||||
endPointDropDownDataQuery: UseQueryResult<SuccessResponse<any>, unknown>;
|
||||
parentContainerDiv?: string;
|
||||
dropdownStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
selectedEndPointName: '',
|
||||
parentContainerDiv: '',
|
||||
dropdownStyle: {},
|
||||
};
|
||||
|
||||
function EndPointsDropDown({
|
||||
selectedEndPointName,
|
||||
setSelectedEndPointName,
|
||||
endPointDropDownDataQuery,
|
||||
parentContainerDiv,
|
||||
dropdownStyle,
|
||||
}: EndPointsDropDownProps): JSX.Element {
|
||||
const { data, isLoading, isFetching } = endPointDropDownDataQuery;
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
setSelectedEndPointName(value);
|
||||
};
|
||||
|
||||
const formattedData = useMemo(
|
||||
() =>
|
||||
getFormattedEndPointDropDownData(data?.payload.data.result[0].table.rows),
|
||||
[data?.payload.data.result],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedEndPointName || undefined}
|
||||
placeholder="Select endpoint"
|
||||
loading={isLoading || isFetching}
|
||||
style={{ width: '100%' }}
|
||||
onChange={handleChange}
|
||||
options={formattedData}
|
||||
getPopupContainer={
|
||||
parentContainerDiv
|
||||
? (): HTMLElement =>
|
||||
document.querySelector(parentContainerDiv) as HTMLElement
|
||||
: (triggerNode): HTMLElement => triggerNode.parentNode as HTMLElement
|
||||
}
|
||||
dropdownStyle={dropdownStyle}
|
||||
allowClear
|
||||
onClear={(): void => {
|
||||
setSelectedEndPointName('');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
EndPointsDropDown.defaultProps = defaultProps;
|
||||
|
||||
export default EndPointsDropDown;
|
||||
@@ -24,7 +24,6 @@ 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 {
|
||||
@@ -37,7 +36,7 @@ function StatusCodeBarCharts({
|
||||
endPointStatusCodeBarChartsDataQuery,
|
||||
endPointStatusCodeLatencyBarChartsDataQuery,
|
||||
domainName,
|
||||
filters,
|
||||
filterExpression,
|
||||
timeRange,
|
||||
onDragSelect,
|
||||
}: {
|
||||
@@ -50,7 +49,7 @@ function StatusCodeBarCharts({
|
||||
unknown
|
||||
>;
|
||||
domainName: string;
|
||||
filters: IBuilderQuery['filters'];
|
||||
filterExpression: string;
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
@@ -130,12 +129,8 @@ function StatusCodeBarCharts({
|
||||
);
|
||||
|
||||
const widget = useMemo<Widgets>(
|
||||
() =>
|
||||
getStatusCodeBarChartWidgetData(domainName, {
|
||||
items: [...(filters?.items || [])],
|
||||
op: filters?.op || 'AND',
|
||||
}),
|
||||
[domainName, filters],
|
||||
() => getStatusCodeBarChartWidgetData(domainName, filterExpression),
|
||||
[domainName, filterExpression],
|
||||
);
|
||||
|
||||
const activeApiResponse = useMemo(
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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'",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Query as ReactQuery,
|
||||
useIsFetching,
|
||||
useQueryClient,
|
||||
} from 'react-query';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
|
||||
interface UseWidgetQueriesReturn {
|
||||
isFetching: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* These widgets' `GridCard` queries. Matches on the widget's query id, so
|
||||
* another tab's cards are neither counted nor refetched.
|
||||
*/
|
||||
export function useWidgetQueries(widgets: Widgets[]): UseWidgetQueriesReturn {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const predicate = useMemo(() => {
|
||||
const queryIds = new Set(
|
||||
widgets
|
||||
.map((widget) => widget.query?.id)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
return ({ queryKey }: ReactQuery): boolean =>
|
||||
Array.isArray(queryKey) &&
|
||||
queryKey[0] === REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE &&
|
||||
queryKey.some((part) => {
|
||||
const id = (part as Query | undefined)?.id;
|
||||
return !!id && queryIds.has(id);
|
||||
});
|
||||
}, [widgets]);
|
||||
|
||||
const isFetching = useIsFetching({ predicate }) > 0;
|
||||
|
||||
const refetch = useCallback((): void => {
|
||||
void queryClient.refetchQueries({ predicate, active: true });
|
||||
}, [queryClient, predicate]);
|
||||
|
||||
return { isFetching, refetch };
|
||||
}
|
||||
@@ -19,6 +19,7 @@ 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';
|
||||
@@ -27,16 +28,26 @@ 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,
|
||||
);
|
||||
@@ -55,6 +66,8 @@ function DomainList(): JSX.Element {
|
||||
});
|
||||
|
||||
const compositeData = useGetCompositeQueryParam();
|
||||
const listFilterExpression =
|
||||
compositeData?.builder?.queryData[0]?.filter?.expression ?? '';
|
||||
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
@@ -155,6 +168,7 @@ 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}
|
||||
/>
|
||||
@@ -225,6 +239,11 @@ 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', {});
|
||||
}
|
||||
},
|
||||
@@ -245,8 +264,12 @@ function DomainList(): JSX.Element {
|
||||
handleClose={(): void => {
|
||||
setSelectedDomainIndex(-1);
|
||||
setParams(DEFAULT_PARAMS);
|
||||
void setDrawerFilters({
|
||||
[ALL_ENDPOINTS_EXPRESSION_KEY]: null,
|
||||
[ENDPOINT_STATS_EXPRESSION_KEY]: null,
|
||||
});
|
||||
}}
|
||||
domainListFilters={query?.filters}
|
||||
domainListFilterExpression={listFilterExpression}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user