mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-12 19:31:13 +00:00
Compare commits
32 Commits
nv/exec-st
...
feat/audit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32fc779641 | ||
|
|
ef8171dc9f | ||
|
|
9cd8df5190 | ||
|
|
d00471c6b9 | ||
|
|
b91f7e9835 | ||
|
|
0b48326b10 | ||
|
|
95ee58eba5 | ||
|
|
9100d9aea6 | ||
|
|
3acfb0c8c0 | ||
|
|
75994b0d6f | ||
|
|
3bfb8708e9 | ||
|
|
876d84ac53 | ||
|
|
88e9ad73e0 | ||
|
|
7f5bd4d1cf | ||
|
|
86e0e4840b | ||
|
|
5c528f8bdc | ||
|
|
79f0720768 | ||
|
|
9ee433424d | ||
|
|
da8d66b5b9 | ||
|
|
f1d6c988c9 | ||
|
|
f02b5521c6 | ||
|
|
4b1b3092d5 | ||
|
|
d6017f88fd | ||
|
|
9d28014664 | ||
|
|
1997901102 | ||
|
|
84268d00c8 | ||
|
|
628e989bc0 | ||
|
|
74d272a945 | ||
|
|
f8421e81e9 | ||
|
|
e938a10ad5 | ||
|
|
93cc1957d4 | ||
|
|
313ec2d657 |
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
|
||||
|
||||
2
.github/workflows/e2eci.yaml
vendored
2
.github/workflows/e2eci.yaml
vendored
@@ -88,7 +88,7 @@ jobs:
|
||||
postgres:15 \
|
||||
clickhouse/clickhouse-server:25.12.5 \
|
||||
clickhouse/clickhouse-keeper:25.12.5 \
|
||||
signoz/signoz-schema-migrator:v0.144.9 \
|
||||
signoz/signoz-otel-collector:v0.144.16-rc.1 \
|
||||
wiremock/wiremock:2.35.1-1 \
|
||||
| xargs -P 0 -n 1 docker pull -q
|
||||
- name: python
|
||||
|
||||
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
|
||||
|
||||
7
.github/workflows/integrationci.yaml
vendored
7
.github/workflows/integrationci.yaml
vendored
@@ -40,6 +40,7 @@ jobs:
|
||||
suite:
|
||||
- alertmanager
|
||||
- alertmanagerrotation
|
||||
- auditquerier
|
||||
- basepath
|
||||
- callbackauthn
|
||||
- cloudintegrations
|
||||
@@ -83,8 +84,8 @@ jobs:
|
||||
- wal
|
||||
clickhouse-version:
|
||||
- 25.12.5
|
||||
schema-migrator-version:
|
||||
- v0.144.6
|
||||
signoz-otel-collector-version:
|
||||
- v0.144.16-rc.1
|
||||
postgres-version:
|
||||
- 15
|
||||
if: |
|
||||
@@ -155,4 +156,4 @@ jobs:
|
||||
--sqlite-mode ${{matrix.sqlite-mode}} \
|
||||
--postgres-version ${{matrix.postgres-version}} \
|
||||
--clickhouse-version ${{matrix.clickhouse-version}} \
|
||||
--schema-migrator-version ${{matrix.schema-migrator-version}}
|
||||
--signoz-otel-collector-version ${{matrix.signoz-otel-collector-version}}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -553,23 +553,23 @@ components:
|
||||
- apiKey
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraAuth:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraCloudAuth'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelJiraSelfHostedAuth'
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraCloudAuth:
|
||||
properties:
|
||||
apiToken:
|
||||
description: The Atlassian API token paired with email. A secret; returned
|
||||
by create, update and fetch by ID, never by list.
|
||||
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.
|
||||
format: password
|
||||
type: string
|
||||
email:
|
||||
description: The Atlassian account email used for basic auth.
|
||||
description: The Atlassian account email used for basic auth. Required when
|
||||
deployment is cloud.
|
||||
type: string
|
||||
pat:
|
||||
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.
|
||||
format: password
|
||||
type: string
|
||||
required:
|
||||
- email
|
||||
- apiToken
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraConfig:
|
||||
properties:
|
||||
@@ -642,16 +642,6 @@ components:
|
||||
- issueType
|
||||
- auth
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraSelfHostedAuth:
|
||||
properties:
|
||||
pat:
|
||||
description: The Jira personal access token, sent as a Bearer header. A
|
||||
secret; returned by create, update and fetch by ID, never by list.
|
||||
format: password
|
||||
type: string
|
||||
required:
|
||||
- pat
|
||||
type: object
|
||||
AlertmanagertypesChannelKind:
|
||||
enum:
|
||||
- slack
|
||||
@@ -3970,6 +3960,7 @@ components:
|
||||
- logs-field
|
||||
- traces-field
|
||||
- metrics-field
|
||||
- llm-pricing-rule
|
||||
type: string
|
||||
CoretypesObject:
|
||||
properties:
|
||||
@@ -10231,8 +10222,10 @@ components:
|
||||
having:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5Having'
|
||||
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.
|
||||
legend:
|
||||
description: The display name template for each series; {{field}} placeholders
|
||||
are replaced by label values.
|
||||
@@ -11743,20 +11736,10 @@ components:
|
||||
description: Whether the rule is paused. A disabled rule is neither evaluated
|
||||
nor notifies. Defaults to false.
|
||||
type: boolean
|
||||
evalWindow:
|
||||
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.
|
||||
type: string
|
||||
evaluation:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesEvaluationEnvelope'
|
||||
description: The evaluation window, as a kind and spec.
|
||||
frequency:
|
||||
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.
|
||||
type: string
|
||||
labels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
@@ -11768,13 +11751,6 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesNotificationSettings'
|
||||
description: Grouping, re-notification and routing behaviour.
|
||||
preferredChannels:
|
||||
description: The displayName of each notification channel to notify, for
|
||||
schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries
|
||||
its own channels.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
ruleType:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesRuleType'
|
||||
@@ -11787,15 +11763,6 @@ components:
|
||||
enum:
|
||||
- v2alpha1
|
||||
type: string
|
||||
source:
|
||||
deprecated: true
|
||||
description: The URL of the page the rule was created from. Informational;
|
||||
it is not used for evaluation.
|
||||
type: string
|
||||
version:
|
||||
deprecated: true
|
||||
description: The query payload version. Nothing reads it.
|
||||
type: string
|
||||
required:
|
||||
- alert
|
||||
- alertType
|
||||
@@ -11884,20 +11851,10 @@ components:
|
||||
description: Whether the rule is paused. A disabled rule is neither evaluated
|
||||
nor notifies. Defaults to false.
|
||||
type: boolean
|
||||
evalWindow:
|
||||
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.
|
||||
type: string
|
||||
evaluation:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesEvaluationEnvelope'
|
||||
description: The evaluation window, as a kind and spec.
|
||||
frequency:
|
||||
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.
|
||||
type: string
|
||||
id:
|
||||
description: The unique identifier of the rule.
|
||||
type: string
|
||||
@@ -11912,13 +11869,6 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesNotificationSettings'
|
||||
description: Grouping, re-notification and routing behaviour.
|
||||
preferredChannels:
|
||||
description: The displayName of each notification channel to notify, for
|
||||
schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries
|
||||
its own channels.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
ruleType:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesRuleType'
|
||||
@@ -11931,11 +11881,6 @@ components:
|
||||
enum:
|
||||
- v2alpha1
|
||||
type: string
|
||||
source:
|
||||
deprecated: true
|
||||
description: The URL of the page the rule was created from. Informational;
|
||||
it is not used for evaluation.
|
||||
type: string
|
||||
state:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesAlertState'
|
||||
@@ -11946,10 +11891,6 @@ components:
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
version:
|
||||
deprecated: true
|
||||
description: The query payload version. Nothing reads it.
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- state
|
||||
@@ -11972,28 +11913,10 @@ components:
|
||||
description: Whether to fire a nodata alert when the query returns no data.
|
||||
Defaults to false.
|
||||
type: boolean
|
||||
algorithm:
|
||||
description: 'anomaly_rule only: the anomaly detection algorithm. The supported
|
||||
value is standard.'
|
||||
type: string
|
||||
compositeQuery:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesAlertCompositeQuery'
|
||||
description: The queries whose result is compared against the thresholds.
|
||||
matchType:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesMatchType'
|
||||
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.'
|
||||
op:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesCompareOperator'
|
||||
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.'
|
||||
requireMinPoints:
|
||||
description: Whether a series is skipped until it has at least requiredNumPoints
|
||||
points in the window. Defaults to false.
|
||||
@@ -12011,17 +11934,6 @@ components:
|
||||
description: The name of the query whose result is evaluated, such as A
|
||||
or F1. Must match a query in compositeQuery.
|
||||
type: string
|
||||
target:
|
||||
description: 'schemaVersion v1 only: the value compared against, in targetUnit.
|
||||
Required for v1; v2alpha1 sets target on each threshold.'
|
||||
format: double
|
||||
nullable: true
|
||||
type: number
|
||||
targetUnit:
|
||||
description: 'schemaVersion v1 only: the unit of target, such as percent
|
||||
or ms. Converted to compositeQuery.unit before comparison when the two
|
||||
differ.'
|
||||
type: string
|
||||
thresholds:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RuletypesRuleThresholdData'
|
||||
@@ -16148,9 +16060,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- llm-pricing-rule:list
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- llm-pricing-rule:list
|
||||
summary: List pricing rules
|
||||
tags:
|
||||
- llmpricingrules
|
||||
@@ -16195,9 +16107,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- llm-pricing-rule:update
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- llm-pricing-rule:update
|
||||
summary: Create or update pricing rules
|
||||
tags:
|
||||
- llmpricingrules
|
||||
@@ -16243,9 +16155,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- llm-pricing-rule:delete
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- llm-pricing-rule:delete
|
||||
summary: Delete a pricing rule
|
||||
tags:
|
||||
- llmpricingrules
|
||||
@@ -16301,9 +16213,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- llm-pricing-rule:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- llm-pricing-rule:read
|
||||
summary: Get a pricing rule
|
||||
tags:
|
||||
- llmpricingrules
|
||||
@@ -16356,9 +16268,11 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- llm-pricing-rule:list
|
||||
- traces:read
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- llm-pricing-rule:list
|
||||
- traces:read
|
||||
summary: List unmapped models
|
||||
tags:
|
||||
- llmpricingrules
|
||||
@@ -25528,56 +25442,6 @@ paths:
|
||||
- rules
|
||||
x-signoz-stability: alpha
|
||||
/api/v2/rules:
|
||||
get:
|
||||
deprecated: true
|
||||
description: 'This endpoint lists all alert rules with their current evaluation
|
||||
state. Deprecated: use ListRulesV3, which supports filtering, sorting and
|
||||
pagination.'
|
||||
operationId: ListRules
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesRule'
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List alert rules
|
||||
tags:
|
||||
- rules
|
||||
x-signoz-stability: alpha
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a new alert rule
|
||||
@@ -25667,7 +25531,6 @@ paths:
|
||||
interval: 30m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
logs_threshold:
|
||||
description: Counts matching log records (ERROR severity + body contains)
|
||||
over a rolling window. Fires at least once per evaluation when the
|
||||
@@ -25734,7 +25597,6 @@ paths:
|
||||
interval: 15m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_anomaly:
|
||||
description: Wraps a builder query in the `anomaly` function with
|
||||
daily seasonality, so SigNoz compares each point against the forecast
|
||||
@@ -25807,7 +25669,6 @@ paths:
|
||||
interval: 4h
|
||||
ruleType: anomaly_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_promql:
|
||||
description: PromQL expression instead of the builder. Dotted OTEL
|
||||
resource attributes are quoted ("deployment.environment"). Useful
|
||||
@@ -25863,7 +25724,6 @@ paths:
|
||||
interval: 1h
|
||||
ruleType: promql_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_threshold_formula:
|
||||
description: Computes disk utilization as (1 - available/capacity)
|
||||
* 100 by combining two disabled base queries with a builder_formula.
|
||||
@@ -25955,7 +25815,6 @@ paths:
|
||||
interval: 2h
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_threshold_single:
|
||||
description: Fires when a pod consumes more than 80% of its requested
|
||||
CPU for the whole evaluation window. Uses `k8s.pod.cpu_request_utilization`.
|
||||
@@ -26024,7 +25883,6 @@ paths:
|
||||
interval: 4h
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
notification_settings:
|
||||
description: 'Demonstrates the full notificationSettings surface:
|
||||
`groupBy` merges alerts across labels to cut noise, `newGroupEvalDelay`
|
||||
@@ -26123,7 +25981,6 @@ paths:
|
||||
usePolicy: false
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
tiered_thresholds:
|
||||
description: Two tiers (warning and critical) in a single rule, each
|
||||
with its own target, op, matchType, and channels so warnings and
|
||||
@@ -26223,7 +26080,6 @@ paths:
|
||||
interval: 15m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
traces_error_rate_formula:
|
||||
description: Two disabled trace count queries (A = error spans where
|
||||
hasError=true, B = total spans) combined via a builder_formula into
|
||||
@@ -26313,7 +26169,6 @@ paths:
|
||||
interval: 15m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
traces_threshold_latency:
|
||||
description: Builder query against the traces signal with p99(duration_nano).
|
||||
The series unit is ns (compositeQuery.unit), the target is in seconds
|
||||
@@ -26382,7 +26237,6 @@ paths:
|
||||
interval: 30m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
@@ -26633,7 +26487,6 @@ paths:
|
||||
interval: 30m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
logs_threshold:
|
||||
description: Counts matching log records (ERROR severity + body contains)
|
||||
over a rolling window. Fires at least once per evaluation when the
|
||||
@@ -26700,7 +26553,6 @@ paths:
|
||||
interval: 15m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_anomaly:
|
||||
description: Wraps a builder query in the `anomaly` function with
|
||||
daily seasonality, so SigNoz compares each point against the forecast
|
||||
@@ -26773,7 +26625,6 @@ paths:
|
||||
interval: 4h
|
||||
ruleType: anomaly_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_promql:
|
||||
description: PromQL expression instead of the builder. Dotted OTEL
|
||||
resource attributes are quoted ("deployment.environment"). Useful
|
||||
@@ -26829,7 +26680,6 @@ paths:
|
||||
interval: 1h
|
||||
ruleType: promql_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_threshold_formula:
|
||||
description: Computes disk utilization as (1 - available/capacity)
|
||||
* 100 by combining two disabled base queries with a builder_formula.
|
||||
@@ -26921,7 +26771,6 @@ paths:
|
||||
interval: 2h
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_threshold_single:
|
||||
description: Fires when a pod consumes more than 80% of its requested
|
||||
CPU for the whole evaluation window. Uses `k8s.pod.cpu_request_utilization`.
|
||||
@@ -26990,7 +26839,6 @@ paths:
|
||||
interval: 4h
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
notification_settings:
|
||||
description: 'Demonstrates the full notificationSettings surface:
|
||||
`groupBy` merges alerts across labels to cut noise, `newGroupEvalDelay`
|
||||
@@ -27089,7 +26937,6 @@ paths:
|
||||
usePolicy: false
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
tiered_thresholds:
|
||||
description: Two tiers (warning and critical) in a single rule, each
|
||||
with its own target, op, matchType, and channels so warnings and
|
||||
@@ -27189,7 +27036,6 @@ paths:
|
||||
interval: 15m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
traces_error_rate_formula:
|
||||
description: Two disabled trace count queries (A = error spans where
|
||||
hasError=true, B = total spans) combined via a builder_formula into
|
||||
@@ -27279,7 +27125,6 @@ paths:
|
||||
interval: 15m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
traces_threshold_latency:
|
||||
description: Builder query against the traces signal with p99(duration_nano).
|
||||
The series unit is ns (compositeQuery.unit), the target is in seconds
|
||||
@@ -27348,7 +27193,6 @@ paths:
|
||||
interval: 30m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
@@ -27500,7 +27344,6 @@ paths:
|
||||
interval: 30m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
logs_threshold:
|
||||
description: Counts matching log records (ERROR severity + body contains)
|
||||
over a rolling window. Fires at least once per evaluation when the
|
||||
@@ -27567,7 +27410,6 @@ paths:
|
||||
interval: 15m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_anomaly:
|
||||
description: Wraps a builder query in the `anomaly` function with
|
||||
daily seasonality, so SigNoz compares each point against the forecast
|
||||
@@ -27640,7 +27482,6 @@ paths:
|
||||
interval: 4h
|
||||
ruleType: anomaly_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_promql:
|
||||
description: PromQL expression instead of the builder. Dotted OTEL
|
||||
resource attributes are quoted ("deployment.environment"). Useful
|
||||
@@ -27696,7 +27537,6 @@ paths:
|
||||
interval: 1h
|
||||
ruleType: promql_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_threshold_formula:
|
||||
description: Computes disk utilization as (1 - available/capacity)
|
||||
* 100 by combining two disabled base queries with a builder_formula.
|
||||
@@ -27788,7 +27628,6 @@ paths:
|
||||
interval: 2h
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_threshold_single:
|
||||
description: Fires when a pod consumes more than 80% of its requested
|
||||
CPU for the whole evaluation window. Uses `k8s.pod.cpu_request_utilization`.
|
||||
@@ -27857,7 +27696,6 @@ paths:
|
||||
interval: 4h
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
notification_settings:
|
||||
description: 'Demonstrates the full notificationSettings surface:
|
||||
`groupBy` merges alerts across labels to cut noise, `newGroupEvalDelay`
|
||||
@@ -27956,7 +27794,6 @@ paths:
|
||||
usePolicy: false
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
tiered_thresholds:
|
||||
description: Two tiers (warning and critical) in a single rule, each
|
||||
with its own target, op, matchType, and channels so warnings and
|
||||
@@ -28056,7 +27893,6 @@ paths:
|
||||
interval: 15m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
traces_error_rate_formula:
|
||||
description: Two disabled trace count queries (A = error spans where
|
||||
hasError=true, B = total spans) combined via a builder_formula into
|
||||
@@ -28146,7 +27982,6 @@ paths:
|
||||
interval: 15m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
traces_threshold_latency:
|
||||
description: Builder query against the traces signal with p99(duration_nano).
|
||||
The series unit is ns (compositeQuery.unit), the target is in seconds
|
||||
@@ -28215,7 +28050,6 @@ paths:
|
||||
interval: 30m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
@@ -29129,7 +28963,6 @@ paths:
|
||||
interval: 30m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
logs_threshold:
|
||||
description: Counts matching log records (ERROR severity + body contains)
|
||||
over a rolling window. Fires at least once per evaluation when the
|
||||
@@ -29196,7 +29029,6 @@ paths:
|
||||
interval: 15m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_anomaly:
|
||||
description: Wraps a builder query in the `anomaly` function with
|
||||
daily seasonality, so SigNoz compares each point against the forecast
|
||||
@@ -29269,7 +29101,6 @@ paths:
|
||||
interval: 4h
|
||||
ruleType: anomaly_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_promql:
|
||||
description: PromQL expression instead of the builder. Dotted OTEL
|
||||
resource attributes are quoted ("deployment.environment"). Useful
|
||||
@@ -29325,7 +29156,6 @@ paths:
|
||||
interval: 1h
|
||||
ruleType: promql_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_threshold_formula:
|
||||
description: Computes disk utilization as (1 - available/capacity)
|
||||
* 100 by combining two disabled base queries with a builder_formula.
|
||||
@@ -29417,7 +29247,6 @@ paths:
|
||||
interval: 2h
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
metric_threshold_single:
|
||||
description: Fires when a pod consumes more than 80% of its requested
|
||||
CPU for the whole evaluation window. Uses `k8s.pod.cpu_request_utilization`.
|
||||
@@ -29486,7 +29315,6 @@ paths:
|
||||
interval: 4h
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
notification_settings:
|
||||
description: 'Demonstrates the full notificationSettings surface:
|
||||
`groupBy` merges alerts across labels to cut noise, `newGroupEvalDelay`
|
||||
@@ -29585,7 +29413,6 @@ paths:
|
||||
usePolicy: false
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
tiered_thresholds:
|
||||
description: Two tiers (warning and critical) in a single rule, each
|
||||
with its own target, op, matchType, and channels so warnings and
|
||||
@@ -29685,7 +29512,6 @@ paths:
|
||||
interval: 15m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
traces_error_rate_formula:
|
||||
description: Two disabled trace count queries (A = error spans where
|
||||
hasError=true, B = total spans) combined via a builder_formula into
|
||||
@@ -29775,7 +29601,6 @@ paths:
|
||||
interval: 15m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
traces_threshold_latency:
|
||||
description: Builder query against the traces signal with p99(duration_nano).
|
||||
The series unit is ns (compositeQuery.unit), the target is in seconds
|
||||
@@ -29844,7 +29669,6 @@ paths:
|
||||
interval: 30m
|
||||
ruleType: threshold_rule
|
||||
schemaVersion: v2alpha1
|
||||
version: v5
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRule'
|
||||
responses:
|
||||
@@ -32729,6 +32553,305 @@ paths:
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_anomaly_request_rate:
|
||||
description: 'anomaly is the one function with named arguments. seasonality
|
||||
says which past periods predict the current value: hourly, daily
|
||||
or weekly, daily when absent. z_score_threshold is how many standard
|
||||
deviations from the prediction count as an anomaly. It has no default:
|
||||
a request without it gets bounds of zero width, so send it. 3 is
|
||||
the value the UI sends. The response carries, next to the values,
|
||||
a predicted series, an upper and a lower bound series and an anomaly
|
||||
score series. The scoring runs in the enterprise build; the community
|
||||
build returns the series unchanged.'
|
||||
summary: 'anomaly: request rate scored against its daily pattern'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: http.server.duration.count
|
||||
spaceAggregation: sum
|
||||
timeAggregation: rate
|
||||
functions:
|
||||
- args:
|
||||
- name: seasonality
|
||||
value: daily
|
||||
- name: z_score_threshold
|
||||
value: 3
|
||||
name: anomaly
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 300s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_cumulativesum_errors_so_far:
|
||||
description: cumulativeSum replaces each point with the sum of every
|
||||
point up to and including it, so a per-step count becomes a running
|
||||
total whose first point is the first count. It takes no arguments.
|
||||
The total resets with the window, so a longer time range gives a
|
||||
larger final value; it is a view of the window, not a counter.
|
||||
summary: 'cumulativeSum: 5xx responses since the start of the window'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- alias: server_errors
|
||||
expression: count()
|
||||
filter:
|
||||
expression: http.response.status_code >= 500
|
||||
functions:
|
||||
- name: cumulativeSum
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: traces
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_cutoffmin_hide_idle_hosts:
|
||||
description: The four threshold functions take one number. cutOffMin
|
||||
and cutOffMax remove the points below or above the threshold, so
|
||||
an idle host disappears from the chart instead of drawing a line
|
||||
along the bottom. clampMin and clampMax keep the points and replace
|
||||
the value with the threshold, which caps spikes without hiding them.
|
||||
system.cpu.utilization is a ratio, so 0.05 is 5%.
|
||||
summary: 'cutOffMin: drop hosts below 5% CPU from the chart'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: system.cpu.utilization
|
||||
spaceAggregation: avg
|
||||
timeAggregation: avg
|
||||
functions:
|
||||
- args:
|
||||
- value: 0.05
|
||||
name: cutOffMin
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: host.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_ewma_smoothed_latency:
|
||||
description: 'ewma3, ewma5 and ewma7 replace each point with an exponentially
|
||||
weighted average of the point and the points before it. The number
|
||||
in the name is the window the default weight is derived from: alpha
|
||||
= 2 / (n + 1), so 0.5, 0.333 and 0.25. One optional argument overrides
|
||||
alpha; a value close to 1 follows the raw series, a value close
|
||||
to 0 smooths harder. 0.3 here smooths a little more than ewma5''s
|
||||
default.'
|
||||
summary: 'ewma5: smoothed p95 latency'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: http.server.duration.bucket
|
||||
spaceAggregation: p95
|
||||
functions:
|
||||
- args:
|
||||
- value: 0.3
|
||||
name: ewma5
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_fillzero_sparse_errors:
|
||||
description: A count over sparse events returns a point only for steps
|
||||
that had a match, so a chart of failed spans has gaps where nothing
|
||||
failed. fillZero adds a zero at every missing step. With no arguments
|
||||
the server fills the request window at the query's step, which is
|
||||
the common case. The three optional arguments are start, end and
|
||||
step in milliseconds, for callers that want to fill a different
|
||||
grid.
|
||||
summary: 'fillZero: a zero at every step with no failed spans'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- alias: failed_spans
|
||||
expression: count()
|
||||
filter:
|
||||
expression: has_error = true
|
||||
functions:
|
||||
- name: fillZero
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: traces
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_log10_network_bytes:
|
||||
description: log2 and log10 replace each value with its logarithm,
|
||||
so a loopback device moving kilobytes and an uplink moving gigabytes
|
||||
fit on one chart. They take no arguments. Zero and negative values
|
||||
have no logarithm and are not drawn. absolute, also without arguments,
|
||||
is the usual companion for a signed series before a logarithm.
|
||||
summary: 'log10: network throughput across devices that differ by
|
||||
orders of magnitude'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: system.network.io
|
||||
spaceAggregation: sum
|
||||
timeAggregation: rate
|
||||
filter:
|
||||
expression: direction = 'transmit'
|
||||
functions:
|
||||
- name: log10
|
||||
groupBy:
|
||||
- fieldContext: attribute
|
||||
name: device
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_median_despiked_request_rate:
|
||||
description: median3, median5 and median7 replace each point with
|
||||
the median of the surrounding 3, 5 or 7 points. A spike that lasts
|
||||
one step vanishes, a shift that lasts longer than half the window
|
||||
stays. The first and last half window, 2 points each for median5,
|
||||
have no full window and keep their values, so the newest spike stays
|
||||
visible; a series shorter than the window is returned unchanged.
|
||||
They take no arguments. Use them when the raw series is noisy and
|
||||
the chart is for a trend, not for catching the spike.
|
||||
summary: 'median5: request rate without one-step spikes'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: http.server.duration.count
|
||||
spaceAggregation: sum
|
||||
timeAggregation: rate
|
||||
functions:
|
||||
- name: median5
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_runningdiff_disk_growth:
|
||||
description: 'runningDiff turns a gauge into the change between consecutive
|
||||
points, so a filesystem usage gauge becomes bytes written per step.
|
||||
It takes no arguments and drops the first point of the window, since
|
||||
that point has nothing to difference against; the server reads one
|
||||
step before the window to keep the first interval. Functions apply
|
||||
in order: clampMin with 0 runs on the differences and replaces the
|
||||
negative value a cleanup or a volume reset produces with zero.'
|
||||
summary: 'runningDiff then clampMin: bytes written to disk per step'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: system.filesystem.usage
|
||||
spaceAggregation: sum
|
||||
timeAggregation: latest
|
||||
filter:
|
||||
expression: state = 'used'
|
||||
functions:
|
||||
- name: runningDiff
|
||||
- args:
|
||||
- value: 0
|
||||
name: clampMin
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: host.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_timeshift_week_over_week:
|
||||
description: timeShift moves every point of its query forward by the
|
||||
argument, in seconds, so a series read from the past lands on the
|
||||
current timestamps. B here reads the hour exactly seven days (604800
|
||||
s) before the requested window and comes back on this week's timestamps,
|
||||
so it overlays A on a chart and the formula can subtract it point
|
||||
by point. The argument is positive; it says how far back to read.
|
||||
summary: 'timeShift: this week''s error logs next to last week''s'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- alias: errors
|
||||
expression: count()
|
||||
filter:
|
||||
expression: severity_text = 'ERROR'
|
||||
name: A
|
||||
signal: logs
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
- spec:
|
||||
aggregations:
|
||||
- alias: errors
|
||||
expression: count()
|
||||
filter:
|
||||
expression: severity_text = 'ERROR'
|
||||
functions:
|
||||
- args:
|
||||
- value: 604800
|
||||
name: timeShift
|
||||
name: B
|
||||
signal: logs
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
- spec:
|
||||
expression: A - B
|
||||
name: F1
|
||||
type: builder_formula
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
logs_raw:
|
||||
summary: 'Raw: fetch raw log records'
|
||||
value:
|
||||
|
||||
@@ -256,7 +256,7 @@ Tests can be configured using pytest options:
|
||||
- `--sqlite-mode` — SQLite journal mode: `delete` or `wal` (default: `delete`). Only relevant when `--sqlstore-provider=sqlite`.
|
||||
- `--postgres-version` — PostgreSQL version (default: `15`)
|
||||
- `--clickhouse-version` — ClickHouse version, also used for ClickHouse Keeper (default: `25.12.5`)
|
||||
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.9`)
|
||||
- `--signoz-otel-collector-version`: SigNoz OTel collector version, used to run the ClickHouse migrations (default: `v0.144.16-rc.1`)
|
||||
- `--with-web` — Build the frontend into the SigNoz image (required for e2e)
|
||||
|
||||
Example:
|
||||
|
||||
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{{
|
||||
|
||||
@@ -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,33 +829,26 @@ export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTy
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
|
||||
jira = 'jira',
|
||||
}
|
||||
export interface AlertmanagertypesChannelJiraCloudAuthDTO {
|
||||
export interface AlertmanagertypesChannelJiraAuthDTO {
|
||||
/**
|
||||
* @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 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;
|
||||
apiToken?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The Atlassian account email used for basic auth.
|
||||
* @description The Atlassian account email used for basic auth. Required when deployment is cloud.
|
||||
*/
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelJiraSelfHostedAuthDTO {
|
||||
email?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
* @description The Jira personal access token, sent as a Bearer header. A secret; returned by create, update and fetch by ID, never by list.
|
||||
* @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;
|
||||
pat?: string;
|
||||
}
|
||||
|
||||
export type AlertmanagertypesChannelJiraAuthDTO =
|
||||
| AlertmanagertypesChannelJiraCloudAuthDTO
|
||||
| AlertmanagertypesChannelJiraSelfHostedAuthDTO;
|
||||
|
||||
export enum AlertmanagertypesJiraDeploymentDTO {
|
||||
cloud = 'cloud',
|
||||
selfhosted = 'selfhosted',
|
||||
@@ -3611,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',
|
||||
@@ -7079,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;
|
||||
/**
|
||||
@@ -12966,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.
|
||||
@@ -13002,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.
|
||||
*/
|
||||
@@ -13048,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.
|
||||
@@ -13071,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.
|
||||
*/
|
||||
@@ -13086,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 {
|
||||
@@ -13160,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.
|
||||
@@ -13188,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.
|
||||
*/
|
||||
@@ -13203,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.
|
||||
*/
|
||||
@@ -13221,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 {
|
||||
@@ -16783,17 +16703,6 @@ export type UpdateRuleView200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRules200 = {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
data: RuletypesRuleDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateRule201 = {
|
||||
data: RuletypesRuleDTO;
|
||||
/**
|
||||
|
||||
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,18 +1,13 @@
|
||||
import { ConfigProvider, Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Select } from 'antd';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { UpdatedWindow } from '../dsl/types';
|
||||
import type { CreatorOption } from '../types';
|
||||
import { UPDATED_OPTIONS } from './constants';
|
||||
import MultiSelectFilter from './MultiSelectFilter';
|
||||
|
||||
import styles from './FilterZone.module.scss';
|
||||
|
||||
// The option checkbox already marks selection, so selected rows stay regular weight.
|
||||
const CREATOR_SELECT_THEME = {
|
||||
components: { Select: { optionSelectedFontWeight: 400 } },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
createdBy: string[];
|
||||
updated: UpdatedWindow;
|
||||
@@ -52,36 +47,16 @@ function FilterChips({
|
||||
|
||||
return (
|
||||
<div className={styles.chips}>
|
||||
<ConfigProvider theme={CREATOR_SELECT_THEME}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
allowClear
|
||||
className={cx(styles.select, styles.selectWide)}
|
||||
placeholder="Created by"
|
||||
value={createdBy}
|
||||
options={creatorOptionsData}
|
||||
optionFilterProp="label"
|
||||
maxTagCount={1}
|
||||
menuItemSelectedIcon={null}
|
||||
data-testid={`${testIdPrefix}-filter-created-by`}
|
||||
disabled={disabled}
|
||||
onClear={onClearCreatedBy}
|
||||
optionRender={(option): JSX.Element => (
|
||||
<div className={styles.creatorOption}>
|
||||
<div className={styles.creatorCheck}>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
value={createdBy.includes(option.value as string)}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.creatorLabel}>{option.label}</span>
|
||||
</div>
|
||||
)}
|
||||
onChange={(value): void => onCreatedByChange(value as string[])}
|
||||
onDropdownVisibleChange={runOnClose}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
<MultiSelectFilter
|
||||
value={createdBy}
|
||||
options={creatorOptionsData}
|
||||
placeholder="Created by"
|
||||
testId={`${testIdPrefix}-filter-created-by`}
|
||||
disabled={disabled}
|
||||
onClear={onClearCreatedBy}
|
||||
onChange={onCreatedByChange}
|
||||
onDropdownVisibleChange={runOnClose}
|
||||
/>
|
||||
<Select
|
||||
showSearch
|
||||
className={cx(styles.select, styles.selectNarrow)}
|
||||
|
||||
@@ -86,21 +86,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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'";
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect } from 'react';
|
||||
import { parseAsString, useQueryStates } from 'nuqs';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import {
|
||||
ALL_ENDPOINTS_EXPRESSION_KEY,
|
||||
ENDPOINT_STATS_EXPRESSION_KEY,
|
||||
} from '../../../filterParamKeys';
|
||||
import {
|
||||
getLegacyFilterExpressions,
|
||||
useApiMonitoringParams,
|
||||
} from '../../../queryParams';
|
||||
|
||||
/**
|
||||
* Moves the filters an older link keeps inside `apiMonitoringParams` into the
|
||||
* drawer's own params, then rewrites `apiMonitoringParams` without them, so it
|
||||
* happens once.
|
||||
*/
|
||||
export function useConvertLegacyFilterLinks(): void {
|
||||
const [, setParams] = useApiMonitoringParams();
|
||||
const [, setDrawerFilters] = useQueryStates({
|
||||
[ALL_ENDPOINTS_EXPRESSION_KEY]: parseAsString,
|
||||
[ENDPOINT_STATS_EXPRESSION_KEY]: parseAsString,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const legacy = getLegacyFilterExpressions(
|
||||
`?${getUnstableCurrentSearchParams().toString()}`,
|
||||
);
|
||||
if (
|
||||
legacy.allEndpoints === undefined &&
|
||||
legacy.endPointDetails === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void setDrawerFilters({
|
||||
[ALL_ENDPOINTS_EXPRESSION_KEY]: legacy.allEndpoints || null,
|
||||
[ENDPOINT_STATS_EXPRESSION_KEY]: legacy.endPointDetails || null,
|
||||
});
|
||||
setParams({}, true);
|
||||
}, [setParams, setDrawerFilters]);
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
|
||||
import {
|
||||
@@ -11,20 +9,14 @@ import {
|
||||
getFormattedEndPointDropDownData,
|
||||
getFormattedEndPointStatusCodeChartData,
|
||||
getFormattedEndPointStatusCodeData,
|
||||
getGroupByFiltersFromGroupByValues,
|
||||
getGroupByExpressionFromRowData,
|
||||
getHttpUrlExpression,
|
||||
getStatusCodeBarChartWidgetData,
|
||||
getTopErrorsColumnsConfig,
|
||||
getTopErrorsCoRelationQueryFilters,
|
||||
} from '../utils';
|
||||
import { APIMonitoringColumnsMock } from './mock';
|
||||
|
||||
// Mock or define DataTypes since it seems to be missing from imports
|
||||
const DataTypes = {
|
||||
String: 'string',
|
||||
Float64: 'float64',
|
||||
bool: 'bool',
|
||||
};
|
||||
|
||||
// Mock the external utils dependencies that are used within our tested functions
|
||||
jest.mock('../utils', () => {
|
||||
// Import the actual module to partial mock
|
||||
@@ -90,140 +82,39 @@ describe('API Monitoring Utils', () => {
|
||||
expect(result[0].lastUsed).toBe('-');
|
||||
});
|
||||
});
|
||||
describe('getGroupByFiltersFromGroupByValues', () => {
|
||||
it('should convert row data to filters correctly', () => {
|
||||
// Arrange
|
||||
const rowData = {
|
||||
'http.method': 'GET',
|
||||
'http.status_code': '200',
|
||||
'service.name': 'api-service',
|
||||
// Fields that should be filtered out
|
||||
data: 'someValue',
|
||||
key: 'someKey',
|
||||
};
|
||||
|
||||
const groupBy = [
|
||||
{
|
||||
id: 'group-by-1',
|
||||
key: 'http.method',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
{
|
||||
id: 'group-by-2',
|
||||
key: 'http.status_code',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
{
|
||||
id: 'group-by-3',
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = getGroupByFiltersFromGroupByValues(
|
||||
rowData,
|
||||
groupBy as BaseAutocompleteData[],
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.op).toBe('AND');
|
||||
// The implementation includes all keys from rowData, not just those in groupBy
|
||||
expect(result?.items?.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Verify each filter matches the corresponding groupBy
|
||||
describe('getGroupByExpressionFromRowData', () => {
|
||||
it('builds an AND expression from the row values, skipping the url and metric columns', () => {
|
||||
expect(
|
||||
result?.items?.some(
|
||||
(item) =>
|
||||
item.key &&
|
||||
item.key.key === 'http.method' &&
|
||||
item.value === 'GET' &&
|
||||
item.op === '=',
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
result?.items?.some(
|
||||
(item) =>
|
||||
item.key &&
|
||||
item.key.key === 'http.status_code' &&
|
||||
item.value === '200' &&
|
||||
item.op === '=',
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
result?.items?.some(
|
||||
(item) =>
|
||||
item.key &&
|
||||
item.key.key === 'service.name' &&
|
||||
item.value === 'api-service' &&
|
||||
item.op === '=',
|
||||
),
|
||||
).toBe(true);
|
||||
getGroupByExpressionFromRowData({
|
||||
http_url: '/api/test',
|
||||
key: '3f2a-row-uuid',
|
||||
A: 10,
|
||||
B: 20,
|
||||
C: 1,
|
||||
F1: 5,
|
||||
'service.name': 'api-service',
|
||||
'http.status_code': 200,
|
||||
}),
|
||||
).toBe("service.name = 'api-service' AND http.status_code = 200");
|
||||
});
|
||||
|
||||
it('should handle fields not in groupBy', () => {
|
||||
// Arrange
|
||||
const rowData = {
|
||||
'http.method': 'GET',
|
||||
'unknown.field': 'someValue',
|
||||
};
|
||||
|
||||
const groupBy = [
|
||||
{
|
||||
id: 'group-by-1',
|
||||
key: 'http.method',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
];
|
||||
// Act
|
||||
const result = getGroupByFiltersFromGroupByValues(
|
||||
rowData,
|
||||
groupBy as BaseAutocompleteData[],
|
||||
it('escapes quotes in values', () => {
|
||||
expect(getGroupByExpressionFromRowData({ 'user.name': "o'brien" })).toBe(
|
||||
"user.name = 'o\\'brien'",
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
// The implementation includes all keys from rowData, not just those in groupBy
|
||||
expect(result?.items?.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Should include the known field with the proper dataType from groupBy
|
||||
const knownField = result?.items?.find(
|
||||
(item) => item.key && item.key.key === 'http.method',
|
||||
);
|
||||
expect(knownField).toBeDefined();
|
||||
if (knownField && knownField.key) {
|
||||
expect(knownField.key.dataType).toBe(DataTypes.String);
|
||||
}
|
||||
|
||||
// Should include the unknown field
|
||||
const unknownField = result?.items?.find(
|
||||
(item) => item.key && item.key.key === 'unknown.field',
|
||||
);
|
||||
expect(unknownField).toBeDefined();
|
||||
if (unknownField && unknownField.key) {
|
||||
expect(unknownField.key.dataType).toBe(DataTypes.String); // Default
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
// Arrange
|
||||
const rowData = {};
|
||||
const groupBy: any[] = [];
|
||||
it('matches spans without the attribute when the group has no value', () => {
|
||||
expect(
|
||||
getGroupByExpressionFromRowData({
|
||||
'service.name': 'cart',
|
||||
'ai.embeddings': null,
|
||||
}),
|
||||
).toBe("service.name = 'cart' AND ai.embeddings NOT EXISTS");
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = getGroupByFiltersFromGroupByValues(rowData, groupBy);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.op).toBe('AND');
|
||||
expect(result?.items).toHaveLength(0);
|
||||
it('returns an empty expression for a row without group columns', () => {
|
||||
expect(getGroupByExpressionFromRowData({ http_url: '/x', A: 1 })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -703,13 +594,9 @@ describe('API Monitoring Utils', () => {
|
||||
it('should generate widget configuration for status code bar chart', () => {
|
||||
// Arrange
|
||||
const domainName = 'test-domain';
|
||||
const filters = { items: [], op: 'AND' };
|
||||
|
||||
// Act
|
||||
const result = getStatusCodeBarChartWidgetData(
|
||||
domainName,
|
||||
filters as IBuilderQuery['filters'],
|
||||
);
|
||||
const result = getStatusCodeBarChartWidgetData(domainName, '');
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
@@ -723,47 +610,29 @@ describe('API Monitoring Utils', () => {
|
||||
const queryData = result.query.builder.queryData[0];
|
||||
|
||||
// Should have domain filter
|
||||
const domainFilter = queryData.filters?.items?.find(
|
||||
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
expect(queryData.filter?.expression).toBe(
|
||||
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}'`,
|
||||
);
|
||||
expect(domainFilter).toBeDefined();
|
||||
if (domainFilter) {
|
||||
expect(domainFilter.value).toBe(domainName);
|
||||
}
|
||||
});
|
||||
|
||||
it('should include custom filters in the widget configuration', () => {
|
||||
// Arrange
|
||||
const domainName = 'test-domain';
|
||||
const customFilter = {
|
||||
id: 'custom-filter',
|
||||
key: {
|
||||
dataType: 'string',
|
||||
key: 'custom.key',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: 'custom-value',
|
||||
};
|
||||
const filters = { items: [customFilter], op: 'AND' };
|
||||
const customFilterExpression = "custom.key = 'custom-value'";
|
||||
|
||||
// Act
|
||||
const result = getStatusCodeBarChartWidgetData(
|
||||
domainName,
|
||||
filters as IBuilderQuery['filters'],
|
||||
customFilterExpression,
|
||||
);
|
||||
|
||||
// Assert
|
||||
const queryData = result.query.builder.queryData[0];
|
||||
|
||||
// Should include our custom filter
|
||||
const includedFilter = queryData.filters?.items?.find(
|
||||
(item) => item.id === 'custom-filter',
|
||||
// Should include our custom filter after the domain filter
|
||||
expect(queryData.filter?.expression).toBe(
|
||||
`${SPAN_ATTRIBUTES.SERVER_NAME} = '${domainName}' AND (${customFilterExpression})`,
|
||||
);
|
||||
expect(includedFilter).toBeDefined();
|
||||
if (includedFilter) {
|
||||
expect(includedFilter.value).toBe('custom-value');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -927,4 +796,17 @@ describe('API Monitoring Utils', () => {
|
||||
expect(latencyColumn?.title).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHttpUrlExpression', () => {
|
||||
it('returns no clause when no endpoint is picked', () => {
|
||||
expect(getHttpUrlExpression('')).toBe('');
|
||||
});
|
||||
|
||||
it('quotes the endpoint and escapes apostrophes', () => {
|
||||
expect(getHttpUrlExpression('/x')).toBe("http_url = '/x'");
|
||||
expect(getHttpUrlExpression("/users/o'brien")).toBe(
|
||||
"http_url = '/users/o\\'brien'",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import {
|
||||
getAllEndpointsWidgetData,
|
||||
getGroupByFiltersFromGroupByValues,
|
||||
getGroupByExpressionFromRowData,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
|
||||
import {
|
||||
ALL_ENDPOINTS_EXPRESSION_KEY,
|
||||
ENDPOINT_STATS_EXPRESSION_KEY,
|
||||
} from 'container/ApiMonitoring/filterParamKeys';
|
||||
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import AllEndPoints from '../Explorer/Domains/DomainDetails/AllEndPoints';
|
||||
import { FilterProviders } from './FilterProviders';
|
||||
import {
|
||||
SPAN_ATTRIBUTES,
|
||||
VIEWS,
|
||||
@@ -14,7 +20,7 @@ import {
|
||||
// Mock the dependencies
|
||||
jest.mock('container/ApiMonitoring/utils', () => ({
|
||||
getAllEndpointsWidgetData: jest.fn(),
|
||||
getGroupByFiltersFromGroupByValues: jest.fn(),
|
||||
getGroupByExpressionFromRowData: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/WidgetCard/Card', () => ({
|
||||
@@ -34,53 +40,47 @@ jest.mock('container/WidgetCard/Card', () => ({
|
||||
)),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(({ onChange }) => (
|
||||
<div data-testid="query-builder-mock">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="filter-change-button"
|
||||
onClick={(): void =>
|
||||
onChange({
|
||||
items: [{ id: 'test', key: 'test', op: '=', value: 'test' }],
|
||||
op: 'AND',
|
||||
})
|
||||
}
|
||||
>
|
||||
Change Filter
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('hooks/queryBuilder/useGetAggregateKeys', () => ({
|
||||
useGetAggregateKeys: jest.fn(),
|
||||
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(({ onRun }) => (
|
||||
<div data-testid="query-builder-mock">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="filter-change-button"
|
||||
onClick={(): void => onRun("test = 'test'")}
|
||||
>
|
||||
Change Filter
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
jest.mock('antd', () => {
|
||||
const originalModule = jest.requireActual('antd');
|
||||
return {
|
||||
...originalModule,
|
||||
Select: jest.fn().mockImplementation(({ onChange }) => (
|
||||
<div data-testid="select-mock">
|
||||
<button
|
||||
data-testid="select-change-button"
|
||||
type="button"
|
||||
onClick={(): void => onChange(['http.status_code'])}
|
||||
>
|
||||
Change GroupBy
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
};
|
||||
});
|
||||
jest.mock('container/QueryBuilder/filters/GroupByFilter/GroupByFilter', () => ({
|
||||
GroupByFilter: jest.fn().mockImplementation(({ onChange }) => (
|
||||
<div data-testid="select-mock">
|
||||
<button
|
||||
data-testid="select-change-button"
|
||||
type="button"
|
||||
onClick={(): void =>
|
||||
onChange([
|
||||
{
|
||||
id: 'http.status_code--string--attribute',
|
||||
key: 'http.status_code',
|
||||
dataType: 'string',
|
||||
type: 'attribute',
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
Change GroupBy
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock useApiMonitoringParams hook
|
||||
jest.mock('container/ApiMonitoring/queryParams', () => ({
|
||||
...jest.requireActual('container/ApiMonitoring/queryParams'),
|
||||
useApiMonitoringParams: jest.fn().mockReturnValue([
|
||||
{
|
||||
showIP: true,
|
||||
@@ -88,8 +88,6 @@ jest.mock('container/ApiMonitoring/queryParams', () => ({
|
||||
selectedView: 'all_endpoints',
|
||||
selectedEndPointName: '',
|
||||
groupBy: [],
|
||||
allEndpointsLocalFilters: undefined,
|
||||
endPointDetailsLocalFilters: undefined,
|
||||
modalTimeRange: undefined,
|
||||
selectedInterval: undefined,
|
||||
},
|
||||
@@ -108,29 +106,21 @@ describe('AllEndPoints', () => {
|
||||
startTime: 1609459200000,
|
||||
endTime: 1609545600000,
|
||||
},
|
||||
initialFilters: { op: 'AND', items: [] },
|
||||
setInitialFiltersEndPointStats: jest.fn(),
|
||||
};
|
||||
|
||||
const renderAllEndPoints = (): void => {
|
||||
render(
|
||||
<FilterProviders queryParamKey={ALL_ENDPOINTS_EXPRESSION_KEY}>
|
||||
<AllEndPoints {...mockProps} />
|
||||
</FilterProviders>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetNuqsState();
|
||||
|
||||
// Setup mock implementations
|
||||
(useGetAggregateKeys as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
payload: {
|
||||
attributeKeys: [
|
||||
{
|
||||
key: 'http.status_code',
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
(getAllEndpointsWidgetData as jest.Mock).mockReturnValue({
|
||||
id: 'test-widget',
|
||||
title: 'Endpoint Overview',
|
||||
@@ -139,10 +129,9 @@ describe('AllEndPoints', () => {
|
||||
queryData: [],
|
||||
});
|
||||
|
||||
(getGroupByFiltersFromGroupByValues as jest.Mock).mockReturnValue({
|
||||
items: [{ id: 'group-filter', key: 'status', op: '=', value: '200' }],
|
||||
op: 'AND',
|
||||
});
|
||||
(getGroupByExpressionFromRowData as jest.Mock).mockReturnValue(
|
||||
"status = '200'",
|
||||
);
|
||||
});
|
||||
|
||||
// Add cleanup after each test
|
||||
@@ -151,7 +140,7 @@ describe('AllEndPoints', () => {
|
||||
});
|
||||
|
||||
it('renders component correctly', () => {
|
||||
render(<AllEndPoints {...mockProps} />);
|
||||
renderAllEndPoints();
|
||||
|
||||
// Verify basic component rendering
|
||||
expect(screen.getByText('Group by')).toBeInTheDocument();
|
||||
@@ -161,7 +150,7 @@ describe('AllEndPoints', () => {
|
||||
});
|
||||
|
||||
it('handles filter changes', () => {
|
||||
render(<AllEndPoints {...mockProps} />);
|
||||
renderAllEndPoints();
|
||||
|
||||
// Trigger filter change
|
||||
fireEvent.click(screen.getByTestId('filter-change-button'));
|
||||
@@ -170,25 +159,24 @@ describe('AllEndPoints', () => {
|
||||
expect(getAllEndpointsWidgetData).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'test-domain',
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([expect.objectContaining({ id: 'test' })]),
|
||||
op: 'AND',
|
||||
}),
|
||||
"test = 'test'",
|
||||
);
|
||||
});
|
||||
|
||||
it('handles group by changes', () => {
|
||||
render(<AllEndPoints {...mockProps} />);
|
||||
renderAllEndPoints();
|
||||
|
||||
// Trigger group by change
|
||||
fireEvent.click(screen.getByTestId('select-change-button'));
|
||||
|
||||
// Check if setGroupBy was called with updated group by value
|
||||
expect(mockProps.setGroupBy).toHaveBeenCalled();
|
||||
// The picked keys are stored as given and written to the URL as bare names
|
||||
expect(mockProps.setGroupBy).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ key: 'http.status_code', type: 'attribute' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles row click in grid card', async () => {
|
||||
render(<AllEndPoints {...mockProps} />);
|
||||
renderAllEndPoints();
|
||||
|
||||
// Trigger row click
|
||||
fireEvent.click(screen.getByTestId('row-click-button'));
|
||||
@@ -196,7 +184,11 @@ describe('AllEndPoints', () => {
|
||||
// Check if proper functions were called
|
||||
expect(mockProps.setSelectedEndPointName).toHaveBeenCalledWith('/api/test');
|
||||
expect(mockProps.setSelectedView).toHaveBeenCalledWith(VIEWS.ENDPOINT_STATS);
|
||||
expect(mockProps.setInitialFiltersEndPointStats).toHaveBeenCalled();
|
||||
expect(getGroupByFiltersFromGroupByValues).toHaveBeenCalled();
|
||||
expect(getGroupByExpressionFromRowData).toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(getCurrentNuqsSearchParams().get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
|
||||
"status = '200'",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,16 +14,12 @@ import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
|
||||
|
||||
describe('AllEndpointsWidget - V5 Migration Validation', () => {
|
||||
const mockDomainName = 'api.example.com';
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyFilters = '';
|
||||
const emptyGroupBy: BaseAutocompleteData[] = [];
|
||||
|
||||
describe('1. V5 Format Migration - All Four Queries', () => {
|
||||
|
||||
@@ -121,8 +121,7 @@ describe('DependentServices', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders loading state correctly', () => {
|
||||
// Arrange
|
||||
it('keeps the table in place while loading', () => {
|
||||
const mockQuery: MockQueryResult = {
|
||||
isLoading: true,
|
||||
isRefetching: false,
|
||||
@@ -131,16 +130,15 @@ describe('DependentServices', () => {
|
||||
refetch: refetchFn,
|
||||
};
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
render(
|
||||
<DependentServices
|
||||
dependentServicesQuery={mockQuery as any}
|
||||
timeRange={mockTimeRange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dependent-services-table')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
|
||||
});
|
||||
|
||||
it('renders error state correctly', () => {
|
||||
@@ -225,8 +223,7 @@ describe('DependentServices', () => {
|
||||
expect(screen.getByTestId('page-size')).toHaveTextContent('5');
|
||||
});
|
||||
|
||||
it('handles refetching state correctly', () => {
|
||||
// Arrange
|
||||
it('shows the rows under the spinner while refetching', () => {
|
||||
const mockQuery: MockQueryResult = {
|
||||
isLoading: false,
|
||||
isRefetching: true,
|
||||
@@ -235,16 +232,16 @@ describe('DependentServices', () => {
|
||||
refetch: refetchFn,
|
||||
};
|
||||
|
||||
// Act
|
||||
const { container } = render(
|
||||
render(
|
||||
<DependentServices
|
||||
dependentServicesQuery={mockQuery as any}
|
||||
timeRange={mockTimeRange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('loading-state')).toHaveTextContent('Loading');
|
||||
expect(screen.getByTestId('row-count')).toHaveTextContent('2');
|
||||
expect(screen.getByTestId('table-row-0')).toHaveTextContent('auth-service');
|
||||
});
|
||||
|
||||
it('handles row click correctly', () => {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { VIEWS } from '../Explorer/Domains/DomainDetails/constants';
|
||||
import DomainDetails from '../Explorer/Domains/DomainDetails/DomainDetails';
|
||||
|
||||
jest.mock('container/ApiMonitoring/queryParams', () => ({
|
||||
...jest.requireActual('container/ApiMonitoring/queryParams'),
|
||||
useApiMonitoringParams: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
jest.mock('../Explorer/Domains/DomainDetails/components/DomainMetrics', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
jest.mock('../Explorer/Domains/DomainDetails/EndPointDetails', () => {
|
||||
const { useInitialExpression } = jest.requireActual(
|
||||
'components/QueryBuilderV2',
|
||||
);
|
||||
return {
|
||||
__esModule: true,
|
||||
default: function MockEndPointDetails(): JSX.Element {
|
||||
return (
|
||||
<span data-testid="endpoint-stats-scope">{useInitialExpression()}</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('DomainDetails', () => {
|
||||
it('scopes Endpoint Stats to the domain and the picked endpoint', () => {
|
||||
jest
|
||||
.mocked(useApiMonitoringParams)
|
||||
.mockReturnValue([
|
||||
{ selectedView: VIEWS.ENDPOINT_STATS, selectedEndPointName: '/v1/charges' },
|
||||
jest.fn(),
|
||||
]);
|
||||
|
||||
render(
|
||||
<DomainDetails
|
||||
domainData={{ domainName: 'api.example.com' }}
|
||||
handleClose={jest.fn()}
|
||||
selectedDomainIndex={0}
|
||||
setSelectedDomainIndex={jest.fn()}
|
||||
domainListLength={1}
|
||||
domainListFilterExpression=""
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('endpoint-stats-scope')).toHaveTextContent(
|
||||
"kind_string = 'Client' AND http_host = 'api.example.com' AND http_url = '/v1/charges'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import DomainList from '../Explorer/Domains/DomainList';
|
||||
|
||||
const FILTER = "http_host = 'api.example.com'";
|
||||
|
||||
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
|
||||
__esModule: true,
|
||||
default: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
({
|
||||
onChange,
|
||||
onRun,
|
||||
}: {
|
||||
onChange: (query: string) => void;
|
||||
onRun?: (query: string) => void;
|
||||
}) => (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="query-search-type"
|
||||
onClick={(): void => onChange("http_host = 'api.example.com'")}
|
||||
>
|
||||
Type
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="query-search-run"
|
||||
onClick={(): void => onRun?.("http_host = 'api.example.com'")}
|
||||
>
|
||||
Run
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('container/Toolbar/Toolbar', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
jest.mock('../Explorer/Domains/DomainDetails/DomainDetails', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
// Under the test router it keeps rewriting the URL, which re-renders the page
|
||||
// without end.
|
||||
jest.mock('hooks/queryBuilder/useShareBuilderUrl', () => ({
|
||||
useShareBuilderUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('DomainList', () => {
|
||||
it('runs the typed filter from the filter box', async () => {
|
||||
let listRequests = 0;
|
||||
server.use(
|
||||
rest.post('*/third-party-apis/overview/list', (_req, res, ctx) => {
|
||||
listRequests += 1;
|
||||
return res(ctx.status(200), ctx.json({ data: { data: { results: [] } } }));
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<DomainList />, undefined, {
|
||||
initialRoute: '/api-monitoring/explorer',
|
||||
});
|
||||
await waitFor(() => expect(listRequests).toBe(1));
|
||||
|
||||
await user.click(screen.getByTestId('query-search-type'));
|
||||
await user.click(screen.getByTestId('query-search-run'));
|
||||
|
||||
await waitFor(() => expect(listRequests).toBe(2));
|
||||
const [url] = safeNavigateMock.mock.calls[0];
|
||||
const compositeQuery = new URLSearchParams(String(url).split('?')[1]).get(
|
||||
'compositeQuery',
|
||||
);
|
||||
expect(decodeURIComponent(compositeQuery ?? '')).toContain(
|
||||
`"expression":"${FILTER}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useQueries } from 'react-query';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { ENDPOINT_STATS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
|
||||
import {
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
|
||||
extractPortAndEndpoint,
|
||||
@@ -11,14 +13,13 @@ import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
TagFilter,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { getCurrentNuqsSearchParams, resetNuqsState } from 'tests/nuqs-helpers';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
|
||||
import EndPointPicker from '../Explorer/Domains/DomainDetails/components/EndPointPicker';
|
||||
import EndPointDetails from '../Explorer/Domains/DomainDetails/EndPointDetails';
|
||||
import { FilterProviders } from './FilterProviders';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('react-query', () => ({
|
||||
@@ -26,72 +27,32 @@ jest.mock('react-query', () => ({
|
||||
useQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock useApiMonitoringParams hook
|
||||
jest.mock('container/ApiMonitoring/queryParams', () => ({
|
||||
useApiMonitoringParams: jest.fn().mockReturnValue([
|
||||
{
|
||||
showIP: true,
|
||||
selectedDomain: '',
|
||||
selectedView: 'all_endpoints',
|
||||
selectedEndPointName: '',
|
||||
groupBy: [],
|
||||
allEndpointsLocalFilters: undefined,
|
||||
endPointDetailsLocalFilters: undefined,
|
||||
modalTimeRange: undefined,
|
||||
selectedInterval: undefined,
|
||||
},
|
||||
jest.fn(),
|
||||
]),
|
||||
}));
|
||||
|
||||
jest.mock('container/ApiMonitoring/utils', () => ({
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY: [
|
||||
'endPointMetricsData',
|
||||
'endPointStatusCodeData',
|
||||
'endPointDropDownData',
|
||||
'endPointDependentServicesData',
|
||||
'endPointStatusCodeBarChartsData',
|
||||
'endPointStatusCodeLatencyBarChartsData',
|
||||
],
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY: jest.requireActual(
|
||||
'container/ApiMonitoring/utils',
|
||||
).END_POINT_DETAILS_QUERY_KEYS_ARRAY,
|
||||
extractPortAndEndpoint: jest.fn(),
|
||||
getHttpUrlExpression: jest.requireActual('container/ApiMonitoring/utils')
|
||||
.getHttpUrlExpression,
|
||||
getEndPointDetailsQueryPayload: jest.fn(),
|
||||
getLatencyOverTimeWidgetData: jest.fn(),
|
||||
getRateOverTimeWidgetData: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(({ onChange }) => (
|
||||
<div data-testid="query-builder-search">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="filter-change-button"
|
||||
onClick={(): void =>
|
||||
onChange({
|
||||
items: [
|
||||
{
|
||||
id: 'test-filter',
|
||||
key: {
|
||||
key: 'test.key',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: 'test-value',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
})
|
||||
}
|
||||
>
|
||||
Change Filter
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
}),
|
||||
);
|
||||
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(({ onRun }) => (
|
||||
<div data-testid="query-builder-search">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="filter-change-button"
|
||||
onClick={(): void => onRun("test.key = 'test-value'")}
|
||||
>
|
||||
Change Filter
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock all child components to simplify testing
|
||||
jest.mock(
|
||||
@@ -107,15 +68,15 @@ jest.mock(
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
|
||||
'../Explorer/Domains/DomainDetails/components/EndPointPicker',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(({ setSelectedEndPointName }) => (
|
||||
default: jest.fn().mockImplementation(({ onChange }) => (
|
||||
<div data-testid="endpoints-dropdown">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="select-endpoint-button"
|
||||
onClick={(): void => setSelectedEndPointName('/api/new-endpoint')}
|
||||
onClick={(): void => onChange('/api/new-endpoint')}
|
||||
>
|
||||
Select Endpoint
|
||||
</button>
|
||||
@@ -172,8 +133,23 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
function renderStats(ui: JSX.Element, linkFilter = ''): void {
|
||||
render(
|
||||
<FilterProviders
|
||||
queryParamKey={ENDPOINT_STATS_EXPRESSION_KEY}
|
||||
searchParams={
|
||||
linkFilter
|
||||
? `${ENDPOINT_STATS_EXPRESSION_KEY}=${encodeURIComponent(linkFilter)}`
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{ui}
|
||||
</FilterProviders>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('EndPointDetails Component', () => {
|
||||
const mockQueryResults = Array(6).fill({
|
||||
const mockQueryResults = Array(5).fill({
|
||||
data: { data: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -184,7 +160,6 @@ describe('EndPointDetails Component', () => {
|
||||
domainName: 'test-domain',
|
||||
endPointName: '/api/test',
|
||||
setSelectedEndPointName: jest.fn(),
|
||||
initialFilters: { items: [], op: 'AND' } as TagFilter,
|
||||
timeRange: {
|
||||
startTime: 1609459200000,
|
||||
endTime: 1609545600000,
|
||||
@@ -197,6 +172,7 @@ describe('EndPointDetails Component', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetNuqsState();
|
||||
|
||||
(extractPortAndEndpoint as jest.Mock).mockReturnValue({
|
||||
port: '8080',
|
||||
@@ -209,7 +185,6 @@ describe('EndPointDetails Component', () => {
|
||||
{ id: 'query3', label: 'Query 3' },
|
||||
{ id: 'query4', label: 'Query 4' },
|
||||
{ id: 'query5', label: 'Query 5' },
|
||||
{ id: 'query6', label: 'Query 6' },
|
||||
]);
|
||||
|
||||
(getRateOverTimeWidgetData as jest.Mock).mockReturnValue({
|
||||
@@ -226,7 +201,7 @@ describe('EndPointDetails Component', () => {
|
||||
});
|
||||
|
||||
it('renders the component correctly', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
// Check all major components are rendered
|
||||
expect(screen.getByTestId('query-builder-search')).toBeInTheDocument();
|
||||
@@ -246,44 +221,29 @@ describe('EndPointDetails Component', () => {
|
||||
});
|
||||
|
||||
it('calls getEndPointDetailsQueryPayload with correct parameters', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
|
||||
'test-domain',
|
||||
mockProps.timeRange.startTime,
|
||||
mockProps.timeRange.endTime,
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
|
||||
value: '/api/test',
|
||||
}),
|
||||
]),
|
||||
op: 'AND',
|
||||
}),
|
||||
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('adds endpoint filter to initial filters', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
|
||||
value: '/api/test',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
|
||||
);
|
||||
});
|
||||
|
||||
it('updates filters when QueryBuilderSearch changes', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
it('updates filters when the search expression is run', async () => {
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
// Trigger filter change
|
||||
fireEvent.click(screen.getByTestId('filter-change-button'));
|
||||
@@ -294,19 +254,37 @@ describe('EndPointDetails Component', () => {
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test' AND (test.key = 'test-value')`,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(getCurrentNuqsSearchParams().get(ENDPOINT_STATS_EXPRESSION_KEY)).toBe(
|
||||
"test.key = 'test-value'",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the endpoint out of the editor and the picker options', () => {
|
||||
renderStats(<EndPointDetails {...mockProps} />, "service.name = 'checkout'");
|
||||
|
||||
expect(QuerySearch).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'test.key' }),
|
||||
value: 'test-value',
|
||||
}),
|
||||
]),
|
||||
queryData: expect.objectContaining({
|
||||
filter: { expression: "service.name = 'checkout'" },
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(EndPointPicker).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
filterExpression: "service.name = 'checkout'",
|
||||
value: '/api/test',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles endpoint dropdown selection', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
// Trigger endpoint selection
|
||||
fireEvent.click(screen.getByTestId('select-endpoint-button'));
|
||||
@@ -318,63 +296,50 @@ describe('EndPointDetails Component', () => {
|
||||
});
|
||||
|
||||
it('does not display dependent services when service filter is applied', () => {
|
||||
const propsWithServiceFilter = {
|
||||
...mockProps,
|
||||
initialFilters: {
|
||||
items: [
|
||||
{
|
||||
id: 'service-filter',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: 'test-service',
|
||||
},
|
||||
] as TagFilterItem[],
|
||||
op: 'AND',
|
||||
} as TagFilter,
|
||||
};
|
||||
|
||||
render(<EndPointDetails {...propsWithServiceFilter} />);
|
||||
renderStats(
|
||||
<EndPointDetails {...mockProps} />,
|
||||
"service.name = 'test-service'",
|
||||
);
|
||||
|
||||
// Dependent services should not be displayed
|
||||
expect(screen.queryByTestId('dependent-services')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes the correct parameters to widget data generators', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
expect(getRateOverTimeWidgetData).toHaveBeenCalledWith(
|
||||
'test-domain',
|
||||
'/api/test',
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
|
||||
value: '/api/test',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
|
||||
);
|
||||
|
||||
expect(getLatencyOverTimeWidgetData).toHaveBeenCalledWith(
|
||||
'test-domain',
|
||||
'/api/test',
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.HTTP_URL }),
|
||||
value: '/api/test',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
expect.stringContaining(`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/test'`),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the previous dependent services while a new filter loads', () => {
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
const queries = jest.mocked(useQueries).mock.calls[0][0];
|
||||
expect(
|
||||
queries.map(({ queryKey, keepPreviousData }) => [
|
||||
(queryKey as unknown[])[0],
|
||||
keepPreviousData,
|
||||
]),
|
||||
).toStrictEqual(
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY.map((key) => [
|
||||
key,
|
||||
key === REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates correct query parameters for useQueries', () => {
|
||||
render(<EndPointDetails {...mockProps} />);
|
||||
renderStats(<EndPointDetails {...mockProps} />);
|
||||
|
||||
// Check if useQueries was called with correct parameters
|
||||
expect(useQueries).toHaveBeenCalledWith(
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
GetMetricQueryRange,
|
||||
GetQueryResultsProps,
|
||||
} from 'lib/dashboard/getQueryResults';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import EndPointPicker from '../Explorer/Domains/DomainDetails/components/EndPointPicker';
|
||||
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
|
||||
|
||||
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||
GetMetricQueryRange: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockGetMetricQueryRange = jest.mocked(GetMetricQueryRange);
|
||||
|
||||
const rowsResponse = (urls: string[]): MetricQueryRangeSuccessResponse => ({
|
||||
statusCode: 200,
|
||||
message: 'success',
|
||||
error: null,
|
||||
payload: {
|
||||
data: {
|
||||
resultType: '',
|
||||
newResult: { data: { result: [], resultType: '' } },
|
||||
result: [
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'A',
|
||||
values: [],
|
||||
table: {
|
||||
columns: [],
|
||||
rows: urls.map((url) => ({
|
||||
data: { [SPAN_ATTRIBUTES.HTTP_URL]: url, A: 1 },
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
domainName: 'api.example.com',
|
||||
timeRange: { startTime: 1000, endTime: 2000 },
|
||||
filterExpression: "service.name = 'checkout'",
|
||||
value: '',
|
||||
};
|
||||
|
||||
describe('EndPointPicker', () => {
|
||||
beforeEach(() => {
|
||||
mockGetMetricQueryRange.mockResolvedValue(
|
||||
rowsResponse(['/v1/charges', '/v1/refunds']),
|
||||
);
|
||||
});
|
||||
|
||||
it('loads options for the domain scoped by the user filter', async () => {
|
||||
render(<EndPointPicker {...baseProps} onChange={jest.fn()} />);
|
||||
|
||||
await waitFor(() => expect(mockGetMetricQueryRange).toHaveBeenCalled());
|
||||
const payload: GetQueryResultsProps =
|
||||
mockGetMetricQueryRange.mock.calls[0][0];
|
||||
expect(payload.query.builder.queryData[0].filter?.expression).toBe(
|
||||
`http_host = 'api.example.com' AND kind_string = 'Client' AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS AND (service.name = 'checkout')`,
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the picked endpoint', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const onChange = jest.fn() as jest.MockedFunction<
|
||||
(endPointName: string) => void
|
||||
>;
|
||||
render(<EndPointPicker {...baseProps} onChange={onChange} />);
|
||||
|
||||
await waitFor(() => expect(mockGetMetricQueryRange).toHaveBeenCalled());
|
||||
await user.click(screen.getByTestId('endpoint-picker'));
|
||||
await user.click(await screen.findByText('/v1/refunds'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('/v1/refunds');
|
||||
});
|
||||
|
||||
it('shows All endpoints when none is picked and clears through it', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const onChange = jest.fn() as jest.MockedFunction<
|
||||
(endPointName: string) => void
|
||||
>;
|
||||
const { rerender } = render(
|
||||
<EndPointPicker {...baseProps} onChange={onChange} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('endpoint-picker')).toHaveTextContent(
|
||||
'All endpoints',
|
||||
);
|
||||
|
||||
rerender(
|
||||
<EndPointPicker {...baseProps} value="/v1/charges" onChange={onChange} />,
|
||||
);
|
||||
await waitFor(() => expect(mockGetMetricQueryRange).toHaveBeenCalled());
|
||||
await user.click(screen.getByTestId('endpoint-picker'));
|
||||
await user.click(
|
||||
await screen.findByRole('option', { name: 'All endpoints' }),
|
||||
);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('shows the selected endpoint', async () => {
|
||||
render(
|
||||
<EndPointPicker {...baseProps} value="/v1/charges" onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('/v1/charges')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,210 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { getFormattedEndPointDropDownData } from 'container/ApiMonitoring/utils';
|
||||
|
||||
import EndPointsDropDown from '../Explorer/Domains/DomainDetails/components/EndPointsDropDown';
|
||||
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
|
||||
|
||||
// Mock the Select component from antd
|
||||
jest.mock('antd', () => {
|
||||
const originalModule = jest.requireActual('antd');
|
||||
return {
|
||||
...originalModule,
|
||||
Select: jest
|
||||
.fn()
|
||||
.mockImplementation(({ value, loading, onChange, options, onClear }) => (
|
||||
<div data-testid="mock-select">
|
||||
<div data-testid="select-value">{value}</div>
|
||||
<div data-testid="select-loading">
|
||||
{loading ? 'loading' : 'not-loading'}
|
||||
</div>
|
||||
<select
|
||||
data-testid="select-element"
|
||||
value={value || ''}
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{options?.map((option: { value: string; label: string; key: string }) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button data-testid="select-clear-button" type="button" onClick={onClear}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the utilities
|
||||
jest.mock('container/ApiMonitoring/utils', () => ({
|
||||
getFormattedEndPointDropDownData: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('EndPointsDropDown Component', () => {
|
||||
const mockEndPoints = [
|
||||
{ key: '1', value: '/api/endpoint1', label: '/api/endpoint1' },
|
||||
{ key: '2', value: '/api/endpoint2', label: '/api/endpoint2' },
|
||||
];
|
||||
|
||||
const mockSetSelectedEndPointName = jest.fn();
|
||||
|
||||
// Create a mock that satisfies the UseQueryResult interface
|
||||
const createMockQueryResult = (overrides: any = {}): any => ({
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
table: {
|
||||
rows: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
dataUpdatedAt: 0,
|
||||
error: null,
|
||||
errorUpdatedAt: 0,
|
||||
failureCount: 0,
|
||||
isError: false,
|
||||
isFetched: true,
|
||||
isFetchedAfterMount: true,
|
||||
isFetching: false,
|
||||
isIdle: false,
|
||||
isLoading: false,
|
||||
isLoadingError: false,
|
||||
isPlaceholderData: false,
|
||||
isPreviousData: false,
|
||||
isRefetchError: false,
|
||||
isRefetching: false,
|
||||
isStale: false,
|
||||
isSuccess: true,
|
||||
refetch: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
status: 'success',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
selectedEndPointName: '',
|
||||
setSelectedEndPointName: mockSetSelectedEndPointName,
|
||||
endPointDropDownDataQuery: createMockQueryResult(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(getFormattedEndPointDropDownData as jest.Mock).mockReturnValue(
|
||||
mockEndPoints,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the component correctly', () => {
|
||||
render(<EndPointsDropDown {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId('mock-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('select-loading')).toHaveTextContent('not-loading');
|
||||
});
|
||||
|
||||
it('shows loading state when data is loading', () => {
|
||||
const loadingProps = {
|
||||
...defaultProps,
|
||||
endPointDropDownDataQuery: createMockQueryResult({
|
||||
isLoading: true,
|
||||
}),
|
||||
};
|
||||
|
||||
render(<EndPointsDropDown {...loadingProps} />);
|
||||
|
||||
expect(screen.getByTestId('select-loading')).toHaveTextContent('loading');
|
||||
});
|
||||
|
||||
it('shows loading state when data is fetching', () => {
|
||||
const fetchingProps = {
|
||||
...defaultProps,
|
||||
endPointDropDownDataQuery: createMockQueryResult({
|
||||
isFetching: true,
|
||||
}),
|
||||
};
|
||||
|
||||
render(<EndPointsDropDown {...fetchingProps} />);
|
||||
|
||||
expect(screen.getByTestId('select-loading')).toHaveTextContent('loading');
|
||||
});
|
||||
|
||||
it('displays the selected endpoint', () => {
|
||||
const selectedProps = {
|
||||
...defaultProps,
|
||||
selectedEndPointName: '/api/endpoint1',
|
||||
};
|
||||
|
||||
render(<EndPointsDropDown {...selectedProps} />);
|
||||
|
||||
expect(screen.getByTestId('select-value')).toHaveTextContent(
|
||||
'/api/endpoint1',
|
||||
);
|
||||
});
|
||||
|
||||
it('calls setSelectedEndPointName when an option is selected', () => {
|
||||
render(<EndPointsDropDown {...defaultProps} />);
|
||||
|
||||
// Get the select element and change its value
|
||||
const selectElement = screen.getByTestId('select-element');
|
||||
fireEvent.change(selectElement, { target: { value: '/api/endpoint2' } });
|
||||
|
||||
expect(mockSetSelectedEndPointName).toHaveBeenCalledWith('/api/endpoint2');
|
||||
});
|
||||
|
||||
it('calls setSelectedEndPointName with empty string when cleared', () => {
|
||||
render(<EndPointsDropDown {...defaultProps} />);
|
||||
|
||||
// Click the clear button
|
||||
const clearButton = screen.getByTestId('select-clear-button');
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(mockSetSelectedEndPointName).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('passes dropdown style prop correctly', () => {
|
||||
const styleProps = {
|
||||
...defaultProps,
|
||||
dropdownStyle: { maxHeight: '200px' },
|
||||
};
|
||||
|
||||
render(<EndPointsDropDown {...styleProps} />);
|
||||
|
||||
// We can't easily test style props in our mock, but at least ensure the component rendered
|
||||
expect(screen.getByTestId('mock-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats data using the utility function', () => {
|
||||
const mockRows = [
|
||||
{ data: { [SPAN_ATTRIBUTES.HTTP_URL]: '/api/test', A: 10 } },
|
||||
];
|
||||
|
||||
const dataProps = {
|
||||
...defaultProps,
|
||||
endPointDropDownDataQuery: createMockQueryResult({
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
table: {
|
||||
rows: mockRows,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
render(<EndPointsDropDown {...dataProps} />);
|
||||
|
||||
expect(getFormattedEndPointDropDownData).toHaveBeenCalledWith(mockRows);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* V5 Migration Tests for Endpoint Dropdown Query
|
||||
*
|
||||
* These tests validate the migration from V4 to V5 format for the third payload
|
||||
* in getEndPointDetailsQueryPayload (endpoint dropdown data):
|
||||
* These tests validate the migration from V4 to V5 format for
|
||||
* getEndPointDropDownQueryPayload (endpoint picker options):
|
||||
* - Filter format change: filters.items[] → filter.expression
|
||||
* - Domain handling: http_host = '${domainName}'
|
||||
* - Kind filter: kind_string = 'Client'
|
||||
@@ -10,9 +10,8 @@
|
||||
* - Aggregation: count() expression
|
||||
* - GroupBy: http_url with type 'attribute'
|
||||
*/
|
||||
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
|
||||
import { getEndPointDropDownQueryPayload } from 'container/ApiMonitoring/utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
|
||||
|
||||
@@ -20,22 +19,16 @@ describe('EndpointDropdown - V5 Migration Validation', () => {
|
||||
const mockDomainName = 'api.example.com';
|
||||
const mockStartTime = 1000;
|
||||
const mockEndTime = 2000;
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyFilters = '';
|
||||
|
||||
describe('1. V5 Format Migration - Structure and Base Filters', () => {
|
||||
it('migrates to V5 format with correct filter expression structure, aggregations, and groupBy', () => {
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
const dropdownQuery = getEndPointDropDownQueryPayload(
|
||||
mockDomainName,
|
||||
mockStartTime,
|
||||
mockEndTime,
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
// Third payload is the endpoint dropdown query (index 2)
|
||||
const dropdownQuery = payload[2];
|
||||
const queryA = dropdownQuery.query.builder.queryData[0];
|
||||
|
||||
// CRITICAL V5 MIGRATION: filter.expression (not filters.items)
|
||||
@@ -77,46 +70,21 @@ describe('EndpointDropdown - V5 Migration Validation', () => {
|
||||
|
||||
describe('2. Custom Filters Integration', () => {
|
||||
it('merges custom filters into filter expression with AND logic', () => {
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
key: {
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'production',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
const customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
const dropdownQuery = getEndPointDropDownQueryPayload(
|
||||
mockDomainName,
|
||||
mockStartTime,
|
||||
mockEndTime,
|
||||
customFilters,
|
||||
);
|
||||
|
||||
const dropdownQuery = payload[2];
|
||||
const expression =
|
||||
dropdownQuery.query.builder.queryData[0].filter?.expression;
|
||||
|
||||
// Exact filter expression with custom filters merged
|
||||
expect(expression).toBe(
|
||||
`${SPAN_ATTRIBUTES.SERVER_NAME} = 'api.example.com' AND kind_string = 'Client' AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS service.name = 'user-service' AND deployment.environment = 'production'`,
|
||||
`${SPAN_ATTRIBUTES.SERVER_NAME} = 'api.example.com' AND kind_string = 'Client' AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS AND (service.name = 'user-service' AND deployment.environment = 'production')`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { QuerySearchV2Provider } from 'components/QueryBuilderV2';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { onNuqsUrlUpdate } from 'tests/nuqs-helpers';
|
||||
|
||||
/** Mounts a drawer tab's filter provider over an in-memory URL. */
|
||||
export function FilterProviders({
|
||||
queryParamKey,
|
||||
initialExpression,
|
||||
searchParams = '',
|
||||
children,
|
||||
}: {
|
||||
queryParamKey: string;
|
||||
initialExpression?: string;
|
||||
searchParams?: string;
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<NuqsTestingAdapter
|
||||
searchParams={searchParams}
|
||||
onUrlUpdate={onNuqsUrlUpdate}
|
||||
rateLimitFactor={0}
|
||||
hasMemory
|
||||
>
|
||||
<QuerySearchV2Provider
|
||||
queryParamKey={queryParamKey}
|
||||
initialExpression={initialExpression}
|
||||
persistOnUnmount
|
||||
>
|
||||
{children}
|
||||
</QuerySearchV2Provider>
|
||||
</NuqsTestingAdapter>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,11 @@ import {
|
||||
getLatencyOverTimeWidgetData,
|
||||
getRateOverTimeWidgetData,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
describe('MetricOverTime - V5 Migration Validation', () => {
|
||||
const mockDomainName = 'api.example.com';
|
||||
const mockEndpointName = '/api/users';
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyFilters = '';
|
||||
|
||||
describe('1. Rate Over Time - V5 Payload Structure', () => {
|
||||
it('generates V5 filter expression format (not V3 filters.items)', () => {
|
||||
@@ -51,31 +46,8 @@ describe('MetricOverTime - V5 Migration Validation', () => {
|
||||
});
|
||||
|
||||
it('merges custom filters into filter expression', () => {
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
key: {
|
||||
key: 'deployment.environment',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'production',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
const customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
|
||||
const widget = getRateOverTimeWidgetData(
|
||||
mockDomainName,
|
||||
@@ -137,21 +109,8 @@ describe('MetricOverTime - V5 Migration Validation', () => {
|
||||
});
|
||||
|
||||
it('merges custom filters into filter expression', () => {
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
const customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
|
||||
const widget = getLatencyOverTimeWidgetData(
|
||||
mockDomainName,
|
||||
@@ -163,7 +122,7 @@ describe('MetricOverTime - V5 Migration Validation', () => {
|
||||
|
||||
// Verify domain filter is present
|
||||
expect(queryData?.filter?.expression).toContain(
|
||||
`http_host = '${mockDomainName}' service.name = 'user-service'`,
|
||||
`http_host = '${mockDomainName}' AND (service.name = 'user-service'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
getStatusCodeBarChartWidgetData,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import ErrorState from '../Explorer/Domains/DomainDetails/components/ErrorState';
|
||||
import StatusCodeBarCharts from '../Explorer/Domains/DomainDetails/components/StatusCodeBarCharts';
|
||||
@@ -156,7 +155,6 @@ jest.mock('antd', () => {
|
||||
|
||||
describe('StatusCodeBarCharts', () => {
|
||||
// Default props for tests
|
||||
const mockFilters: IBuilderQuery['filters'] = { items: [], op: 'AND' };
|
||||
const mockTimeRange = {
|
||||
startTime: 1609459200000,
|
||||
endTime: 1609545600000,
|
||||
@@ -251,7 +249,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockFilters}
|
||||
filterExpression=""
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -286,7 +284,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockFilters}
|
||||
filterExpression=""
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -328,7 +326,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockFilters}
|
||||
filterExpression=""
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -372,7 +370,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockFilters}
|
||||
filterExpression=""
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -419,7 +417,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockFilters}
|
||||
filterExpression=""
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -433,26 +431,13 @@ describe('StatusCodeBarCharts', () => {
|
||||
// but we've confirmed the function is mocked and ready to be tested
|
||||
expect(getStatusCodeBarChartWidgetData).toHaveBeenCalledWith(
|
||||
mockDomainName,
|
||||
expect.objectContaining({
|
||||
items: [],
|
||||
op: 'AND',
|
||||
}),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles widget generation with current filters', () => {
|
||||
// Arrange
|
||||
const mockCustomFilters = {
|
||||
items: [
|
||||
{
|
||||
id: 'custom-filter',
|
||||
key: { key: 'test-key' },
|
||||
op: '=',
|
||||
value: 'test-value',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
const mockCustomFilterExpression = "test-key = 'test-value'";
|
||||
|
||||
const mockData = {
|
||||
payload: mockFormattedData,
|
||||
@@ -480,7 +465,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
|
||||
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
|
||||
domainName={mockDomainName}
|
||||
filters={mockCustomFilters as IBuilderQuery['filters']}
|
||||
filterExpression={mockCustomFilterExpression}
|
||||
timeRange={mockTimeRange}
|
||||
onDragSelect={onDragSelectMock}
|
||||
/>,
|
||||
@@ -489,12 +474,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
// Assert widget creation was called with the correct parameters
|
||||
expect(getStatusCodeBarChartWidgetData).toHaveBeenCalledWith(
|
||||
mockDomainName,
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'custom-filter' }),
|
||||
]),
|
||||
op: 'AND',
|
||||
}),
|
||||
mockCustomFilterExpression,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* V5 Migration Tests for Status Code Bar Chart Queries
|
||||
*
|
||||
* These tests validate the migration to V5 format for the bar chart payloads
|
||||
* in getEndPointDetailsQueryPayload (5th and 6th payloads):
|
||||
* in getEndPointDetailsQueryPayload (4th and 5th payloads):
|
||||
* - Number of Calls Chart (count aggregation)
|
||||
* - Latency Chart (p99 aggregation)
|
||||
*
|
||||
@@ -15,16 +15,12 @@
|
||||
*/
|
||||
import { TraceAggregation } from 'api/v5/v5';
|
||||
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
const mockDomainName = '0.0.0.0';
|
||||
const mockStartTime = 1762573673000;
|
||||
const mockEndTime = 1762832873000;
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyFilters = '';
|
||||
|
||||
describe('1. Number of Calls Chart - V5 Payload Structure', () => {
|
||||
it('generates correct V5 payload for count aggregation grouped by status code', () => {
|
||||
@@ -35,8 +31,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
// 5th payload (index 4) is the number of calls bar chart
|
||||
const callsChartQuery = payload[4];
|
||||
// 4th payload (index 3) is the number of calls bar chart
|
||||
const callsChartQuery = payload[3];
|
||||
const queryA = callsChartQuery.query.builder.queryData[0];
|
||||
|
||||
// V5 format: filter.expression (not filters.items)
|
||||
@@ -84,8 +80,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
emptyFilters,
|
||||
);
|
||||
|
||||
// 6th payload (index 5) is the latency bar chart
|
||||
const latencyChartQuery = payload[5];
|
||||
// 5th payload (index 4) is the latency bar chart
|
||||
const latencyChartQuery = payload[4];
|
||||
const queryA = latencyChartQuery.query.builder.queryData[0];
|
||||
|
||||
// V5 format: filter.expression (not filters.items)
|
||||
@@ -130,31 +126,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
|
||||
describe('3. Custom Filters Integration', () => {
|
||||
it('merges custom filters into filter expression for both charts', () => {
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
key: {
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'production',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
const customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
@@ -163,8 +136,8 @@ describe('StatusCodeBarCharts - V5 Migration Validation', () => {
|
||||
customFilters,
|
||||
);
|
||||
|
||||
const callsChartQuery = payload[4];
|
||||
const latencyChartQuery = payload[5];
|
||||
const callsChartQuery = payload[3];
|
||||
const latencyChartQuery = payload[4];
|
||||
|
||||
const callsExpression =
|
||||
callsChartQuery.query.builder.queryData[0].filter?.expression;
|
||||
|
||||
@@ -13,16 +13,12 @@
|
||||
*/
|
||||
import { TraceAggregation } from 'api/v5/v5';
|
||||
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
describe('StatusCodeTable - V5 Migration Validation', () => {
|
||||
const mockDomainName = 'api.example.com';
|
||||
const mockStartTime = 1000;
|
||||
const mockEndTime = 2000;
|
||||
const emptyFilters: IBuilderQuery['filters'] = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
const emptyFilters = '';
|
||||
|
||||
describe('1. V5 Format Migration with Base Filters', () => {
|
||||
it('migrates to V5 format with correct filter expression structure and base filters', () => {
|
||||
@@ -109,31 +105,8 @@ describe('StatusCodeTable - V5 Migration Validation', () => {
|
||||
|
||||
describe('3. Custom Filters Integration', () => {
|
||||
it('merges custom filters into filter expression with AND logic', () => {
|
||||
const customFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-1',
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'user-service',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
key: {
|
||||
key: 'deployment.environment',
|
||||
dataType: 'string' as any,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value: 'production',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
const customFilters =
|
||||
"service.name = 'user-service' AND deployment.environment = 'production'";
|
||||
|
||||
const payload = getEndPointDetailsQueryPayload(
|
||||
mockDomainName,
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
import { BuilderQuery } from 'api/v5/v5';
|
||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
|
||||
import TopErrors from '../Explorer/Domains/DomainDetails/TopErrors';
|
||||
import { getTopErrorsQueryPayload } from '../utils';
|
||||
|
||||
// Mock the EndPointsDropDown component to avoid issues
|
||||
interface QueryRangeRequestBody {
|
||||
compositeQuery: { queries: { spec: { filter: { expression: string } } }[] };
|
||||
}
|
||||
|
||||
// Mock the EndPointPicker component to avoid issues
|
||||
jest.mock(
|
||||
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
|
||||
'../Explorer/Domains/DomainDetails/components/EndPointPicker',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: jest
|
||||
.fn()
|
||||
.mockImplementation(({ setSelectedEndPointName }): JSX.Element => (
|
||||
<div data-testid="endpoints-dropdown-mock">
|
||||
<select
|
||||
data-testid="endpoints-select"
|
||||
onChange={(e): void => setSelectedEndPointName(e.target.value)}
|
||||
>
|
||||
<option value="/api/test">/api/test</option>
|
||||
<option value="/api/new-endpoint">/api/new-endpoint</option>
|
||||
</select>
|
||||
</div>
|
||||
)),
|
||||
default: jest.fn().mockImplementation(({ onChange }): JSX.Element => (
|
||||
<div data-testid="endpoints-dropdown-mock">
|
||||
<select
|
||||
data-testid="endpoints-select"
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
>
|
||||
<option value="/api/test">/api/test</option>
|
||||
<option value="/api/new-endpoint">/api/new-endpoint</option>
|
||||
</select>
|
||||
</div>
|
||||
)),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -43,10 +52,7 @@ describe('TopErrors', () => {
|
||||
startTime: 1000000000,
|
||||
endTime: 1000010000,
|
||||
},
|
||||
initialFilters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
initialFilterExpression: '',
|
||||
};
|
||||
|
||||
// Helper function to wait for table data to load
|
||||
@@ -227,22 +233,30 @@ describe('TopErrors', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates endpoint filter when dropdown value changes', async () => {
|
||||
it('scopes the query to the endpoint picked in the picker', async () => {
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const expressions: string[] = [];
|
||||
server.use(
|
||||
rest.post(V5_QUERY_RANGE_API_PATH, async (req) => {
|
||||
const body: QueryRangeRequestBody = await req.json();
|
||||
expressions.push(body.compositeQuery.queries[0].spec.filter.expression);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TopErrors {...mockProps} />);
|
||||
await waitFor(() => expect(expressions).toHaveLength(1));
|
||||
expect(expressions[0]).not.toContain(`${SPAN_ATTRIBUTES.HTTP_URL} =`);
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByTestId('endpoints-select'),
|
||||
'/api/new-endpoint',
|
||||
);
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(expressions[expressions.length - 1]).toContain(
|
||||
`${SPAN_ATTRIBUTES.HTTP_URL} = '/api/new-endpoint'`,
|
||||
);
|
||||
});
|
||||
|
||||
// Find the dropdown
|
||||
const dropdown = screen.getByRole('combobox');
|
||||
|
||||
// Mock the change
|
||||
fireEvent.change(dropdown, { target: { value: '/api/new-endpoint' } });
|
||||
|
||||
// Component should re-render with new filter
|
||||
expect(dropdown).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles status message toggle correctly', async () => {
|
||||
@@ -306,7 +320,7 @@ describe('TopErrors', () => {
|
||||
'test-domain',
|
||||
mockProps.timeRange.startTime,
|
||||
mockProps.timeRange.endTime,
|
||||
{ items: [], op: 'AND' },
|
||||
'',
|
||||
false,
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ApiMonitoringParams,
|
||||
DEFAULT_PARAMS,
|
||||
getApiMonitoringParams,
|
||||
getLegacyFilterExpressions,
|
||||
setApiMonitoringParams,
|
||||
} from 'container/ApiMonitoring/queryParams';
|
||||
import { mockNavigate } from 'tests/router-mocks';
|
||||
@@ -52,6 +53,50 @@ describe('API Monitoring Query Params', () => {
|
||||
|
||||
expect(getApiMonitoringParams(search)).toStrictEqual(DEFAULT_PARAMS);
|
||||
});
|
||||
|
||||
it('converts filter items saved by older links into an expression', () => {
|
||||
const legacyParams = {
|
||||
selectedDomain: 'test-domain',
|
||||
allEndpointsLocalFilters: {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: 'a',
|
||||
key: { key: 'service.name', dataType: 'string', type: 'resource' },
|
||||
op: '=',
|
||||
value: 'frontend',
|
||||
},
|
||||
],
|
||||
},
|
||||
endPointDetailsLocalFilters: {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: 'b',
|
||||
key: { key: 'http.status_code', dataType: 'string', type: '' },
|
||||
op: '=',
|
||||
value: '500',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set(
|
||||
'apiMonitoringParams',
|
||||
encodeURIComponent(JSON.stringify(legacyParams)),
|
||||
);
|
||||
|
||||
const search = `?${urlParams.toString()}`;
|
||||
|
||||
expect(getLegacyFilterExpressions(search)).toStrictEqual({
|
||||
allEndpoints: "service.name = 'frontend'",
|
||||
endPointDetails: "http.status_code = '500'",
|
||||
});
|
||||
const result = getApiMonitoringParams(search);
|
||||
expect(result.selectedDomain).toBe('test-domain');
|
||||
expect(result).not.toHaveProperty('allEndpointsLocalFilters');
|
||||
expect(result).not.toHaveProperty('endPointDetailsLocalFilters');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setApiMonitoringParams', () => {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ALL_ENDPOINTS_EXPRESSION_KEY } from 'container/ApiMonitoring/filterParamKeys';
|
||||
import {
|
||||
getApiMonitoringParams,
|
||||
useApiMonitoringParams,
|
||||
} from 'container/ApiMonitoring/queryParams';
|
||||
import { mockAppLocation, mockNavigate } from 'tests/router-mocks';
|
||||
import { act, renderHook } from 'tests/test-utils';
|
||||
|
||||
// The stub writes to the browser URL, which the hook reads back; the router's
|
||||
// own location (`useAppLocation`) stays at its first value.
|
||||
function renderParamsHook(): ReturnType<
|
||||
typeof renderHook<ReturnType<typeof useApiMonitoringParams>, unknown>
|
||||
> {
|
||||
mockNavigate().mockImplementation((to) => {
|
||||
const url = typeof to === 'string' ? to : `/${to.search ?? ''}`;
|
||||
window.history.pushState(null, '', url);
|
||||
});
|
||||
mockAppLocation();
|
||||
return renderHook(() => useApiMonitoringParams());
|
||||
}
|
||||
|
||||
describe('useApiMonitoringParams', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
it('keeps every write made before the next render', () => {
|
||||
const { result } = renderParamsHook();
|
||||
|
||||
act(() => {
|
||||
const [, setParams] = result.current;
|
||||
setParams({ selectedView: 'endpoint_stats' });
|
||||
setParams({ selectedInterval: '15m' });
|
||||
});
|
||||
|
||||
expect(getApiMonitoringParams(window.location.search)).toMatchObject({
|
||||
selectedView: 'endpoint_stats',
|
||||
selectedInterval: '15m',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a drawer filter written outside the router', () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`/?${ALL_ENDPOINTS_EXPRESSION_KEY}=${encodeURIComponent(
|
||||
"service.name = 'cart'",
|
||||
)}`,
|
||||
);
|
||||
const { result } = renderParamsHook();
|
||||
|
||||
act(() => {
|
||||
const [, setParams] = result.current;
|
||||
setParams({ selectedView: 'endpoint_stats' });
|
||||
});
|
||||
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
expect(search.get(ALL_ENDPOINTS_EXPRESSION_KEY)).toBe(
|
||||
"service.name = 'cart'",
|
||||
);
|
||||
expect(getApiMonitoringParams(window.location.search).selectedView).toBe(
|
||||
'endpoint_stats',
|
||||
);
|
||||
});
|
||||
});
|
||||
5
frontend/src/container/ApiMonitoring/filterParamKeys.ts
Normal file
5
frontend/src/container/ApiMonitoring/filterParamKeys.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// The drawer filters live in their own URL params, owned by `QuerySearchV2Provider`.
|
||||
export const ALL_ENDPOINTS_EXPRESSION_KEY =
|
||||
'apiMonitoringAllEndpointsExpression';
|
||||
export const ENDPOINT_STATS_EXPRESSION_KEY =
|
||||
'apiMonitoringEndpointStatsExpression';
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { omit } from 'lodash-es';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
// --- Types for all API Monitoring query params ---
|
||||
export interface ApiMonitoringParams {
|
||||
@@ -9,8 +13,6 @@ export interface ApiMonitoringParams {
|
||||
selectedView?: string;
|
||||
selectedEndPointName?: string;
|
||||
groupBy?: string[];
|
||||
allEndpointsLocalFilters?: any;
|
||||
endPointDetailsLocalFilters?: any;
|
||||
modalTimeRange?: { startTime: number; endTime: number };
|
||||
selectedInterval?: string;
|
||||
// Add more params as needed
|
||||
@@ -22,8 +24,6 @@ export const DEFAULT_PARAMS: ApiMonitoringParams = {
|
||||
selectedView: 'all_endpoints',
|
||||
selectedEndPointName: '',
|
||||
groupBy: [],
|
||||
allEndpointsLocalFilters: undefined,
|
||||
endPointDetailsLocalFilters: undefined,
|
||||
modalTimeRange: undefined,
|
||||
selectedInterval: undefined,
|
||||
};
|
||||
@@ -35,15 +35,50 @@ function encodeParams(params: ApiMonitoringParams): string {
|
||||
return encodeURIComponent(JSON.stringify(params));
|
||||
}
|
||||
|
||||
function decodeParams(value: string | null): ApiMonitoringParams {
|
||||
// Older shared links carry the filters as TagFilter items.
|
||||
type LegacyApiMonitoringParams = ApiMonitoringParams & {
|
||||
allEndpointsLocalFilters?: TagFilter;
|
||||
endPointDetailsLocalFilters?: TagFilter;
|
||||
};
|
||||
|
||||
function parseParams(value: string | null): LegacyApiMonitoringParams | null {
|
||||
if (!value) {
|
||||
return DEFAULT_PARAMS;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Drops the legacy filters, so the next write leaves them out of the URL.
|
||||
function decodeParams(value: string | null): ApiMonitoringParams {
|
||||
const parsed = parseParams(value);
|
||||
if (!parsed) {
|
||||
return DEFAULT_PARAMS;
|
||||
}
|
||||
return omit(parsed, [
|
||||
'allEndpointsLocalFilters',
|
||||
'endPointDetailsLocalFilters',
|
||||
]);
|
||||
}
|
||||
|
||||
export interface LegacyFilterExpressions {
|
||||
allEndpoints?: string;
|
||||
endPointDetails?: string;
|
||||
}
|
||||
|
||||
export function getLegacyFilterExpressions(
|
||||
search: string,
|
||||
): LegacyFilterExpressions {
|
||||
const parsed = parseParams(new URLSearchParams(search).get(PARAM_KEY));
|
||||
const toExpression = (filters?: TagFilter): string | undefined =>
|
||||
filters ? convertFiltersToExpression(filters).expression : undefined;
|
||||
return {
|
||||
allEndpoints: toExpression(parsed?.allEndpointsLocalFilters),
|
||||
endPointDetails: toExpression(parsed?.endPointDetailsLocalFilters),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Read query params from URL ---
|
||||
@@ -74,11 +109,18 @@ export function useApiMonitoringParams(): [
|
||||
const location = useAppLocation();
|
||||
const params = getApiMonitoringParams(location.search);
|
||||
|
||||
// Read the browser's URL, not the router's: several writes can land before
|
||||
// the next render, and the drawer filters are written by nuqs, which the
|
||||
// router's location does not see.
|
||||
const setParams = useCallback(
|
||||
(newParams: Partial<ApiMonitoringParams>, replace = false) => {
|
||||
setApiMonitoringParams(newParams, location.search, replace);
|
||||
setApiMonitoringParams(
|
||||
newParams,
|
||||
`?${getUnstableCurrentSearchParams().toString()}`,
|
||||
replace,
|
||||
);
|
||||
},
|
||||
[location.search],
|
||||
[],
|
||||
);
|
||||
|
||||
return [params, setParams];
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Color } from '@signozhq/design-tokens';
|
||||
import { TableColumnType as ColumnType, Tooltip } from 'antd';
|
||||
import { Progress } from '@signozhq/ui/progress';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
FiltersType,
|
||||
IQuickFiltersConfig,
|
||||
@@ -56,30 +57,28 @@ export const getDisplayValue = (value: unknown): string =>
|
||||
isEmptyFilterValue(value) ? '-' : String(value);
|
||||
|
||||
export const getDomainNameFilterExpression = (domainName: string): string =>
|
||||
`http_host = '${domainName}'`;
|
||||
`${SPAN_ATTRIBUTES.SERVER_NAME} = ${formatValueForExpression(domainName)}`;
|
||||
|
||||
export const clientKindExpression = `kind_string = 'Client'`;
|
||||
export const clientKindExpression = "kind_string = 'Client'";
|
||||
|
||||
/**
|
||||
* Converts filters to expression
|
||||
* @param filters Filters to convert
|
||||
* @param baseExpression Base expression to combine with filters
|
||||
* @returns Filter expression string
|
||||
*/
|
||||
export const convertFiltersWithUrlHandling = (
|
||||
filters: IBuilderQuery['filters'],
|
||||
baseExpression: string,
|
||||
): string => {
|
||||
if (!filters) {
|
||||
return baseExpression;
|
||||
}
|
||||
export const getHttpUrlExpression = (endPointName: string): string =>
|
||||
endPointName
|
||||
? `${SPAN_ATTRIBUTES.HTTP_URL} = ${formatValueForExpression(endPointName, '=')}`
|
||||
: '';
|
||||
|
||||
const { filter } = convertFiltersToExpressionWithExistingQuery(
|
||||
filters,
|
||||
baseExpression,
|
||||
);
|
||||
return filter.expression;
|
||||
};
|
||||
// The domain (and endpoint) the drawer is locked to, built from the same
|
||||
// expressions as the requests' filters so the chip shows what runs.
|
||||
export const getDomainScopeExpression = (
|
||||
domainName: string,
|
||||
endPointName = '',
|
||||
): string =>
|
||||
[
|
||||
clientKindExpression,
|
||||
getDomainNameFilterExpression(domainName),
|
||||
getHttpUrlExpression(endPointName),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' AND ');
|
||||
|
||||
export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
{
|
||||
@@ -347,7 +346,7 @@ export const getDomainMetricsQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): GetQueryResultsProps[] => [
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
@@ -368,9 +367,9 @@ export const getDomainMetricsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(domainName)} AND ${urlExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'A',
|
||||
@@ -396,9 +395,9 @@ export const getDomainMetricsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(domainName)}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'B',
|
||||
@@ -424,9 +423,9 @@ export const getDomainMetricsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(domainName)} AND has_error = true`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'C',
|
||||
@@ -452,9 +451,9 @@ export const getDomainMetricsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(domainName)}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'D',
|
||||
@@ -824,7 +823,7 @@ export const getEndPointsQueryPayload = (
|
||||
|
||||
function buildFilterExpression(
|
||||
domainName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
showStatusCodeErrors: boolean,
|
||||
): string {
|
||||
const baseFilterParts = [
|
||||
@@ -836,21 +835,22 @@ function buildFilterExpression(
|
||||
if (showStatusCodeErrors) {
|
||||
baseFilterParts.push('status_message EXISTS');
|
||||
}
|
||||
const filterExpression = baseFilterParts.join(' AND ');
|
||||
|
||||
return convertFiltersWithUrlHandling(filters, filterExpression);
|
||||
return combineInitialAndUserExpression(
|
||||
baseFilterParts.join(' AND '),
|
||||
filterExpression,
|
||||
);
|
||||
}
|
||||
|
||||
export const getTopErrorsQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
showStatusCodeErrors = true,
|
||||
): QueryRangePayloadV5 => {
|
||||
const filterExpression = buildFilterExpression(
|
||||
const combinedExpression = buildFilterExpression(
|
||||
domainName,
|
||||
filters,
|
||||
filterExpression,
|
||||
showStatusCodeErrors,
|
||||
);
|
||||
|
||||
@@ -869,7 +869,7 @@ export const getTopErrorsQueryPayload = (
|
||||
stepInterval: 60,
|
||||
disabled: false,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
filter: { expression: filterExpression },
|
||||
filter: { expression: combinedExpression },
|
||||
groupBy: [
|
||||
{
|
||||
name: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
@@ -1367,15 +1367,14 @@ export const createFiltersForSelectedRowData = (
|
||||
|
||||
// First query payload for endpoint metrics
|
||||
// Second query payload for endpoint status code table
|
||||
// Third query payload for endpoint dropdown selection
|
||||
// Fourth query payload for endpoint dependant services
|
||||
// Fifth query payload for endpoint response status count bar chart
|
||||
// Sixth query payload for endpoint response status code latency bar chart
|
||||
// Third query payload for endpoint dependant services
|
||||
// Fourth query payload for endpoint response status count bar chart
|
||||
// Fifth query payload for endpoint response status code latency bar chart
|
||||
export const getEndPointDetailsQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): GetQueryResultsProps[] => [
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
@@ -1394,11 +1393,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1424,11 +1423,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1454,11 +1453,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: true,
|
||||
expression: 'C',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND has_error = true`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1484,11 +1483,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'D',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1514,11 +1513,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: true,
|
||||
expression: 'E',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1586,11 +1585,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1622,11 +1621,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1660,11 +1659,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND response_status_code EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
expression: 'C',
|
||||
@@ -1713,77 +1712,6 @@ export const getEndPointDetailsQueryPayload = (
|
||||
end,
|
||||
step: 60,
|
||||
},
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TABLE,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'count',
|
||||
aggregations: [
|
||||
{
|
||||
expression: 'count()',
|
||||
},
|
||||
],
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS`,
|
||||
),
|
||||
},
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: null,
|
||||
having: [],
|
||||
legend: '',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [
|
||||
{
|
||||
key: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
dataType: DataTypes.String,
|
||||
type: 'attribute',
|
||||
},
|
||||
],
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
|
||||
promql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
variables: {},
|
||||
formatForWeb: true,
|
||||
start,
|
||||
end,
|
||||
step: 60,
|
||||
},
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TABLE,
|
||||
@@ -1801,11 +1729,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1838,11 +1766,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1875,11 +1803,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'C',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1912,11 +1840,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: true,
|
||||
expression: 'D',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND has_error = true`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -1991,11 +1919,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2062,11 +1990,11 @@ export const getEndPointDetailsQueryPayload = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression}`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2118,100 +2046,82 @@ export const getEndPointDetailsQueryPayload = (
|
||||
},
|
||||
];
|
||||
|
||||
export const getEndPointZeroStateQueryPayload = (
|
||||
export const getEndPointDropDownQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): GetQueryResultsProps[] => [
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TABLE,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.String,
|
||||
key: '',
|
||||
type: '',
|
||||
filterExpression: string,
|
||||
): GetQueryResultsProps => ({
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TABLE,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'count',
|
||||
aggregations: [
|
||||
{
|
||||
expression: 'count()',
|
||||
},
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: '3db61dd6',
|
||||
key: {
|
||||
key: SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
},
|
||||
{
|
||||
id: '212678b9',
|
||||
key: {
|
||||
key: 'kind_string',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: 'Client',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
legend: '',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [
|
||||
{
|
||||
key: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
],
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
],
|
||||
timeAggregation: 'count',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND ${SPAN_ATTRIBUTES.HTTP_URL} EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [
|
||||
{
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: null,
|
||||
having: [],
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [
|
||||
{
|
||||
key: SPAN_ATTRIBUTES.HTTP_URL,
|
||||
dataType: DataTypes.String,
|
||||
type: 'attribute',
|
||||
},
|
||||
],
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
],
|
||||
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
|
||||
promql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
variables: {},
|
||||
formatForWeb: true,
|
||||
start,
|
||||
end,
|
||||
step: 60,
|
||||
clickhouse_sql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
|
||||
promql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
];
|
||||
variables: {},
|
||||
formatForWeb: true,
|
||||
start,
|
||||
end,
|
||||
step: 60,
|
||||
});
|
||||
|
||||
interface EndPointMetricsResponseRow {
|
||||
data: {
|
||||
@@ -2369,8 +2279,8 @@ export const statusCodeWidgetInfo = [
|
||||
|
||||
interface EndPointDropDownResponseRow {
|
||||
data: {
|
||||
[SPAN_ATTRIBUTES.HTTP_URL]: string;
|
||||
A: number;
|
||||
[SPAN_ATTRIBUTES.HTTP_URL]?: string;
|
||||
A?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2712,7 +2622,7 @@ export const groupStatusCodes = (
|
||||
|
||||
export const getStatusCodeBarChartWidgetData = (
|
||||
domainName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): Widgets => ({
|
||||
query: {
|
||||
builder: {
|
||||
@@ -2728,22 +2638,13 @@ export const getStatusCodeBarChartWidgetData = (
|
||||
dataSource: DataSource.TRACES,
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: 'c6724407',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
},
|
||||
...(filters?.items || []),
|
||||
],
|
||||
op: 'AND',
|
||||
filter: {
|
||||
expression: combineInitialAndUserExpression(
|
||||
getDomainNameFilterExpression(domainName),
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
@@ -2824,18 +2725,15 @@ export const getFormattedEndPointStatusCodeChartData = (
|
||||
export const END_POINT_DETAILS_QUERY_KEYS_ARRAY = [
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_METRICS_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_RATE_OVER_TIME_DATA,
|
||||
REACT_QUERY_KEY.GET_ENDPOINT_LATENCY_OVER_TIME_DATA,
|
||||
];
|
||||
|
||||
export const getAllEndpointsWidgetData = (
|
||||
groupBy: BaseAutocompleteData[],
|
||||
domainName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): Widgets => {
|
||||
const isGroupedByAttribute = groupBy.length > 0;
|
||||
@@ -2857,11 +2755,11 @@ export const getAllEndpointsWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters,
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND http_url EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2889,11 +2787,11 @@ export const getAllEndpointsWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'B',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters,
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND http_url EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2921,11 +2819,11 @@ export const getAllEndpointsWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'C',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters,
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND http_url EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -2953,11 +2851,11 @@ export const getAllEndpointsWidgetData = (
|
||||
disabled: true,
|
||||
expression: 'D',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters,
|
||||
expression: combineInitialAndUserExpression(
|
||||
`${getDomainNameFilterExpression(
|
||||
domainName,
|
||||
)} AND ${clientKindExpression} AND has_error = true AND http_url EXISTS`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -3075,38 +2973,26 @@ export const getAllEndpointsWidgetData = (
|
||||
return widget;
|
||||
};
|
||||
|
||||
const keysToRemove = [SPAN_ATTRIBUTES.HTTP_URL, 'A', 'B', 'C', 'F1'];
|
||||
const keysToRemove = [SPAN_ATTRIBUTES.HTTP_URL, 'key', 'A', 'B', 'C', 'F1'];
|
||||
|
||||
export const getGroupByFiltersFromGroupByValues = (
|
||||
rowData: any,
|
||||
groupBy: BaseAutocompleteData[],
|
||||
): IBuilderQuery['filters'] => {
|
||||
const items = Object.keys(rowData)
|
||||
// A group without a value for a key is the set of spans missing that attribute.
|
||||
export const getGroupByExpressionFromRowData = (
|
||||
rowData: Record<string, string | number | boolean | null | undefined>,
|
||||
): string =>
|
||||
Object.keys(rowData)
|
||||
.filter((key) => !keysToRemove.includes(key))
|
||||
.map((key) => {
|
||||
const groupByAttribute = groupBy.find((gb) => gb.key === key);
|
||||
return {
|
||||
id: groupByAttribute?.id || v4(),
|
||||
key: {
|
||||
dataType: groupByAttribute?.dataType || DataTypes.String,
|
||||
key: groupByAttribute?.key || key,
|
||||
type: groupByAttribute?.type || '',
|
||||
},
|
||||
op: '=', // operator for every attribute -> discuss
|
||||
value: rowData[key],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
op: 'AND',
|
||||
};
|
||||
};
|
||||
const value = rowData[key];
|
||||
return value === null || value === undefined
|
||||
? `${key} NOT EXISTS`
|
||||
: `${key} = ${formatValueForExpression(value)}`;
|
||||
})
|
||||
.join(' AND ');
|
||||
|
||||
export const getRateOverTimeWidgetData = (
|
||||
domainName: string,
|
||||
endPointName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): Widgets => {
|
||||
let legend = domainName;
|
||||
if (endPointName) {
|
||||
@@ -3130,9 +3016,9 @@ export const getRateOverTimeWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`http_host = '${domainName}'`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
@@ -3156,7 +3042,7 @@ export const getRateOverTimeWidgetData = (
|
||||
export const getLatencyOverTimeWidgetData = (
|
||||
domainName: string,
|
||||
endPointName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
filterExpression: string,
|
||||
): Widgets => {
|
||||
let legend = domainName;
|
||||
if (endPointName) {
|
||||
@@ -3180,9 +3066,9 @@ export const getLatencyOverTimeWidgetData = (
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filter: {
|
||||
expression: convertFiltersWithUrlHandling(
|
||||
filters || { items: [], op: 'AND' },
|
||||
expression: combineInitialAndUserExpression(
|
||||
`http_host = '${domainName}'`,
|
||||
filterExpression,
|
||||
),
|
||||
},
|
||||
functions: [],
|
||||
|
||||
@@ -111,8 +111,8 @@ jest.mock('constants/formats/alertFormatCategories', () => ({
|
||||
]),
|
||||
}));
|
||||
|
||||
jest.mock('container/CreateAlertV2/utils', () => ({
|
||||
...jest.requireActual('container/CreateAlertV2/utils'),
|
||||
jest.mock('container/CreateAlert/utils', () => ({
|
||||
...jest.requireActual('container/CreateAlert/utils'),
|
||||
}));
|
||||
|
||||
const TEST_STRINGS = {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
import { createMockAlertContextState } from 'container/CreateAlert/EvaluationSettings/__tests__/testUtils';
|
||||
import { render, screen, userEvent, within } from 'tests/test-utils';
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
|
||||
import { createMockAlertContextState } from 'container/CreateAlert/EvaluationSettings/__tests__/testUtils';
|
||||
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
|
||||
import * as appHooks from 'providers/App/App';
|
||||
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
|
||||
@@ -9,7 +9,7 @@ import ROUTES from 'constants/routes';
|
||||
import {
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
} from 'container/CreateAlert/context/types';
|
||||
import { getSelectedQueryOptions } from 'container/FormAlertRules/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { ArrowRight } from '@signozhq/icons';
|
||||
@@ -9,12 +9,12 @@ import CreateAlertHeader from './CreateAlertHeader';
|
||||
import Footer from './Footer';
|
||||
import NotificationSettings from './NotificationSettings';
|
||||
import QuerySection from './QuerySection';
|
||||
import { CreateAlertV2Props } from './types';
|
||||
import { CreateAlertProps } from './types';
|
||||
import { Spinner } from './utils';
|
||||
|
||||
import './CreateAlertV2.styles.scss';
|
||||
import './CreateAlert.styles.scss';
|
||||
|
||||
function CreateAlertV2({ alertType }: CreateAlertV2Props): JSX.Element {
|
||||
function CreateAlert({ alertType }: CreateAlertProps): JSX.Element {
|
||||
const queryToRedirect = buildInitialAlertDef(alertType);
|
||||
const currentQueryToRedirect = mapQueryDataFromApi(
|
||||
queryToRedirect.condition.compositeQuery,
|
||||
@@ -36,8 +36,8 @@ function CreateAlertV2({ alertType }: CreateAlertV2Props): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
CreateAlertV2.defaultProps = {
|
||||
CreateAlert.defaultProps = {
|
||||
initialQuery: initialQueriesMap.metrics,
|
||||
};
|
||||
|
||||
export default CreateAlertV2;
|
||||
export default CreateAlert;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user