mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-05 01:40:33 +01:00
Compare commits
62 Commits
v0.92.0-cl
...
variables-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fe1d1b3f7 | ||
|
|
11e4971db4 | ||
|
|
a0ab69ac4c | ||
|
|
972fadf79a | ||
|
|
9c2f127282 | ||
|
|
e30de5f13e | ||
|
|
019083983a | ||
|
|
fdcad997f5 | ||
|
|
03359a40a2 | ||
|
|
4f45801729 | ||
|
|
674556d672 | ||
|
|
af987e53ce | ||
|
|
59d5accd33 | ||
|
|
5a7ad670d8 | ||
|
|
9d04b397ac | ||
|
|
a4f3be5e46 | ||
|
|
8f833fa62c | ||
|
|
7029233596 | ||
|
|
d26efd2833 | ||
|
|
0e3ac2a179 | ||
|
|
249f8be845 | ||
|
|
9c952942ad | ||
|
|
dac46d82ff | ||
|
|
802ce6de01 | ||
|
|
6853f0c99d | ||
|
|
3f8a2870e4 | ||
|
|
5fa70ea802 | ||
|
|
3a952fa330 | ||
|
|
6d97db1d9d | ||
|
|
5412e7f70b | ||
|
|
8e5cb9046d | ||
|
|
760eabb2dc | ||
|
|
35ddaaa2fc | ||
|
|
a51ee66c02 | ||
|
|
75d189162b | ||
|
|
932918e3a4 | ||
|
|
aa3bc16dcb | ||
|
|
b5098e00a3 | ||
|
|
20dc561bfe | ||
|
|
99bbb87738 | ||
|
|
f1ce93171c | ||
|
|
92794389d6 | ||
|
|
bd02848623 | ||
|
|
b5016b061b | ||
|
|
c308e8668c | ||
|
|
41ee4176ad | ||
|
|
994663110d | ||
|
|
3a2eab2019 | ||
|
|
01202b5800 | ||
|
|
2901e052ae | ||
|
|
372372694e | ||
|
|
8e5b1be106 | ||
|
|
301d9ca4dd | ||
|
|
f350b0e2f0 | ||
|
|
090538f11f | ||
|
|
db13f85a3c | ||
|
|
3d874c22b0 | ||
|
|
e68ce11183 | ||
|
|
587b0ef6c4 | ||
|
|
bb6c366031 | ||
|
|
5c1f070d8f | ||
|
|
1ce150d4b0 |
@@ -24,7 +24,7 @@ services:
|
||||
depends_on:
|
||||
- zookeeper
|
||||
zookeeper:
|
||||
image: bitnami/zookeeper:3.7.1
|
||||
image: signoz/zookeeper:3.7.1
|
||||
container_name: zookeeper
|
||||
volumes:
|
||||
- ${PWD}/fs/tmp/zookeeper:/bitnami/zookeeper
|
||||
@@ -40,7 +40,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
schema-migrator-sync:
|
||||
image: signoz/signoz-schema-migrator:v0.128.2
|
||||
image: signoz/signoz-schema-migrator:v0.129.0
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
condition: service_healthy
|
||||
restart: on-failure
|
||||
schema-migrator-async:
|
||||
image: signoz/signoz-schema-migrator:v0.128.2
|
||||
image: signoz/signoz-schema-migrator:v0.129.0
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
29
.devenv/docker/signoz-otel-collector/compose.yaml
Normal file
29
.devenv/docker/signoz-otel-collector/compose.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
services:
|
||||
signoz-otel-collector:
|
||||
image: signoz/signoz-otel-collector:v0.128.2
|
||||
container_name: signoz-otel-collector-dev
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --feature-gates=-pkg.translator.prometheus.NormalizeName
|
||||
volumes:
|
||||
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
|
||||
environment:
|
||||
- OTEL_RESOURCE_ATTRIBUTES=host.name=signoz-host,os.type=linux
|
||||
- LOW_CARDINAL_EXCEPTION_GROUPING=false
|
||||
ports:
|
||||
- "4317:4317" # OTLP gRPC receiver
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
- "13133:13133" # health check extension
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- wget
|
||||
- --spider
|
||||
- -q
|
||||
- localhost:13133
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
@@ -0,0 +1,96 @@
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:4317
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
prometheus:
|
||||
config:
|
||||
global:
|
||||
scrape_interval: 60s
|
||||
scrape_configs:
|
||||
- job_name: otel-collector
|
||||
static_configs:
|
||||
- targets:
|
||||
- localhost:8888
|
||||
labels:
|
||||
job_name: otel-collector
|
||||
|
||||
processors:
|
||||
batch:
|
||||
send_batch_size: 10000
|
||||
send_batch_max_size: 11000
|
||||
timeout: 10s
|
||||
resourcedetection:
|
||||
# Using OTEL_RESOURCE_ATTRIBUTES envvar, env detector adds custom labels.
|
||||
detectors: [env, system]
|
||||
timeout: 2s
|
||||
signozspanmetrics/delta:
|
||||
metrics_exporter: signozclickhousemetrics
|
||||
metrics_flush_interval: 60s
|
||||
latency_histogram_buckets: [100us, 1ms, 2ms, 6ms, 10ms, 50ms, 100ms, 250ms, 500ms, 1000ms, 1400ms, 2000ms, 5s, 10s, 20s, 40s, 60s ]
|
||||
dimensions_cache_size: 100000
|
||||
aggregation_temporality: AGGREGATION_TEMPORALITY_DELTA
|
||||
enable_exp_histogram: true
|
||||
dimensions:
|
||||
- name: service.namespace
|
||||
default: default
|
||||
- name: deployment.environment
|
||||
default: default
|
||||
# This is added to ensure the uniqueness of the timeseries
|
||||
# Otherwise, identical timeseries produced by multiple replicas of
|
||||
# collectors result in incorrect APM metrics
|
||||
- name: signoz.collector.id
|
||||
- name: service.version
|
||||
- name: browser.platform
|
||||
- name: browser.mobile
|
||||
- name: k8s.cluster.name
|
||||
- name: k8s.node.name
|
||||
- name: k8s.namespace.name
|
||||
- name: host.name
|
||||
- name: host.type
|
||||
- name: container.name
|
||||
|
||||
extensions:
|
||||
health_check:
|
||||
endpoint: 0.0.0.0:13133
|
||||
pprof:
|
||||
endpoint: 0.0.0.0:1777
|
||||
|
||||
exporters:
|
||||
clickhousetraces:
|
||||
datasource: tcp://host.docker.internal:9000/signoz_traces
|
||||
low_cardinal_exception_grouping: ${env:LOW_CARDINAL_EXCEPTION_GROUPING}
|
||||
use_new_schema: true
|
||||
signozclickhousemetrics:
|
||||
dsn: tcp://host.docker.internal:9000/signoz_metrics
|
||||
clickhouselogsexporter:
|
||||
dsn: tcp://host.docker.internal:9000/signoz_logs
|
||||
timeout: 10s
|
||||
use_new_schema: true
|
||||
|
||||
service:
|
||||
telemetry:
|
||||
logs:
|
||||
encoding: json
|
||||
extensions:
|
||||
- health_check
|
||||
- pprof
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmetrics/delta, batch]
|
||||
exporters: [clickhousetraces]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics]
|
||||
metrics/prometheus:
|
||||
receivers: [prometheus]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [clickhouselogsexporter]
|
||||
11
Makefile
11
Makefile
@@ -61,6 +61,17 @@ devenv-postgres: ## Run postgres in devenv
|
||||
@cd .devenv/docker/postgres; \
|
||||
docker compose -f compose.yaml up -d
|
||||
|
||||
.PHONY: devenv-signoz-otel-collector
|
||||
devenv-signoz-otel-collector: ## Run signoz-otel-collector in devenv (requires clickhouse to be running)
|
||||
@cd .devenv/docker/signoz-otel-collector; \
|
||||
docker compose -f compose.yaml up -d
|
||||
|
||||
.PHONY: devenv-up
|
||||
devenv-up: devenv-clickhouse devenv-signoz-otel-collector ## Start both clickhouse and signoz-otel-collector for local development
|
||||
@echo "Development environment is ready!"
|
||||
@echo " - ClickHouse: http://localhost:8123"
|
||||
@echo " - Signoz OTel Collector: grpc://localhost:4317, http://localhost:4318"
|
||||
|
||||
##############################################################
|
||||
# go commands
|
||||
##############################################################
|
||||
|
||||
@@ -121,6 +121,8 @@ telemetrystore:
|
||||
timeout_before_checking_execution_speed: 0
|
||||
max_bytes_to_read: 0
|
||||
max_result_rows: 0
|
||||
ignore_data_skipping_indices: ""
|
||||
secondary_indices_enable_bulk_filtering: false
|
||||
|
||||
##################### Prometheus #####################
|
||||
prometheus:
|
||||
|
||||
@@ -39,7 +39,7 @@ x-clickhouse-defaults: &clickhouse-defaults
|
||||
hard: 262144
|
||||
x-zookeeper-defaults: &zookeeper-defaults
|
||||
!!merge <<: *common
|
||||
image: bitnami/zookeeper:3.7.1
|
||||
image: signoz/zookeeper:3.7.1
|
||||
user: root
|
||||
deploy:
|
||||
labels:
|
||||
@@ -174,7 +174,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.91.0
|
||||
image: signoz/signoz:v0.92.1
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -207,7 +207,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.128.2
|
||||
image: signoz/signoz-otel-collector:v0.129.0
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
@@ -231,7 +231,7 @@ services:
|
||||
- signoz
|
||||
schema-migrator:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:v0.128.2
|
||||
image: signoz/signoz-schema-migrator:v0.129.0
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -38,7 +38,7 @@ x-clickhouse-defaults: &clickhouse-defaults
|
||||
hard: 262144
|
||||
x-zookeeper-defaults: &zookeeper-defaults
|
||||
!!merge <<: *common
|
||||
image: bitnami/zookeeper:3.7.1
|
||||
image: signoz/zookeeper:3.7.1
|
||||
user: root
|
||||
deploy:
|
||||
labels:
|
||||
@@ -115,7 +115,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.91.0
|
||||
image: signoz/signoz:v0.92.1
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -148,7 +148,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.128.2
|
||||
image: signoz/signoz-otel-collector:v0.129.0
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
@@ -174,7 +174,7 @@ services:
|
||||
- signoz
|
||||
schema-migrator:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:v0.128.2
|
||||
image: signoz/signoz-schema-migrator:v0.129.0
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -42,7 +42,7 @@ x-clickhouse-defaults: &clickhouse-defaults
|
||||
hard: 262144
|
||||
x-zookeeper-defaults: &zookeeper-defaults
|
||||
!!merge <<: *common
|
||||
image: bitnami/zookeeper:3.7.1
|
||||
image: signoz/zookeeper:3.7.1
|
||||
user: root
|
||||
labels:
|
||||
signoz.io/scrape: "true"
|
||||
@@ -177,7 +177,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.91.0}
|
||||
image: signoz/signoz:${VERSION:-v0.92.1}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -211,7 +211,7 @@ services:
|
||||
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.128.2}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.0}
|
||||
container_name: signoz-otel-collector
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
@@ -237,7 +237,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-sync:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.128.2}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.0}
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -248,7 +248,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-async:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.128.2}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.0}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -38,7 +38,7 @@ x-clickhouse-defaults: &clickhouse-defaults
|
||||
hard: 262144
|
||||
x-zookeeper-defaults: &zookeeper-defaults
|
||||
!!merge <<: *common
|
||||
image: bitnami/zookeeper:3.7.1
|
||||
image: signoz/zookeeper:3.7.1
|
||||
user: root
|
||||
labels:
|
||||
signoz.io/scrape: "true"
|
||||
@@ -110,7 +110,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.91.0}
|
||||
image: signoz/signoz:${VERSION:-v0.92.1}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -143,7 +143,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.128.2}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.0}
|
||||
container_name: signoz-otel-collector
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
@@ -165,7 +165,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-sync:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.128.2}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.0}
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -177,7 +177,7 @@ services:
|
||||
restart: on-failure
|
||||
schema-migrator-async:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.128.2}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.0}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -44,20 +44,35 @@ Before diving in, make sure you have these tools installed:
|
||||
|
||||
SigNoz has three main components: Clickhouse, Backend, and Frontend. Let's set them up one by one.
|
||||
|
||||
### 1. Setting up Clickhouse
|
||||
### 1. Setting up ClickHouse
|
||||
|
||||
First, we need to get Clickhouse running:
|
||||
First, we need to get ClickHouse running:
|
||||
|
||||
```bash
|
||||
make devenv-clickhouse
|
||||
```
|
||||
|
||||
This command:
|
||||
- Starts Clickhouse in a single-shard, single-replica cluster
|
||||
- Starts ClickHouse in a single-shard, single-replica cluster
|
||||
- Sets up Zookeeper
|
||||
- Runs the latest schema migrations
|
||||
|
||||
### 2. Starting the Backend
|
||||
### 2. Setting up SigNoz OpenTelemetry Collector
|
||||
|
||||
Next, start the OpenTelemetry Collector to receive telemetry data:
|
||||
|
||||
```bash
|
||||
make devenv-signoz-otel-collector
|
||||
```
|
||||
|
||||
This command:
|
||||
- Starts the SigNoz OpenTelemetry Collector
|
||||
- Listens on port 4317 (gRPC) and 4318 (HTTP) for incoming telemetry data
|
||||
- Forwards data to ClickHouse for storage
|
||||
|
||||
> 💡 **Quick Setup**: Use `make devenv-up` to start both ClickHouse and OTel Collector together
|
||||
|
||||
### 3. Starting the Backend
|
||||
|
||||
1. Run the backend server:
|
||||
```bash
|
||||
@@ -73,19 +88,24 @@ This command:
|
||||
|
||||
> 💡 **Tip**: The API server runs at `http://localhost:8080/` by default
|
||||
|
||||
### 3. Setting up the Frontend
|
||||
### 4. Setting up the Frontend
|
||||
|
||||
1. Install dependencies:
|
||||
1. Navigate to the frontend directory:
|
||||
```bash
|
||||
cd frontend
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
2. Create a `.env` file in the `frontend` directory:
|
||||
3. Create a `.env` file in this directory:
|
||||
```env
|
||||
FRONTEND_API_ENDPOINT=http://localhost:8080
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
4. Start the development server:
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
@@ -93,3 +113,25 @@ This command:
|
||||
> 💡 **Tip**: `yarn dev` will automatically rebuild when you make changes to the code
|
||||
|
||||
Now you're all set to start developing! Happy coding! 🎉
|
||||
|
||||
## Verifying Your Setup
|
||||
To verify everything is working correctly:
|
||||
|
||||
1. **Check ClickHouse**: `curl http://localhost:8123/ping` (should return "Ok.")
|
||||
2. **Check OTel Collector**: `curl http://localhost:13133` (should return health status)
|
||||
3. **Check Backend**: `curl http://localhost:8080/api/v1/health` (should return `{"status":"ok"}`)
|
||||
4. **Check Frontend**: Open `http://localhost:3301` in your browser
|
||||
|
||||
## How to send test data?
|
||||
|
||||
You can now send telemetry data to your local SigNoz instance:
|
||||
|
||||
- **OTLP gRPC**: `localhost:4317`
|
||||
- **OTLP HTTP**: `localhost:4318`
|
||||
|
||||
For example, using `curl` to send a test trace:
|
||||
```bash
|
||||
curl -X POST http://localhost:4318/v1/traces \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"test-service"}}]},"scopeSpans":[{"spans":[{"traceId":"12345678901234567890123456789012","spanId":"1234567890123456","name":"test-span","startTimeUnixNano":"1609459200000000000","endTimeUnixNano":"1609459201000000000"}]}]}]}'
|
||||
```
|
||||
|
||||
@@ -50,19 +50,14 @@ func (p *BaseSeasonalProvider) getQueryParams(req *AnomaliesRequest) *anomalyQue
|
||||
|
||||
func (p *BaseSeasonalProvider) toTSResults(ctx context.Context, resp *qbtypes.QueryRangeResponse) []*qbtypes.TimeSeriesData {
|
||||
|
||||
if resp == nil || resp.Data == nil {
|
||||
tsData := []*qbtypes.TimeSeriesData{}
|
||||
|
||||
if resp == nil {
|
||||
p.logger.InfoContext(ctx, "nil response from query range")
|
||||
return tsData
|
||||
}
|
||||
|
||||
data, ok := resp.Data.(struct {
|
||||
Results []any `json:"results"`
|
||||
Warnings []string `json:"warnings"`
|
||||
})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
tsData := []*qbtypes.TimeSeriesData{}
|
||||
for _, item := range data.Results {
|
||||
for _, item := range resp.Data.Results {
|
||||
if resultData, ok := item.(*qbtypes.TimeSeriesData); ok {
|
||||
tsData = append(tsData, resultData)
|
||||
}
|
||||
@@ -395,6 +390,11 @@ func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, orgID valuer.UU
|
||||
continue
|
||||
}
|
||||
|
||||
// no data;
|
||||
if len(result.Aggregations) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
aggOfInterest := result.Aggregations[0]
|
||||
|
||||
for _, series := range aggOfInterest.Series {
|
||||
|
||||
@@ -113,6 +113,8 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
// v5
|
||||
router.HandleFunc("/api/v5/query_range", am.ViewAccess(ah.queryRangeV5)).Methods(http.MethodPost)
|
||||
|
||||
router.HandleFunc("/api/v5/substitute_vars", am.ViewAccess(ah.QuerierAPI.ReplaceVariables)).Methods(http.MethodPost)
|
||||
|
||||
// Gateway
|
||||
router.PathPrefix(gateway.RoutePrefix).HandlerFunc(am.EditAccess(ah.ServeGatewayHTTP))
|
||||
|
||||
|
||||
@@ -260,11 +260,9 @@ func (aH *APIHandler) queryRangeV5(rw http.ResponseWriter, req *http.Request) {
|
||||
finalResp := &qbtypes.QueryRangeResponse{
|
||||
Type: queryRangeRequest.RequestType,
|
||||
Data: struct {
|
||||
Results []any `json:"results"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Results []any `json:"results"`
|
||||
}{
|
||||
Results: results,
|
||||
Warnings: make([]string, 0), // TODO(srikanthccv): will there be any warnings here?
|
||||
Results: results,
|
||||
},
|
||||
Meta: struct {
|
||||
RowsScanned uint64 `json:"rowsScanned"`
|
||||
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
_ "net/http/pprof" // http profiler
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
@@ -104,6 +106,8 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz, jwt *authtypes.JWT)
|
||||
signoz.TelemetryStore,
|
||||
signoz.Prometheus,
|
||||
signoz.Modules.OrgGetter,
|
||||
signoz.Querier,
|
||||
signoz.Instrumentation.Logger(),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -421,6 +425,8 @@ func makeRulesManager(
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
prometheus prometheus.Prometheus,
|
||||
orgGetter organization.Getter,
|
||||
querier querier.Querier,
|
||||
logger *slog.Logger,
|
||||
) (*baserules.Manager, error) {
|
||||
// create manager opts
|
||||
managerOpts := &baserules.ManagerOptions{
|
||||
@@ -429,6 +435,8 @@ func makeRulesManager(
|
||||
Context: context.Background(),
|
||||
Logger: zap.L(),
|
||||
Reader: ch,
|
||||
Querier: querier,
|
||||
SLogger: logger,
|
||||
Cache: cache,
|
||||
EvalDelay: baseconst.GetEvalDelay(),
|
||||
PrepareTaskFunc: rules.PrepareTaskFunc,
|
||||
|
||||
@@ -4,17 +4,17 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/query-service/anomaly"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/common"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/SigNoz/signoz/pkg/transition"
|
||||
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
@@ -30,6 +30,11 @@ import (
|
||||
|
||||
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
|
||||
querierV5 "github.com/SigNoz/signoz/pkg/querier"
|
||||
|
||||
anomalyV2 "github.com/SigNoz/signoz/ee/anomaly"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
yaml "gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
@@ -47,7 +52,14 @@ type AnomalyRule struct {
|
||||
// querierV2 is used for alerts created after the introduction of new metrics query builder
|
||||
querierV2 interfaces.Querier
|
||||
|
||||
provider anomaly.Provider
|
||||
// querierV5 is used for alerts migrated after the introduction of new query builder
|
||||
querierV5 querierV5.Querier
|
||||
|
||||
provider anomaly.Provider
|
||||
providerV2 anomalyV2.Provider
|
||||
|
||||
version string
|
||||
logger *slog.Logger
|
||||
|
||||
seasonality anomaly.Seasonality
|
||||
}
|
||||
@@ -57,11 +69,15 @@ func NewAnomalyRule(
|
||||
orgID valuer.UUID,
|
||||
p *ruletypes.PostableRule,
|
||||
reader interfaces.Reader,
|
||||
querierV5 querierV5.Querier,
|
||||
logger *slog.Logger,
|
||||
cache cache.Cache,
|
||||
opts ...baserules.RuleOption,
|
||||
) (*AnomalyRule, error) {
|
||||
|
||||
zap.L().Info("creating new AnomalyRule", zap.String("id", id), zap.Any("opts", opts))
|
||||
logger.Info("creating new AnomalyRule", "rule_id", id)
|
||||
|
||||
opts = append(opts, baserules.WithLogger(logger))
|
||||
|
||||
if p.RuleCondition.CompareOp == ruletypes.ValueIsBelow {
|
||||
target := -1 * *p.RuleCondition.Target
|
||||
@@ -88,7 +104,7 @@ func NewAnomalyRule(
|
||||
t.seasonality = anomaly.SeasonalityDaily
|
||||
}
|
||||
|
||||
zap.L().Info("using seasonality", zap.String("seasonality", t.seasonality.String()))
|
||||
logger.Info("using seasonality", "seasonality", t.seasonality.String())
|
||||
|
||||
querierOptsV2 := querierV2.QuerierOptions{
|
||||
Reader: reader,
|
||||
@@ -117,6 +133,27 @@ func NewAnomalyRule(
|
||||
anomaly.WithReader[*anomaly.WeeklyProvider](reader),
|
||||
)
|
||||
}
|
||||
|
||||
if t.seasonality == anomaly.SeasonalityHourly {
|
||||
t.providerV2 = anomalyV2.NewHourlyProvider(
|
||||
anomalyV2.WithQuerier[*anomalyV2.HourlyProvider](querierV5),
|
||||
anomalyV2.WithLogger[*anomalyV2.HourlyProvider](logger),
|
||||
)
|
||||
} else if t.seasonality == anomaly.SeasonalityDaily {
|
||||
t.providerV2 = anomalyV2.NewDailyProvider(
|
||||
anomalyV2.WithQuerier[*anomalyV2.DailyProvider](querierV5),
|
||||
anomalyV2.WithLogger[*anomalyV2.DailyProvider](logger),
|
||||
)
|
||||
} else if t.seasonality == anomaly.SeasonalityWeekly {
|
||||
t.providerV2 = anomalyV2.NewWeeklyProvider(
|
||||
anomalyV2.WithQuerier[*anomalyV2.WeeklyProvider](querierV5),
|
||||
anomalyV2.WithLogger[*anomalyV2.WeeklyProvider](logger),
|
||||
)
|
||||
}
|
||||
|
||||
t.querierV5 = querierV5
|
||||
t.version = p.Version
|
||||
t.logger = logger
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
@@ -124,9 +161,11 @@ func (r *AnomalyRule) Type() ruletypes.RuleType {
|
||||
return RuleTypeAnomaly
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) prepareQueryRange(ts time.Time) (*v3.QueryRangeParamsV3, error) {
|
||||
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) (*v3.QueryRangeParamsV3, error) {
|
||||
|
||||
zap.L().Info("prepareQueryRange", zap.Int64("ts", ts.UnixMilli()), zap.Int64("evalWindow", r.EvalWindow().Milliseconds()), zap.Int64("evalDelay", r.EvalDelay().Milliseconds()))
|
||||
r.logger.InfoContext(
|
||||
ctx, "prepare query range request v4", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds(),
|
||||
)
|
||||
|
||||
start := ts.Add(-time.Duration(r.EvalWindow())).UnixMilli()
|
||||
end := ts.UnixMilli()
|
||||
@@ -156,13 +195,34 @@ func (r *AnomalyRule) prepareQueryRange(ts time.Time) (*v3.QueryRangeParamsV3, e
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) prepareQueryRangeV5(ctx context.Context, ts time.Time) (*qbtypes.QueryRangeRequest, error) {
|
||||
|
||||
r.logger.InfoContext(ctx, "prepare query range request v5", "ts", ts.UnixMilli(), "eval_window", r.EvalWindow().Milliseconds(), "eval_delay", r.EvalDelay().Milliseconds())
|
||||
|
||||
startTs, endTs := r.Timestamps(ts)
|
||||
start, end := startTs.UnixMilli(), endTs.UnixMilli()
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(start),
|
||||
End: uint64(end),
|
||||
RequestType: qbtypes.RequestTypeTimeSeries,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: make([]qbtypes.QueryEnvelope, 0),
|
||||
},
|
||||
NoCache: true,
|
||||
}
|
||||
req.CompositeQuery.Queries = make([]qbtypes.QueryEnvelope, len(r.Condition().CompositeQuery.Queries))
|
||||
copy(req.CompositeQuery.Queries, r.Condition().CompositeQuery.Queries)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) GetSelectedQuery() string {
|
||||
return r.Condition().GetSelectedQueryName()
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
|
||||
|
||||
params, err := r.prepareQueryRange(ts)
|
||||
params, err := r.prepareQueryRange(ctx, ts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -190,7 +250,50 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
|
||||
var resultVector ruletypes.Vector
|
||||
|
||||
scoresJSON, _ := json.Marshal(queryResult.AnomalyScores)
|
||||
zap.L().Info("anomaly scores", zap.String("scores", string(scoresJSON)))
|
||||
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
|
||||
|
||||
for _, series := range queryResult.AnomalyScores {
|
||||
smpl, shouldAlert := r.ShouldAlert(*series)
|
||||
if shouldAlert {
|
||||
resultVector = append(resultVector, smpl)
|
||||
}
|
||||
}
|
||||
return resultVector, nil
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
|
||||
|
||||
params, err := r.prepareQueryRangeV5(ctx, ts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
anomalies, err := r.providerV2.GetAnomalies(ctx, orgID, &anomalyV2.AnomaliesRequest{
|
||||
Params: *params,
|
||||
Seasonality: anomalyV2.Seasonality{String: valuer.NewString(r.seasonality.String())},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var qbResult *qbtypes.TimeSeriesData
|
||||
for _, result := range anomalies.Results {
|
||||
if result.QueryName == r.GetSelectedQuery() {
|
||||
qbResult = result
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if qbResult == nil {
|
||||
r.logger.WarnContext(ctx, "nil qb result", "ts", ts.UnixMilli())
|
||||
}
|
||||
|
||||
queryResult := transition.ConvertV5TimeSeriesDataToV4Result(qbResult)
|
||||
|
||||
var resultVector ruletypes.Vector
|
||||
|
||||
scoresJSON, _ := json.Marshal(queryResult.AnomalyScores)
|
||||
r.logger.InfoContext(ctx, "anomaly scores", "scores", string(scoresJSON))
|
||||
|
||||
for _, series := range queryResult.AnomalyScores {
|
||||
smpl, shouldAlert := r.ShouldAlert(*series)
|
||||
@@ -206,8 +309,17 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
|
||||
prevState := r.State()
|
||||
|
||||
valueFormatter := formatter.FromUnit(r.Unit())
|
||||
res, err := r.buildAndRunQuery(ctx, r.OrgID(), ts)
|
||||
|
||||
var res ruletypes.Vector
|
||||
var err error
|
||||
|
||||
if r.version == "v5" {
|
||||
r.logger.InfoContext(ctx, "running v5 query")
|
||||
res, err = r.buildAndRunQueryV5(ctx, r.OrgID(), ts)
|
||||
} else {
|
||||
r.logger.InfoContext(ctx, "running v4 query")
|
||||
res, err = r.buildAndRunQuery(ctx, r.OrgID(), ts)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -226,7 +338,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
|
||||
|
||||
value := valueFormatter.Format(smpl.V, r.Unit())
|
||||
threshold := valueFormatter.Format(r.TargetVal(), r.Unit())
|
||||
zap.L().Debug("Alert template data for rule", zap.String("name", r.Name()), zap.String("formatter", valueFormatter.Name()), zap.String("value", value), zap.String("threshold", threshold))
|
||||
r.logger.DebugContext(ctx, "Alert template data for rule", "rule_name", r.Name(), "formatter", valueFormatter.Name(), "value", value, "threshold", threshold)
|
||||
|
||||
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
|
||||
// Inject some convenience variables that are easier to remember for users
|
||||
@@ -247,7 +359,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
|
||||
result, err := tmpl.Expand()
|
||||
if err != nil {
|
||||
result = fmt.Sprintf("<error expanding template: %s>", err)
|
||||
zap.L().Error("Expanding alert template failed", zap.Error(err), zap.Any("data", tmplData))
|
||||
r.logger.ErrorContext(ctx, "Expanding alert template failed", "error", err, "data", tmplData, "rule_name", r.Name())
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -276,7 +388,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
|
||||
resultFPs[h] = struct{}{}
|
||||
|
||||
if _, ok := alerts[h]; ok {
|
||||
zap.L().Error("the alert query returns duplicate records", zap.String("ruleid", r.ID()), zap.Any("alert", alerts[h]))
|
||||
r.logger.ErrorContext(ctx, "the alert query returns duplicate records", "rule_id", r.ID(), "alert", alerts[h])
|
||||
err = fmt.Errorf("duplicate alert found, vector contains metrics with the same labelset after applying alert labels")
|
||||
return nil, err
|
||||
}
|
||||
@@ -294,7 +406,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
|
||||
}
|
||||
}
|
||||
|
||||
zap.L().Info("number of alerts found", zap.String("name", r.Name()), zap.Int("count", len(alerts)))
|
||||
r.logger.InfoContext(ctx, "number of alerts found", "rule_name", r.Name(), "alerts_count", len(alerts))
|
||||
|
||||
// alerts[h] is ready, add or update active list now
|
||||
for h, a := range alerts {
|
||||
@@ -317,7 +429,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
|
||||
for fp, a := range r.Active {
|
||||
labelsJSON, err := json.Marshal(a.QueryResultLables)
|
||||
if err != nil {
|
||||
zap.L().Error("error marshaling labels", zap.Error(err), zap.Any("labels", a.Labels))
|
||||
r.logger.ErrorContext(ctx, "error marshaling labels", "error", err, "labels", a.Labels)
|
||||
}
|
||||
if _, ok := resultFPs[fp]; !ok {
|
||||
// If the alert was previously firing, keep it around for a given
|
||||
|
||||
@@ -27,6 +27,8 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
opts.OrgID,
|
||||
opts.Rule,
|
||||
opts.Reader,
|
||||
opts.Querier,
|
||||
opts.SLogger,
|
||||
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
)
|
||||
@@ -47,7 +49,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
ruleId,
|
||||
opts.OrgID,
|
||||
opts.Rule,
|
||||
opts.Logger,
|
||||
opts.SLogger,
|
||||
opts.Reader,
|
||||
opts.ManagerOpts.Prometheus,
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
@@ -69,6 +71,8 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
opts.OrgID,
|
||||
opts.Rule,
|
||||
opts.Reader,
|
||||
opts.Querier,
|
||||
opts.SLogger,
|
||||
opts.Cache,
|
||||
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
@@ -126,6 +130,8 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
|
||||
opts.OrgID,
|
||||
parsedRule,
|
||||
opts.Reader,
|
||||
opts.Querier,
|
||||
opts.SLogger,
|
||||
baserules.WithSendAlways(),
|
||||
baserules.WithSendUnmatched(),
|
||||
baserules.WithSQLStore(opts.SQLStore),
|
||||
@@ -143,7 +149,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
|
||||
alertname,
|
||||
opts.OrgID,
|
||||
parsedRule,
|
||||
opts.Logger,
|
||||
opts.SLogger,
|
||||
opts.Reader,
|
||||
opts.ManagerOpts.Prometheus,
|
||||
baserules.WithSendAlways(),
|
||||
@@ -162,6 +168,8 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
|
||||
opts.OrgID,
|
||||
parsedRule,
|
||||
opts.Reader,
|
||||
opts.Querier,
|
||||
opts.SLogger,
|
||||
opts.Cache,
|
||||
baserules.WithSendAlways(),
|
||||
baserules.WithSendUnmatched(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
module.exports = {
|
||||
ignorePatterns: ['src/parser/*.ts'],
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
|
||||
@@ -8,3 +8,6 @@ public/
|
||||
|
||||
# Ignore all JSON files:
|
||||
**/*.json
|
||||
|
||||
# Ignore all files in parser folder:
|
||||
src/parser/**
|
||||
@@ -25,7 +25,7 @@ const config: Config.InitialOptions = {
|
||||
'^.+\\.(js|jsx)$': 'babel-jest',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@signozhq/design-tokens|d3-interpolate|d3-color|api)/)',
|
||||
'node_modules/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@signozhq/design-tokens|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn)/)',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>jest.setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/public/'],
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"dependencies": {
|
||||
"@ant-design/colors": "6.0.0",
|
||||
"@ant-design/icons": "4.8.0",
|
||||
"@codemirror/autocomplete": "6.18.6",
|
||||
"@codemirror/lang-javascript": "6.2.3",
|
||||
"@dnd-kit/core": "6.1.0",
|
||||
"@dnd-kit/modifiers": "7.0.0",
|
||||
"@dnd-kit/sortable": "8.0.0",
|
||||
@@ -44,6 +46,9 @@
|
||||
"@signozhq/design-tokens": "1.1.4",
|
||||
"@tanstack/react-table": "8.20.6",
|
||||
"@tanstack/react-virtual": "3.11.2",
|
||||
"@uiw/codemirror-theme-github": "4.24.1",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
"@uiw/react-codemirror": "4.23.10",
|
||||
"@uiw/react-md-editor": "3.23.5",
|
||||
"@visx/group": "3.3.0",
|
||||
"@visx/hierarchy": "3.12.0",
|
||||
@@ -53,6 +58,7 @@
|
||||
"ansi-to-html": "0.7.2",
|
||||
"antd": "5.11.0",
|
||||
"antd-table-saveas-excel": "2.2.1",
|
||||
"antlr4": "4.13.2",
|
||||
"axios": "1.8.2",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^29.6.4",
|
||||
|
||||
@@ -46,5 +46,8 @@
|
||||
"ALERT_HISTORY": "SigNoz | Alert Rule History",
|
||||
"ALERT_OVERVIEW": "SigNoz | Alert Rule Overview",
|
||||
"INFRASTRUCTURE_MONITORING_HOSTS": "SigNoz | Infra Monitoring",
|
||||
"INFRASTRUCTURE_MONITORING_KUBERNETES": "SigNoz | Infra Monitoring"
|
||||
"INFRASTRUCTURE_MONITORING_KUBERNETES": "SigNoz | Infra Monitoring",
|
||||
"METER_EXPLORER": "SigNoz | Meter Explorer",
|
||||
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
|
||||
"METER": "SigNoz | Meter"
|
||||
}
|
||||
|
||||
@@ -69,5 +69,8 @@
|
||||
"METRICS_EXPLORER": "SigNoz | Metrics Explorer",
|
||||
"METRICS_EXPLORER_EXPLORER": "SigNoz | Metrics Explorer",
|
||||
"METRICS_EXPLORER_VIEWS": "SigNoz | Metrics Explorer",
|
||||
"API_MONITORING": "SigNoz | External APIs"
|
||||
"API_MONITORING": "SigNoz | External APIs",
|
||||
"METER_EXPLORER": "SigNoz | Meter Explorer",
|
||||
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
|
||||
"METER": "SigNoz | Meter"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import MessagingQueues from 'pages/MessagingQueues';
|
||||
import MeterExplorer from 'pages/MeterExplorer';
|
||||
import { RouteProps } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
@@ -434,6 +435,28 @@ const routes: AppRoutes[] = [
|
||||
key: 'METRICS_EXPLORER_VIEWS',
|
||||
isPrivate: true,
|
||||
},
|
||||
|
||||
{
|
||||
path: ROUTES.METER,
|
||||
exact: true,
|
||||
component: MeterExplorer,
|
||||
key: 'METER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METER_EXPLORER,
|
||||
exact: true,
|
||||
component: MeterExplorer,
|
||||
key: 'METER_EXPLORER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METER_EXPLORER_VIEWS,
|
||||
exact: true,
|
||||
component: MeterExplorer,
|
||||
key: 'METER_EXPLORER_VIEWS',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.API_MONITORING,
|
||||
exact: true,
|
||||
|
||||
@@ -3,6 +3,7 @@ const apiV1 = '/api/v1/';
|
||||
export const apiV2 = '/api/v2/';
|
||||
export const apiV3 = '/api/v3/';
|
||||
export const apiV4 = '/api/v4/';
|
||||
export const apiV5 = '/api/v5/';
|
||||
export const gatewayApiV1 = '/api/gateway/v1/';
|
||||
export const gatewayApiV2 = '/api/gateway/v2/';
|
||||
export const apiAlertManager = '/api/alertmanager/';
|
||||
|
||||
34
frontend/src/api/dashboard/substitute_vars.ts
Normal file
34
frontend/src/api/dashboard/substitute_vars.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ApiV5Instance } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { QueryRangePayloadV5 } from 'api/v5/v5';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||
|
||||
interface ISubstituteVars {
|
||||
compositeQuery: ICompositeMetricQuery;
|
||||
}
|
||||
|
||||
export const getSubstituteVars = async (
|
||||
props?: Partial<QueryRangePayloadV5>,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<SuccessResponseV2<ISubstituteVars>> => {
|
||||
try {
|
||||
const response = await ApiV5Instance.post<{ data: ISubstituteVars }>(
|
||||
'/substitute_vars',
|
||||
props,
|
||||
{
|
||||
signal,
|
||||
headers,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
34
frontend/src/api/dynamicVariables/getFieldKeys.ts
Normal file
34
frontend/src/api/dynamicVariables/getFieldKeys.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { FieldKeyResponse } from 'types/api/dynamicVariables/getFieldKeys';
|
||||
|
||||
/**
|
||||
* Get field keys for a given signal type
|
||||
* @param signal Type of signal (traces, logs, metrics)
|
||||
* @param name Optional search text
|
||||
*/
|
||||
export const getFieldKeys = async (
|
||||
signal?: 'traces' | 'logs' | 'metrics',
|
||||
name?: string,
|
||||
): Promise<SuccessResponse<FieldKeyResponse> | ErrorResponse> => {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (signal) {
|
||||
params.signal = signal;
|
||||
}
|
||||
|
||||
if (name) {
|
||||
params.name = name;
|
||||
}
|
||||
|
||||
const response = await ApiBaseInstance.get('/fields/keys', { params });
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default getFieldKeys;
|
||||
63
frontend/src/api/dynamicVariables/getFieldValues.ts
Normal file
63
frontend/src/api/dynamicVariables/getFieldValues.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
|
||||
|
||||
/**
|
||||
* Get field values for a given signal type and field name
|
||||
* @param signal Type of signal (traces, logs, metrics)
|
||||
* @param name Name of the attribute for which values are being fetched
|
||||
* @param value Optional search text
|
||||
*/
|
||||
export const getFieldValues = async (
|
||||
signal?: 'traces' | 'logs' | 'metrics',
|
||||
name?: string,
|
||||
value?: string,
|
||||
startUnixMilli?: number,
|
||||
endUnixMilli?: number,
|
||||
): Promise<SuccessResponse<FieldValueResponse> | ErrorResponse> => {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (signal) {
|
||||
params.signal = signal;
|
||||
}
|
||||
|
||||
if (name) {
|
||||
params.name = name;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
params.value = value;
|
||||
}
|
||||
|
||||
if (startUnixMilli) {
|
||||
params.startUnixMilli = Math.floor(startUnixMilli / 1000000).toString();
|
||||
}
|
||||
|
||||
if (endUnixMilli) {
|
||||
params.endUnixMilli = Math.floor(endUnixMilli / 1000000).toString();
|
||||
}
|
||||
|
||||
const response = await ApiBaseInstance.get('/fields/values', { params });
|
||||
|
||||
// Normalize values from different types (stringValues, boolValues, etc.)
|
||||
if (response.data?.data?.values) {
|
||||
const allValues: string[] = [];
|
||||
Object.values(response.data.data.values).forEach((valueArray: any) => {
|
||||
if (Array.isArray(valueArray)) {
|
||||
allValues.push(...valueArray.map(String));
|
||||
}
|
||||
});
|
||||
|
||||
// Add a normalized values array to the response
|
||||
response.data.data.normalizedValues = allValues;
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default getFieldValues;
|
||||
@@ -19,6 +19,7 @@ import apiV1, {
|
||||
apiV2,
|
||||
apiV3,
|
||||
apiV4,
|
||||
apiV5,
|
||||
gatewayApiV1,
|
||||
gatewayApiV2,
|
||||
} from './apiV1';
|
||||
@@ -171,6 +172,18 @@ ApiV4Instance.interceptors.response.use(
|
||||
ApiV4Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
//
|
||||
|
||||
// axios V5
|
||||
export const ApiV5Instance = axios.create({
|
||||
baseURL: `${ENVIRONMENT.baseURL}${apiV5}`,
|
||||
});
|
||||
|
||||
ApiV5Instance.interceptors.response.use(
|
||||
interceptorsResponse,
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV5Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
//
|
||||
|
||||
// axios Base
|
||||
export const ApiBaseInstance = axios.create({
|
||||
baseURL: `${ENVIRONMENT.baseURL}${apiV1}`,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ApiV3Instance, ApiV4Instance } from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { ErrorResponse, SuccessResponse, Warning } from 'types/api';
|
||||
import {
|
||||
MetricRangePayloadV3,
|
||||
QueryRangePayload,
|
||||
@@ -13,7 +13,9 @@ export const getMetricsQueryRange = async (
|
||||
version: string,
|
||||
signal: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<SuccessResponse<MetricRangePayloadV3> | ErrorResponse> => {
|
||||
): Promise<
|
||||
(SuccessResponse<MetricRangePayloadV3> & { warning?: Warning }) | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
if (version && version === ENTITY_VERSION_V4) {
|
||||
const response = await ApiV4Instance.post('/query_range', props, {
|
||||
|
||||
@@ -17,6 +17,7 @@ export const getAggregateAttribute = async ({
|
||||
aggregateOperator,
|
||||
searchText,
|
||||
dataSource,
|
||||
source,
|
||||
}: IGetAggregateAttributePayload): Promise<
|
||||
SuccessResponse<IQueryAutocompleteResponse> | ErrorResponse
|
||||
> => {
|
||||
@@ -27,7 +28,7 @@ export const getAggregateAttribute = async ({
|
||||
`/autocomplete/aggregate_attributes?${createQueryParams({
|
||||
aggregateOperator,
|
||||
searchText,
|
||||
dataSource,
|
||||
dataSource: source === 'meter' ? 'meter' : dataSource,
|
||||
})}`,
|
||||
);
|
||||
|
||||
|
||||
30
frontend/src/api/querySuggestions/getKeySuggestions.ts
Normal file
30
frontend/src/api/querySuggestions/getKeySuggestions.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import axios from 'api';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import {
|
||||
QueryKeyRequestProps,
|
||||
QueryKeySuggestionsResponseProps,
|
||||
} from 'types/api/querySuggestions/types';
|
||||
|
||||
export const getKeySuggestions = (
|
||||
props: QueryKeyRequestProps,
|
||||
): Promise<AxiosResponse<QueryKeySuggestionsResponseProps>> => {
|
||||
const {
|
||||
signal = '',
|
||||
searchText = '',
|
||||
metricName = '',
|
||||
fieldContext = '',
|
||||
fieldDataType = '',
|
||||
signalSource = '',
|
||||
} = props;
|
||||
|
||||
const encodedSignal = encodeURIComponent(signal);
|
||||
const encodedSearchText = encodeURIComponent(searchText);
|
||||
const encodedMetricName = encodeURIComponent(metricName);
|
||||
const encodedFieldContext = encodeURIComponent(fieldContext);
|
||||
const encodedFieldDataType = encodeURIComponent(fieldDataType);
|
||||
const encodedSource = encodeURIComponent(signalSource);
|
||||
|
||||
return axios.get(
|
||||
`/fields/keys?signal=${encodedSignal}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&fieldContext=${encodedFieldContext}&fieldDataType=${encodedFieldDataType}&source=${encodedSource}`,
|
||||
);
|
||||
};
|
||||
22
frontend/src/api/querySuggestions/getValueSuggestion.ts
Normal file
22
frontend/src/api/querySuggestions/getValueSuggestion.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import axios from 'api';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import {
|
||||
QueryKeyValueRequestProps,
|
||||
QueryKeyValueSuggestionsResponseProps,
|
||||
} from 'types/api/querySuggestions/types';
|
||||
|
||||
export const getValueSuggestions = (
|
||||
props: QueryKeyValueRequestProps,
|
||||
): Promise<AxiosResponse<QueryKeyValueSuggestionsResponseProps>> => {
|
||||
const { signal, key, searchText, signalSource, metricName } = props;
|
||||
|
||||
const encodedSignal = encodeURIComponent(signal);
|
||||
const encodedKey = encodeURIComponent(key);
|
||||
const encodedMetricName = encodeURIComponent(metricName || '');
|
||||
const encodedSearchText = encodeURIComponent(searchText);
|
||||
const encodedSource = encodeURIComponent(signalSource || '');
|
||||
|
||||
return axios.get(
|
||||
`/fields/values?signal=${encodedSignal}&name=${encodedKey}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&source=${encodedSource}`,
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,6 @@ import { AllViewsProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export const getAllViews = (
|
||||
sourcepage: DataSource,
|
||||
sourcepage: DataSource | 'meter',
|
||||
): Promise<AxiosResponse<AllViewsProps>> =>
|
||||
axios.get(`/explorer/views?sourcePage=${sourcepage}`);
|
||||
|
||||
168
frontend/src/api/v5/queryRange/constants.ts
Normal file
168
frontend/src/api/v5/queryRange/constants.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// V5 Query Range Constants
|
||||
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import {
|
||||
FunctionName,
|
||||
RequestType,
|
||||
SignalType,
|
||||
Step,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
// ===================== Schema and Version Constants =====================
|
||||
|
||||
export const SCHEMA_VERSION_V5 = ENTITY_VERSION_V5;
|
||||
export const API_VERSION_V5 = 'v5';
|
||||
|
||||
// ===================== Default Values =====================
|
||||
|
||||
export const DEFAULT_STEP_INTERVAL: Step = '60s';
|
||||
export const DEFAULT_LIMIT = 100;
|
||||
export const DEFAULT_OFFSET = 0;
|
||||
|
||||
// ===================== Request Type Constants =====================
|
||||
|
||||
export const REQUEST_TYPES: Record<string, RequestType> = {
|
||||
SCALAR: 'scalar',
|
||||
TIME_SERIES: 'time_series',
|
||||
RAW: 'raw',
|
||||
DISTRIBUTION: 'distribution',
|
||||
} as const;
|
||||
|
||||
// ===================== Signal Type Constants =====================
|
||||
|
||||
export const SIGNAL_TYPES: Record<string, SignalType> = {
|
||||
TRACES: 'traces',
|
||||
LOGS: 'logs',
|
||||
METRICS: 'metrics',
|
||||
} as const;
|
||||
|
||||
// ===================== Common Aggregation Expressions =====================
|
||||
|
||||
export const TRACE_AGGREGATIONS = {
|
||||
COUNT: 'count()',
|
||||
COUNT_DISTINCT_TRACE_ID: 'count_distinct(traceID)',
|
||||
AVG_DURATION: 'avg(duration_nano)',
|
||||
P50_DURATION: 'p50(duration_nano)',
|
||||
P95_DURATION: 'p95(duration_nano)',
|
||||
P99_DURATION: 'p99(duration_nano)',
|
||||
MAX_DURATION: 'max(duration_nano)',
|
||||
MIN_DURATION: 'min(duration_nano)',
|
||||
SUM_DURATION: 'sum(duration_nano)',
|
||||
} as const;
|
||||
|
||||
export const LOG_AGGREGATIONS = {
|
||||
COUNT: 'count()',
|
||||
COUNT_DISTINCT_HOST: 'count_distinct(host.name)',
|
||||
COUNT_DISTINCT_SERVICE: 'count_distinct(service.name)',
|
||||
COUNT_DISTINCT_CONTAINER: 'count_distinct(container.name)',
|
||||
} as const;
|
||||
|
||||
// ===================== Common Filter Expressions =====================
|
||||
|
||||
export const COMMON_FILTERS = {
|
||||
// Trace filters
|
||||
SERVER_SPANS: "kind_string = 'Server'",
|
||||
CLIENT_SPANS: "kind_string = 'Client'",
|
||||
INTERNAL_SPANS: "kind_string = 'Internal'",
|
||||
ERROR_SPANS: 'http.status_code >= 400',
|
||||
SUCCESS_SPANS: 'http.status_code < 400',
|
||||
|
||||
// Common service filters
|
||||
EXCLUDE_HEALTH_CHECKS: "http.route != '/health' AND http.route != '/ping'",
|
||||
HTTP_REQUESTS: "http.method != ''",
|
||||
|
||||
// Log filters
|
||||
ERROR_LOGS: "severity_text = 'ERROR'",
|
||||
WARN_LOGS: "severity_text = 'WARN'",
|
||||
INFO_LOGS: "severity_text = 'INFO'",
|
||||
DEBUG_LOGS: "severity_text = 'DEBUG'",
|
||||
} as const;
|
||||
|
||||
// ===================== Common Group By Fields =====================
|
||||
|
||||
export const COMMON_GROUP_BY_FIELDS = {
|
||||
SERVICE_NAME: {
|
||||
name: 'service.name',
|
||||
fieldDataType: 'string' as const,
|
||||
fieldContext: 'resource' as const,
|
||||
},
|
||||
HTTP_METHOD: {
|
||||
name: 'http.method',
|
||||
fieldDataType: 'string' as const,
|
||||
fieldContext: 'attribute' as const,
|
||||
},
|
||||
HTTP_ROUTE: {
|
||||
name: 'http.route',
|
||||
fieldDataType: 'string' as const,
|
||||
fieldContext: 'attribute' as const,
|
||||
},
|
||||
HTTP_STATUS_CODE: {
|
||||
name: 'http.status_code',
|
||||
fieldDataType: 'int64' as const,
|
||||
fieldContext: 'attribute' as const,
|
||||
},
|
||||
HOST_NAME: {
|
||||
name: 'host.name',
|
||||
fieldDataType: 'string' as const,
|
||||
fieldContext: 'resource' as const,
|
||||
},
|
||||
CONTAINER_NAME: {
|
||||
name: 'container.name',
|
||||
fieldDataType: 'string' as const,
|
||||
fieldContext: 'resource' as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ===================== Function Names =====================
|
||||
|
||||
export const FUNCTION_NAMES: Record<string, FunctionName> = {
|
||||
CUT_OFF_MIN: 'cutOffMin',
|
||||
CUT_OFF_MAX: 'cutOffMax',
|
||||
CLAMP_MIN: 'clampMin',
|
||||
CLAMP_MAX: 'clampMax',
|
||||
ABSOLUTE: 'absolute',
|
||||
RUNNING_DIFF: 'runningDiff',
|
||||
LOG2: 'log2',
|
||||
LOG10: 'log10',
|
||||
CUM_SUM: 'cumSum',
|
||||
EWMA3: 'ewma3',
|
||||
EWMA5: 'ewma5',
|
||||
EWMA7: 'ewma7',
|
||||
MEDIAN3: 'median3',
|
||||
MEDIAN5: 'median5',
|
||||
MEDIAN7: 'median7',
|
||||
TIME_SHIFT: 'timeShift',
|
||||
ANOMALY: 'anomaly',
|
||||
} as const;
|
||||
|
||||
// ===================== Common Step Intervals =====================
|
||||
|
||||
export const STEP_INTERVALS = {
|
||||
FIFTEEN_SECONDS: '15s',
|
||||
THIRTY_SECONDS: '30s',
|
||||
ONE_MINUTE: '60s',
|
||||
FIVE_MINUTES: '300s',
|
||||
TEN_MINUTES: '600s',
|
||||
FIFTEEN_MINUTES: '900s',
|
||||
THIRTY_MINUTES: '1800s',
|
||||
ONE_HOUR: '3600s',
|
||||
TWO_HOURS: '7200s',
|
||||
SIX_HOURS: '21600s',
|
||||
TWELVE_HOURS: '43200s',
|
||||
ONE_DAY: '86400s',
|
||||
} as const;
|
||||
|
||||
// ===================== Time Range Presets =====================
|
||||
|
||||
export const TIME_RANGE_PRESETS = {
|
||||
LAST_5_MINUTES: 5 * 60 * 1000,
|
||||
LAST_15_MINUTES: 15 * 60 * 1000,
|
||||
LAST_30_MINUTES: 30 * 60 * 1000,
|
||||
LAST_HOUR: 60 * 60 * 1000,
|
||||
LAST_3_HOURS: 3 * 60 * 60 * 1000,
|
||||
LAST_6_HOURS: 6 * 60 * 60 * 1000,
|
||||
LAST_12_HOURS: 12 * 60 * 60 * 1000,
|
||||
LAST_24_HOURS: 24 * 60 * 60 * 1000,
|
||||
LAST_3_DAYS: 3 * 24 * 60 * 60 * 1000,
|
||||
LAST_7_DAYS: 7 * 24 * 60 * 60 * 1000,
|
||||
} as const;
|
||||
439
frontend/src/api/v5/queryRange/convertV5Response.ts
Normal file
439
frontend/src/api/v5/queryRange/convertV5Response.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import { cloneDeep, isEmpty } from 'lodash-es';
|
||||
import { SuccessResponse, Warning } from 'types/api';
|
||||
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
import {
|
||||
DistributionData,
|
||||
MetricRangePayloadV5,
|
||||
QueryRangeRequestV5,
|
||||
RawData,
|
||||
ScalarData,
|
||||
TimeSeriesData,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
function getColName(
|
||||
col: ScalarData['columns'][number],
|
||||
legendMap: Record<string, string>,
|
||||
aggregationPerQuery: Record<string, any>,
|
||||
): string {
|
||||
if (col.columnType === 'group') {
|
||||
return col.name;
|
||||
}
|
||||
|
||||
const aggregation =
|
||||
aggregationPerQuery?.[col.queryName]?.[col.aggregationIndex];
|
||||
const legend = legendMap[col.queryName];
|
||||
const alias = aggregation?.alias;
|
||||
const expression = aggregation?.expression || '';
|
||||
const aggregationsCount = aggregationPerQuery[col.queryName]?.length || 0;
|
||||
const isSingleAggregation = aggregationsCount === 1;
|
||||
|
||||
if (aggregationsCount > 0) {
|
||||
// Single aggregation: Priority is alias > legend > expression
|
||||
if (isSingleAggregation) {
|
||||
return alias || legend || expression || col.queryName;
|
||||
}
|
||||
|
||||
// Multiple aggregations: Each follows single rules BUT never shows legend
|
||||
// Priority: alias > expression (legend is ignored for multiple aggregations)
|
||||
return alias || expression || col.queryName;
|
||||
}
|
||||
|
||||
return legend || col.queryName;
|
||||
}
|
||||
|
||||
function getColId(
|
||||
col: ScalarData['columns'][number],
|
||||
aggregationPerQuery: Record<string, any>,
|
||||
): string {
|
||||
if (col.columnType === 'group') {
|
||||
return col.name;
|
||||
}
|
||||
const aggregation =
|
||||
aggregationPerQuery?.[col.queryName]?.[col.aggregationIndex];
|
||||
const expression = aggregation?.expression || '';
|
||||
const aggregationsCount = aggregationPerQuery[col.queryName]?.length || 0;
|
||||
const isMultipleAggregations = aggregationsCount > 1;
|
||||
|
||||
if (isMultipleAggregations && expression) {
|
||||
return `${col.queryName}.${expression}`;
|
||||
}
|
||||
|
||||
return col.queryName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts V5 TimeSeriesData to legacy format
|
||||
*/
|
||||
function convertTimeSeriesData(
|
||||
timeSeriesData: TimeSeriesData,
|
||||
legendMap: Record<string, string>,
|
||||
): QueryDataV3 {
|
||||
// Convert V5 time series format to legacy QueryDataV3 format
|
||||
|
||||
// Helper function to process series data
|
||||
const processSeriesData = (
|
||||
aggregations: any[],
|
||||
seriesKey:
|
||||
| 'series'
|
||||
| 'predictedSeries'
|
||||
| 'upperBoundSeries'
|
||||
| 'lowerBoundSeries'
|
||||
| 'anomalyScores',
|
||||
): any[] =>
|
||||
aggregations?.flatMap((aggregation) => {
|
||||
const { index, alias } = aggregation;
|
||||
const seriesData = aggregation[seriesKey];
|
||||
|
||||
if (!seriesData || !seriesData.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return seriesData.map((series: any) => ({
|
||||
labels: series.labels
|
||||
? Object.fromEntries(
|
||||
series.labels.map((label: any) => [label.key.name, label.value]),
|
||||
)
|
||||
: {},
|
||||
labelsArray: series.labels
|
||||
? series.labels.map((label: any) => ({ [label.key.name]: label.value }))
|
||||
: [],
|
||||
values: series.values.map((value: any) => ({
|
||||
timestamp: value.timestamp,
|
||||
value: String(value.value),
|
||||
})),
|
||||
metaData: {
|
||||
alias,
|
||||
index,
|
||||
queryName: timeSeriesData.queryName,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
queryName: timeSeriesData.queryName,
|
||||
legend: legendMap[timeSeriesData.queryName] || timeSeriesData.queryName,
|
||||
series: processSeriesData(timeSeriesData?.aggregations, 'series'),
|
||||
predictedSeries: processSeriesData(
|
||||
timeSeriesData?.aggregations,
|
||||
'predictedSeries',
|
||||
),
|
||||
upperBoundSeries: processSeriesData(
|
||||
timeSeriesData?.aggregations,
|
||||
'upperBoundSeries',
|
||||
),
|
||||
lowerBoundSeries: processSeriesData(
|
||||
timeSeriesData?.aggregations,
|
||||
'lowerBoundSeries',
|
||||
),
|
||||
anomalyScores: processSeriesData(
|
||||
timeSeriesData?.aggregations,
|
||||
'anomalyScores',
|
||||
),
|
||||
list: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts V5 ScalarData array to legacy format with table structure
|
||||
*/
|
||||
function convertScalarDataArrayToTable(
|
||||
scalarDataArray: ScalarData[],
|
||||
legendMap: Record<string, string>,
|
||||
aggregationPerQuery: Record<string, any>,
|
||||
): QueryDataV3[] {
|
||||
// If no scalar data, return empty structure
|
||||
|
||||
if (!scalarDataArray || scalarDataArray.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Process each scalar data separately to maintain query separation
|
||||
return scalarDataArray?.map((scalarData) => {
|
||||
// Get query name from the first column
|
||||
const queryName = scalarData?.columns?.[0]?.queryName || '';
|
||||
|
||||
if ((scalarData as any)?.aggregations?.length > 0) {
|
||||
return {
|
||||
...convertTimeSeriesData(scalarData as any, legendMap),
|
||||
table: {
|
||||
columns: [],
|
||||
rows: [],
|
||||
},
|
||||
list: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Collect columns for this specific query
|
||||
const columns = scalarData?.columns?.map((col) => ({
|
||||
name: getColName(col, legendMap, aggregationPerQuery),
|
||||
queryName: col.queryName,
|
||||
isValueColumn: col.columnType === 'aggregation',
|
||||
id: getColId(col, aggregationPerQuery),
|
||||
}));
|
||||
|
||||
// Process rows for this specific query
|
||||
const rows = scalarData?.data?.map((dataRow) => {
|
||||
const rowData: Record<string, any> = {};
|
||||
|
||||
scalarData?.columns?.forEach((col, colIndex) => {
|
||||
const columnName = getColName(col, legendMap, aggregationPerQuery);
|
||||
const columnId = getColId(col, aggregationPerQuery);
|
||||
rowData[columnId || columnName] = dataRow[colIndex];
|
||||
});
|
||||
|
||||
return { data: rowData };
|
||||
});
|
||||
|
||||
return {
|
||||
queryName,
|
||||
legend: legendMap[queryName] || '',
|
||||
series: null,
|
||||
list: null,
|
||||
table: {
|
||||
columns,
|
||||
rows,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function convertScalarWithFormatForWeb(
|
||||
scalarDataArray: ScalarData[],
|
||||
legendMap: Record<string, string>,
|
||||
aggregationPerQuery: Record<string, any>,
|
||||
): QueryDataV3[] {
|
||||
if (!scalarDataArray || scalarDataArray.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return scalarDataArray.map((scalarData) => {
|
||||
const columns =
|
||||
scalarData.columns?.map((col) => {
|
||||
const colName = getColName(col, legendMap, aggregationPerQuery);
|
||||
|
||||
return {
|
||||
name: colName,
|
||||
queryName: col.queryName,
|
||||
isValueColumn: col.columnType === 'aggregation',
|
||||
id: getColId(col, aggregationPerQuery),
|
||||
};
|
||||
}) || [];
|
||||
|
||||
const rows =
|
||||
scalarData.data?.map((dataRow) => {
|
||||
const rowData: Record<string, any> = {};
|
||||
columns?.forEach((col, colIndex) => {
|
||||
rowData[col.id || col.name] = dataRow[colIndex];
|
||||
});
|
||||
return { data: rowData };
|
||||
}) || [];
|
||||
|
||||
const queryName = scalarData.columns?.[0]?.queryName || '';
|
||||
|
||||
return {
|
||||
queryName,
|
||||
legend: legendMap[queryName] || queryName,
|
||||
series: null,
|
||||
list: null,
|
||||
table: {
|
||||
columns,
|
||||
rows,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts V5 RawData to legacy format
|
||||
*/
|
||||
function convertRawData(
|
||||
rawData: RawData,
|
||||
legendMap: Record<string, string>,
|
||||
): QueryDataV3 {
|
||||
// Convert V5 raw format to legacy QueryDataV3 format
|
||||
return {
|
||||
queryName: rawData.queryName,
|
||||
legend: legendMap[rawData.queryName] || rawData.queryName,
|
||||
series: null,
|
||||
list: rawData.rows?.map((row) => ({
|
||||
timestamp: row.timestamp,
|
||||
data: {
|
||||
// Map raw data to ILog structure - spread row.data first to include all properties
|
||||
...row.data,
|
||||
date: row.timestamp,
|
||||
} as any,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts V5 DistributionData to legacy format
|
||||
*/
|
||||
function convertDistributionData(
|
||||
distributionData: DistributionData,
|
||||
legendMap: Record<string, string>,
|
||||
): any {
|
||||
// eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
// Convert V5 distribution format to legacy histogram format
|
||||
return {
|
||||
...distributionData,
|
||||
legendMap,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to convert V5 data based on type
|
||||
*/
|
||||
function convertV5DataByType(
|
||||
v5Data: any,
|
||||
legendMap: Record<string, string>,
|
||||
aggregationPerQuery: Record<string, any>,
|
||||
): MetricRangePayloadV3['data'] {
|
||||
switch (v5Data?.type) {
|
||||
case 'time_series': {
|
||||
const timeSeriesData = v5Data.data.results as TimeSeriesData[];
|
||||
return {
|
||||
resultType: 'time_series',
|
||||
result: timeSeriesData.map((timeSeries) =>
|
||||
convertTimeSeriesData(timeSeries, legendMap),
|
||||
),
|
||||
};
|
||||
}
|
||||
case 'scalar': {
|
||||
const scalarData = v5Data.data.results as ScalarData[];
|
||||
// For scalar data, combine all results into separate table entries
|
||||
const combinedTables = convertScalarDataArrayToTable(
|
||||
scalarData,
|
||||
legendMap,
|
||||
aggregationPerQuery,
|
||||
);
|
||||
return {
|
||||
resultType: 'scalar',
|
||||
result: combinedTables,
|
||||
};
|
||||
}
|
||||
case 'raw': {
|
||||
const rawData = v5Data.data.results as RawData[];
|
||||
return {
|
||||
resultType: 'raw',
|
||||
result: rawData.map((raw) => convertRawData(raw, legendMap)),
|
||||
};
|
||||
}
|
||||
case 'trace': {
|
||||
const traceData = v5Data.data.results as RawData[];
|
||||
return {
|
||||
resultType: 'trace',
|
||||
result: traceData.map((trace) => convertRawData(trace, legendMap)),
|
||||
};
|
||||
}
|
||||
case 'distribution': {
|
||||
const distributionData = v5Data.data.results as DistributionData[];
|
||||
return {
|
||||
resultType: 'distribution',
|
||||
result: distributionData.map((distribution) =>
|
||||
convertDistributionData(distribution, legendMap),
|
||||
),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
resultType: '',
|
||||
result: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts V5 API response to legacy format expected by frontend components
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function convertV5ResponseToLegacy(
|
||||
v5Response: SuccessResponse<MetricRangePayloadV5>,
|
||||
legendMap: Record<string, string>,
|
||||
formatForWeb?: boolean,
|
||||
): SuccessResponse<MetricRangePayloadV3> & { warning?: Warning } {
|
||||
const { payload, params } = v5Response;
|
||||
const v5Data = payload?.data;
|
||||
|
||||
const aggregationPerQuery =
|
||||
(params as QueryRangeRequestV5)?.compositeQuery?.queries
|
||||
?.filter((query) => query.type === 'builder_query')
|
||||
.reduce((acc, query) => {
|
||||
if (
|
||||
query.type === 'builder_query' &&
|
||||
'aggregations' in query.spec &&
|
||||
query.spec.name
|
||||
) {
|
||||
acc[query.spec.name] = query.spec.aggregations;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, any>) || {};
|
||||
|
||||
// If formatForWeb is true, return as-is (like existing logic)
|
||||
if (formatForWeb && v5Data?.type === 'scalar') {
|
||||
const scalarData = v5Data.data.results as ScalarData[];
|
||||
const webTables = convertScalarWithFormatForWeb(
|
||||
scalarData,
|
||||
legendMap,
|
||||
aggregationPerQuery,
|
||||
);
|
||||
|
||||
return {
|
||||
...v5Response,
|
||||
payload: {
|
||||
data: {
|
||||
resultType: 'scalar',
|
||||
result: webTables,
|
||||
warnings: v5Data?.data?.warning || [],
|
||||
},
|
||||
warning: v5Data?.warning || undefined,
|
||||
},
|
||||
warning: v5Data?.warning || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Convert based on V5 response type
|
||||
const convertedData = convertV5DataByType(
|
||||
v5Data,
|
||||
legendMap,
|
||||
aggregationPerQuery,
|
||||
);
|
||||
|
||||
// Create legacy-compatible response structure
|
||||
const legacyResponse: SuccessResponse<MetricRangePayloadV3> = {
|
||||
...v5Response,
|
||||
payload: {
|
||||
data: convertedData,
|
||||
warning: v5Response.payload?.data?.warning || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// Apply legend mapping (similar to existing logic)
|
||||
if (legacyResponse.payload?.data?.result) {
|
||||
legacyResponse.payload.data.result = legacyResponse.payload.data.result.map(
|
||||
(queryData: any) => {
|
||||
// eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const newQueryData = cloneDeep(queryData);
|
||||
newQueryData.legend = legendMap[queryData.queryName];
|
||||
|
||||
// If metric names is an empty object
|
||||
if (isEmpty(queryData.metric)) {
|
||||
// If metrics list is empty && the user haven't defined a legend then add the legend equal to the name of the query.
|
||||
if (newQueryData.legend === undefined || newQueryData.legend === null) {
|
||||
newQueryData.legend = queryData.queryName;
|
||||
}
|
||||
// If name of the query and the legend if inserted is same then add the same to the metrics object.
|
||||
if (queryData.queryName === newQueryData.legend) {
|
||||
newQueryData.metric = newQueryData.metric || {};
|
||||
newQueryData.metric[queryData.queryName] = queryData.queryName;
|
||||
}
|
||||
}
|
||||
|
||||
return newQueryData;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return legacyResponse;
|
||||
}
|
||||
45
frontend/src/api/v5/queryRange/getQueryRange.ts
Normal file
45
frontend/src/api/v5/queryRange/getQueryRange.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ApiV5Instance } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
MetricRangePayloadV5,
|
||||
QueryRangePayloadV5,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
export const getQueryRangeV5 = async (
|
||||
props: QueryRangePayloadV5,
|
||||
version: string,
|
||||
signal: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<SuccessResponseV2<MetricRangePayloadV5>> => {
|
||||
try {
|
||||
if (version && version === ENTITY_VERSION_V5) {
|
||||
const response = await ApiV5Instance.post('/query_range', props, {
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data,
|
||||
};
|
||||
}
|
||||
|
||||
// Default V5 behavior
|
||||
const response = await ApiV5Instance.post('/query_range', props, {
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default getQueryRangeV5;
|
||||
450
frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts
Normal file
450
frontend/src/api/v5/queryRange/prepareQueryRangePayloadV5.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
||||
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
BaseBuilderQuery,
|
||||
FieldContext,
|
||||
FieldDataType,
|
||||
FunctionName,
|
||||
GroupByKey,
|
||||
Having,
|
||||
LogAggregation,
|
||||
MetricAggregation,
|
||||
OrderBy,
|
||||
QueryEnvelope,
|
||||
QueryFunction,
|
||||
QueryRangePayloadV5,
|
||||
QueryType,
|
||||
RequestType,
|
||||
TelemetryFieldKey,
|
||||
TraceAggregation,
|
||||
VariableItem,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { normalizeFunctionName } from 'utils/functionNameNormalizer';
|
||||
|
||||
type PrepareQueryRangePayloadV5Result = {
|
||||
queryPayload: QueryRangePayloadV5;
|
||||
legendMap: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps panel types to V5 request types
|
||||
*/
|
||||
export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
|
||||
switch (panelType) {
|
||||
case PANEL_TYPES.TIME_SERIES:
|
||||
case PANEL_TYPES.BAR:
|
||||
return 'time_series';
|
||||
case PANEL_TYPES.TABLE:
|
||||
case PANEL_TYPES.PIE:
|
||||
case PANEL_TYPES.VALUE:
|
||||
return 'scalar';
|
||||
case PANEL_TYPES.TRACE:
|
||||
return 'trace';
|
||||
case PANEL_TYPES.LIST:
|
||||
return 'raw';
|
||||
case PANEL_TYPES.HISTOGRAM:
|
||||
return 'distribution';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets signal type from data source
|
||||
*/
|
||||
function getSignalType(dataSource: string): 'traces' | 'logs' | 'metrics' {
|
||||
if (dataSource === 'traces') return 'traces';
|
||||
if (dataSource === 'logs') return 'logs';
|
||||
return 'metrics';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates base spec for builder queries
|
||||
*/
|
||||
function createBaseSpec(
|
||||
queryData: IBuilderQuery,
|
||||
requestType: RequestType,
|
||||
panelType?: PANEL_TYPES,
|
||||
): BaseBuilderQuery {
|
||||
const nonEmptySelectColumns = (queryData.selectColumns as (
|
||||
| BaseAutocompleteData
|
||||
| TelemetryFieldKey
|
||||
)[])?.filter((c) => ('key' in c ? c?.key : c?.name));
|
||||
|
||||
return {
|
||||
stepInterval: queryData?.stepInterval || undefined,
|
||||
disabled: queryData.disabled,
|
||||
filter: queryData?.filter?.expression ? queryData.filter : undefined,
|
||||
groupBy:
|
||||
queryData.groupBy?.length > 0
|
||||
? queryData.groupBy.map(
|
||||
(item: any): GroupByKey => ({
|
||||
name: item.key,
|
||||
fieldDataType: item?.dataType,
|
||||
fieldContext: item?.type,
|
||||
description: item?.description,
|
||||
unit: item?.unit,
|
||||
signal: item?.signal,
|
||||
materialized: item?.materialized,
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
limit:
|
||||
panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.LIST
|
||||
? queryData.limit || queryData.pageSize || undefined
|
||||
: queryData.limit || undefined,
|
||||
offset:
|
||||
requestType === 'raw' || requestType === 'trace'
|
||||
? queryData.offset
|
||||
: undefined,
|
||||
order:
|
||||
queryData.orderBy?.length > 0
|
||||
? queryData.orderBy.map(
|
||||
(order: any): OrderBy => ({
|
||||
key: {
|
||||
name: order.columnName,
|
||||
},
|
||||
direction: order.order,
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
|
||||
having: isEmpty(queryData.having) ? undefined : (queryData?.having as Having),
|
||||
functions: isEmpty(queryData.functions)
|
||||
? undefined
|
||||
: queryData.functions.map(
|
||||
(func: QueryFunction): QueryFunction => {
|
||||
// Normalize function name to handle case sensitivity
|
||||
const normalizedName = normalizeFunctionName(func?.name);
|
||||
return {
|
||||
name: normalizedName as FunctionName,
|
||||
args: isEmpty(func.namedArgs)
|
||||
? func.args?.map((arg) => ({
|
||||
value: arg?.value,
|
||||
}))
|
||||
: Object.entries(func?.namedArgs || {}).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
})),
|
||||
};
|
||||
},
|
||||
),
|
||||
selectFields: isEmpty(nonEmptySelectColumns)
|
||||
? undefined
|
||||
: nonEmptySelectColumns?.map(
|
||||
(column: any): TelemetryFieldKey => ({
|
||||
name: column.name ?? column.key,
|
||||
fieldDataType:
|
||||
column?.fieldDataType ?? (column?.dataType as FieldDataType),
|
||||
fieldContext: column?.fieldContext ?? (column?.type as FieldContext),
|
||||
signal: column?.signal ?? undefined,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
// Utility to parse aggregation expressions with optional alias
|
||||
export function parseAggregations(
|
||||
expression: string,
|
||||
): { expression: string; alias?: string }[] {
|
||||
const result: { expression: string; alias?: string }[] = [];
|
||||
// Matches function calls like "count()" or "sum(field)" with optional alias like "as 'alias'"
|
||||
// Handles quoted ('alias'), dash-separated (field-name), and unquoted values after "as" keyword
|
||||
const regex = /([a-zA-Z0-9_]+\([^)]*\))(?:\s*as\s+((?:'[^']*'|"[^"]*"|[a-zA-Z0-9_-]+)))?/g;
|
||||
let match = regex.exec(expression);
|
||||
while (match !== null) {
|
||||
const expr = match[1];
|
||||
let alias = match[2];
|
||||
if (alias) {
|
||||
// Remove quotes if present
|
||||
alias = alias.replace(/^['"]|['"]$/g, '');
|
||||
result.push({ expression: expr, alias });
|
||||
} else {
|
||||
result.push({ expression: expr });
|
||||
}
|
||||
match = regex.exec(expression);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createAggregation(
|
||||
queryData: any,
|
||||
panelType?: PANEL_TYPES,
|
||||
): TraceAggregation[] | LogAggregation[] | MetricAggregation[] {
|
||||
if (!queryData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const haveReduceTo =
|
||||
queryData.dataSource === DataSource.METRICS &&
|
||||
panelType &&
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.PIE ||
|
||||
panelType === PANEL_TYPES.VALUE);
|
||||
|
||||
if (queryData.dataSource === DataSource.METRICS) {
|
||||
return [
|
||||
{
|
||||
metricName:
|
||||
queryData?.aggregations?.[0]?.metricName ||
|
||||
queryData?.aggregateAttribute?.key,
|
||||
temporality:
|
||||
queryData?.aggregations?.[0]?.temporality ||
|
||||
queryData?.aggregateAttribute?.temporality,
|
||||
timeAggregation:
|
||||
queryData?.aggregations?.[0]?.timeAggregation ||
|
||||
queryData?.timeAggregation,
|
||||
spaceAggregation:
|
||||
queryData?.aggregations?.[0]?.spaceAggregation ||
|
||||
queryData?.spaceAggregation,
|
||||
reduceTo: haveReduceTo
|
||||
? queryData?.aggregations?.[0]?.reduceTo || queryData?.reduceTo
|
||||
: undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (queryData.aggregations?.length > 0) {
|
||||
return isEmpty(parseAggregations(queryData.aggregations?.[0].expression))
|
||||
? [{ expression: 'count()' }]
|
||||
: parseAggregations(queryData.aggregations?.[0].expression);
|
||||
}
|
||||
|
||||
return [{ expression: 'count()' }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts query builder data to V5 builder queries
|
||||
*/
|
||||
export function convertBuilderQueriesToV5(
|
||||
builderQueries: Record<string, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
requestType: RequestType,
|
||||
panelType?: PANEL_TYPES,
|
||||
): QueryEnvelope[] {
|
||||
return Object.entries(builderQueries).map(
|
||||
([queryName, queryData]): QueryEnvelope => {
|
||||
const signal = getSignalType(queryData.dataSource);
|
||||
const baseSpec = createBaseSpec(queryData, requestType, panelType);
|
||||
let spec: QueryEnvelope['spec'];
|
||||
|
||||
// Skip aggregation for raw request type
|
||||
const aggregations =
|
||||
requestType === 'raw' ? undefined : createAggregation(queryData, panelType);
|
||||
|
||||
switch (signal) {
|
||||
case 'traces':
|
||||
spec = {
|
||||
name: queryName,
|
||||
signal: 'traces' as const,
|
||||
...baseSpec,
|
||||
aggregations: aggregations as TraceAggregation[],
|
||||
};
|
||||
break;
|
||||
case 'logs':
|
||||
spec = {
|
||||
name: queryName,
|
||||
signal: 'logs' as const,
|
||||
...baseSpec,
|
||||
aggregations: aggregations as LogAggregation[],
|
||||
};
|
||||
break;
|
||||
case 'metrics':
|
||||
default:
|
||||
spec = {
|
||||
name: queryName,
|
||||
signal: 'metrics' as const,
|
||||
source: queryData.source || '',
|
||||
...baseSpec,
|
||||
aggregations: aggregations as MetricAggregation[],
|
||||
// reduceTo: queryData.reduceTo,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'builder_query' as QueryType,
|
||||
spec,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts PromQL queries to V5 format
|
||||
*/
|
||||
export function convertPromQueriesToV5(
|
||||
promQueries: Record<string, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
): QueryEnvelope[] {
|
||||
return Object.entries(promQueries).map(
|
||||
([queryName, queryData]): QueryEnvelope => ({
|
||||
type: 'promql' as QueryType,
|
||||
spec: {
|
||||
name: queryName,
|
||||
query: queryData.query,
|
||||
disabled: queryData.disabled || false,
|
||||
step: queryData?.stepInterval,
|
||||
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
|
||||
stats: false, // PromQL specific field
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts ClickHouse queries to V5 format
|
||||
*/
|
||||
export function convertClickHouseQueriesToV5(
|
||||
chQueries: Record<string, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
): QueryEnvelope[] {
|
||||
return Object.entries(chQueries).map(
|
||||
([queryName, queryData]): QueryEnvelope => ({
|
||||
type: 'clickhouse_sql' as QueryType,
|
||||
spec: {
|
||||
name: queryName,
|
||||
query: queryData.query,
|
||||
disabled: queryData.disabled || false,
|
||||
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
|
||||
// ClickHouse doesn't have step or stats like PromQL
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to reduce query arrays to objects
|
||||
*/
|
||||
function reduceQueriesToObject(
|
||||
queryArray: any[], // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
): { queries: Record<string, any>; legends: Record<string, string> } {
|
||||
// eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const legends: Record<string, string> = {};
|
||||
const queries = queryArray.reduce((acc, queryItem) => {
|
||||
if (!queryItem.query) return acc;
|
||||
acc[queryItem.name] = queryItem;
|
||||
legends[queryItem.name] = queryItem.legend;
|
||||
return acc;
|
||||
}, {} as Record<string, any>); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
return { queries, legends };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares V5 query range payload from GetQueryResultsProps
|
||||
*/
|
||||
export const prepareQueryRangePayloadV5 = ({
|
||||
query,
|
||||
globalSelectedInterval,
|
||||
graphType,
|
||||
selectedTime,
|
||||
tableParams,
|
||||
variables = {},
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
formatForWeb,
|
||||
originalGraphType,
|
||||
fillGaps,
|
||||
}: GetQueryResultsProps): PrepareQueryRangePayloadV5Result => {
|
||||
let legendMap: Record<string, string> = {};
|
||||
const requestType = mapPanelTypeToRequestType(graphType);
|
||||
let queries: QueryEnvelope[] = [];
|
||||
|
||||
switch (query.queryType) {
|
||||
case EQueryType.QUERY_BUILDER: {
|
||||
const { queryData: data, queryFormulas } = query.builder;
|
||||
const currentQueryData = mapQueryDataToApi(data, 'queryName', tableParams);
|
||||
const currentFormulas = mapQueryDataToApi(queryFormulas, 'queryName');
|
||||
|
||||
// Combine legend maps
|
||||
legendMap = {
|
||||
...currentQueryData.newLegendMap,
|
||||
...currentFormulas.newLegendMap,
|
||||
};
|
||||
|
||||
// Convert builder queries
|
||||
const builderQueries = convertBuilderQueriesToV5(
|
||||
currentQueryData.data,
|
||||
requestType,
|
||||
graphType,
|
||||
);
|
||||
|
||||
// Convert formulas as separate query type
|
||||
const formulaQueries = Object.entries(currentFormulas.data).map(
|
||||
([queryName, formulaData]): QueryEnvelope => ({
|
||||
type: 'builder_formula' as const,
|
||||
spec: {
|
||||
name: queryName,
|
||||
expression: formulaData.expression || '',
|
||||
disabled: formulaData.disabled,
|
||||
limit: formulaData.limit ?? undefined,
|
||||
legend: isEmpty(formulaData.legend) ? undefined : formulaData.legend,
|
||||
order: formulaData.orderBy?.map(
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
(order: any): OrderBy => ({
|
||||
key: {
|
||||
name: order.columnName,
|
||||
},
|
||||
direction: order.order,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Combine both types
|
||||
queries = [...builderQueries, ...formulaQueries];
|
||||
break;
|
||||
}
|
||||
case EQueryType.PROM: {
|
||||
const promQueries = reduceQueriesToObject(query[query.queryType]);
|
||||
queries = convertPromQueriesToV5(promQueries.queries);
|
||||
legendMap = promQueries.legends;
|
||||
break;
|
||||
}
|
||||
case EQueryType.CLICKHOUSE: {
|
||||
const chQueries = reduceQueriesToObject(query[query.queryType]);
|
||||
queries = convertClickHouseQueriesToV5(chQueries.queries);
|
||||
legendMap = chQueries.legends;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Calculate time range
|
||||
const { start, end } = getStartEndRangeTime({
|
||||
type: selectedTime,
|
||||
interval: globalSelectedInterval,
|
||||
});
|
||||
|
||||
// Create V5 payload
|
||||
const queryPayload: QueryRangePayloadV5 = {
|
||||
schemaVersion: 'v1',
|
||||
start: startTime ? startTime * 1e3 : parseInt(start, 10) * 1e3,
|
||||
end: endTime ? endTime * 1e3 : parseInt(end, 10) * 1e3,
|
||||
requestType,
|
||||
compositeQuery: {
|
||||
queries,
|
||||
},
|
||||
formatOptions: {
|
||||
formatTableResultForUI:
|
||||
!!formatForWeb ||
|
||||
(originalGraphType
|
||||
? originalGraphType === PANEL_TYPES.TABLE
|
||||
: graphType === PANEL_TYPES.TABLE),
|
||||
fillGaps: fillGaps || false,
|
||||
},
|
||||
variables: Object.entries(variables).reduce((acc, [key, value]) => {
|
||||
acc[key] = { value };
|
||||
return acc;
|
||||
}, {} as Record<string, VariableItem>),
|
||||
};
|
||||
|
||||
return { legendMap, queryPayload };
|
||||
};
|
||||
8
frontend/src/api/v5/v5.ts
Normal file
8
frontend/src/api/v5/v5.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// V5 API exports
|
||||
export * from './queryRange/constants';
|
||||
export { convertV5ResponseToLegacy } from './queryRange/convertV5Response';
|
||||
export { getQueryRangeV5 } from './queryRange/getQueryRange';
|
||||
export { prepareQueryRangePayloadV5 } from './queryRange/prepareQueryRangePayloadV5';
|
||||
|
||||
// Export types from proper location
|
||||
export * from 'types/api/v5/queryRange';
|
||||
@@ -64,7 +64,8 @@ export function applyCeleryFilterOnWidgetData(
|
||||
...queryItem,
|
||||
filters: {
|
||||
...queryItem.filters,
|
||||
items: [...queryItem.filters.items, ...filters],
|
||||
items: [...(queryItem.filters?.items || []), ...filters],
|
||||
op: queryItem.filters?.op || 'AND',
|
||||
},
|
||||
}
|
||||
: queryItem,
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface NavigateToExplorerProps {
|
||||
endTime?: number;
|
||||
sameTab?: boolean;
|
||||
shouldResolveQuery?: boolean;
|
||||
widgetQuery?: Query;
|
||||
}
|
||||
|
||||
export function useNavigateToExplorer(): (
|
||||
@@ -30,26 +31,34 @@ export function useNavigateToExplorer(): (
|
||||
);
|
||||
|
||||
const prepareQuery = useCallback(
|
||||
(selectedFilters: TagFilterItem[], dataSource: DataSource): Query => ({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData
|
||||
.map((item) => ({
|
||||
...item,
|
||||
dataSource,
|
||||
aggregateOperator: MetricAggregateOperator.NOOP,
|
||||
filters: {
|
||||
...item.filters,
|
||||
items: selectedFilters,
|
||||
},
|
||||
groupBy: [],
|
||||
disabled: false,
|
||||
}))
|
||||
.slice(0, 1),
|
||||
queryFormulas: [],
|
||||
},
|
||||
}),
|
||||
(
|
||||
selectedFilters: TagFilterItem[],
|
||||
dataSource: DataSource,
|
||||
query?: Query,
|
||||
): Query => {
|
||||
const widgetQuery = query || currentQuery;
|
||||
return {
|
||||
...widgetQuery,
|
||||
builder: {
|
||||
...widgetQuery.builder,
|
||||
queryData: widgetQuery.builder.queryData
|
||||
.map((item) => ({
|
||||
...item,
|
||||
dataSource,
|
||||
aggregateOperator: MetricAggregateOperator.NOOP,
|
||||
filters: {
|
||||
...item.filters,
|
||||
items: [...(item.filters?.items || []), ...selectedFilters],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
groupBy: [],
|
||||
disabled: false,
|
||||
}))
|
||||
.slice(0, 1),
|
||||
queryFormulas: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
[currentQuery],
|
||||
);
|
||||
|
||||
@@ -66,6 +75,7 @@ export function useNavigateToExplorer(): (
|
||||
endTime,
|
||||
sameTab,
|
||||
shouldResolveQuery,
|
||||
widgetQuery,
|
||||
} = props;
|
||||
const urlParams = new URLSearchParams();
|
||||
if (startTime && endTime) {
|
||||
@@ -76,7 +86,7 @@ export function useNavigateToExplorer(): (
|
||||
urlParams.set(QueryParams.endTime, (maxTime / 1000000).toString());
|
||||
}
|
||||
|
||||
let preparedQuery = prepareQuery(filters, dataSource);
|
||||
let preparedQuery = prepareQuery(filters, dataSource, widgetQuery);
|
||||
|
||||
if (shouldResolveQuery) {
|
||||
await getUpdatedQuery({
|
||||
|
||||
@@ -137,5 +137,11 @@
|
||||
h6 {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
33
frontend/src/components/Common/Common.styles.scss
Normal file
33
frontend/src/components/Common/Common.styles.scss
Normal file
@@ -0,0 +1,33 @@
|
||||
.error-state-container {
|
||||
height: 240px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
border-radius: 3px;
|
||||
|
||||
.error-state-container-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.error-state-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error-state-additional-messages {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.error-state-additional-text {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
frontend/src/components/Common/ErrorStateComponent.tsx
Normal file
59
frontend/src/components/Common/ErrorStateComponent.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import './Common.styles.scss';
|
||||
|
||||
import { Typography } from 'antd';
|
||||
|
||||
import APIError from '../../types/api/error';
|
||||
|
||||
interface ErrorStateComponentProps {
|
||||
message?: string;
|
||||
error?: APIError;
|
||||
}
|
||||
|
||||
const defaultProps: Partial<ErrorStateComponentProps> = {
|
||||
message: undefined,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
function ErrorStateComponent({
|
||||
message,
|
||||
error,
|
||||
}: ErrorStateComponentProps): JSX.Element {
|
||||
// Handle API Error object
|
||||
if (error) {
|
||||
const mainMessage = error.getErrorMessage();
|
||||
const additionalErrors = error.getErrorDetails().error.errors || [];
|
||||
|
||||
return (
|
||||
<div className="error-state-container">
|
||||
<div className="error-state-container-content">
|
||||
<Typography className="error-state-text">{mainMessage}</Typography>
|
||||
{additionalErrors.length > 0 && (
|
||||
<div className="error-state-additional-messages">
|
||||
{additionalErrors.map((additionalError) => (
|
||||
<Typography
|
||||
key={`error-${additionalError.message}`}
|
||||
className="error-state-additional-text"
|
||||
>
|
||||
• {additionalError.message}
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle simple string message (backwards compatibility)
|
||||
return (
|
||||
<div className="error-state-container">
|
||||
<div className="error-state-container-content">
|
||||
<Typography className="error-state-text">{message}</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ErrorStateComponent.defaultProps = defaultProps;
|
||||
|
||||
export default ErrorStateComponent;
|
||||
79
frontend/src/components/ErrorInPlace/ErrorInPlace.tsx
Normal file
79
frontend/src/components/ErrorInPlace/ErrorInPlace.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import { ReactNode } from 'react';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
interface ErrorInPlaceProps {
|
||||
/** The error object to display */
|
||||
error: APIError;
|
||||
/** Custom class name */
|
||||
className?: string;
|
||||
/** Custom style */
|
||||
style?: React.CSSProperties;
|
||||
/** Whether to show a border */
|
||||
bordered?: boolean;
|
||||
/** Background color */
|
||||
background?: string;
|
||||
/** Padding */
|
||||
padding?: string | number;
|
||||
/** Height - defaults to 100% to take available space */
|
||||
height?: string | number;
|
||||
/** Width - defaults to 100% to take available space */
|
||||
width?: string | number;
|
||||
/** Custom content instead of ErrorContent */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorInPlace - A component that renders error content directly in the available space
|
||||
* of its parent container. Perfect for displaying errors in widgets, cards, or any
|
||||
* container where you want the error to take up the full available space.
|
||||
*
|
||||
* @example
|
||||
* <ErrorInPlace error={error} />
|
||||
*
|
||||
* @example
|
||||
* <ErrorInPlace error={error} bordered background="#f5f5f5" padding={16} />
|
||||
*/
|
||||
function ErrorInPlace({
|
||||
error,
|
||||
className = '',
|
||||
style,
|
||||
bordered = false,
|
||||
background,
|
||||
padding = 16,
|
||||
height = '100%',
|
||||
width = '100%',
|
||||
children,
|
||||
}: ErrorInPlaceProps): JSX.Element {
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width,
|
||||
height,
|
||||
padding: typeof padding === 'number' ? `${padding}px` : padding,
|
||||
backgroundColor: background,
|
||||
border: bordered ? '1px solid var(--bg-slate-400, #374151)' : 'none',
|
||||
borderRadius: bordered ? '4px' : '0',
|
||||
overflow: 'auto',
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`error-in-place ${className}`.trim()} style={containerStyle}>
|
||||
{children || <ErrorContent error={error} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ErrorInPlace.defaultProps = {
|
||||
className: undefined,
|
||||
style: undefined,
|
||||
bordered: undefined,
|
||||
background: undefined,
|
||||
padding: undefined,
|
||||
height: undefined,
|
||||
width: undefined,
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
export default ErrorInPlace;
|
||||
@@ -18,7 +18,7 @@ function ErrorContent({ error }: ErrorContentProps): JSX.Element {
|
||||
errors: errorMessages,
|
||||
code: errorCode,
|
||||
message: errorMessage,
|
||||
} = error.error.error;
|
||||
} = error?.error?.error || {};
|
||||
return (
|
||||
<section className="error-content">
|
||||
{/* Summary Header */}
|
||||
|
||||
33
frontend/src/components/ErrorPopover/ErrorPopover.tsx
Normal file
33
frontend/src/components/ErrorPopover/ErrorPopover.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { Popover, PopoverProps } from 'antd';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface ErrorPopoverProps extends Omit<PopoverProps, 'content'> {
|
||||
/** Content to display in the popover */
|
||||
content: ReactNode;
|
||||
/** Element that triggers the popover */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorPopover - A clean wrapper around Ant Design's Popover
|
||||
* that provides a simple interface for displaying content in a popover.
|
||||
*
|
||||
* @example
|
||||
* <ErrorPopover content={<ErrorContent error={error} />}>
|
||||
* <CircleX />
|
||||
* </ErrorPopover>
|
||||
*/
|
||||
function ErrorPopover({
|
||||
content,
|
||||
children,
|
||||
...popoverProps
|
||||
}: ErrorPopoverProps): JSX.Element {
|
||||
return (
|
||||
<Popover content={content} {...popoverProps}>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorPopover;
|
||||
@@ -43,13 +43,13 @@ export const omitIdFromQuery = (query: Query | null): any => ({
|
||||
builder: {
|
||||
...query?.builder,
|
||||
queryData: query?.builder.queryData.map((queryData) => {
|
||||
const { id, ...rest } = queryData.aggregateAttribute;
|
||||
const { id, ...rest } = queryData.aggregateAttribute || {};
|
||||
const newAggregateAttribute = rest;
|
||||
const newGroupByAttributes = queryData.groupBy.map((groupByAttribute) => {
|
||||
const { id, ...rest } = groupByAttribute;
|
||||
return rest;
|
||||
});
|
||||
const newItems = queryData.filters.items.map((item) => {
|
||||
const newItems = queryData.filters?.items?.map((item) => {
|
||||
const { id, ...newItem } = item;
|
||||
if (item.key) {
|
||||
const { id, ...rest } = item.key;
|
||||
|
||||
@@ -74,16 +74,16 @@ function HostMetricTraces({
|
||||
...currentQuery.builder.queryData[0].aggregateAttribute,
|
||||
},
|
||||
filters: {
|
||||
items: tracesFilters.items.filter(
|
||||
(item) => item.key?.key !== 'host.name',
|
||||
),
|
||||
items:
|
||||
tracesFilters?.items?.filter((item) => item.key?.key !== 'host.name') ||
|
||||
[],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
[currentQuery, tracesFilters.items],
|
||||
[currentQuery, tracesFilters?.items],
|
||||
);
|
||||
|
||||
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
|
||||
@@ -140,7 +140,8 @@ function HostMetricTraces({
|
||||
|
||||
const isDataEmpty =
|
||||
!isLoading && !isFetching && !isError && traces.length === 0;
|
||||
const hasAdditionalFilters = tracesFilters.items.length > 1;
|
||||
const hasAdditionalFilters =
|
||||
tracesFilters?.items && tracesFilters?.items?.length > 1;
|
||||
|
||||
const totalCount =
|
||||
data?.payload?.data?.newResult?.data?.result?.[0]?.list?.length || 0;
|
||||
@@ -158,7 +159,7 @@ function HostMetricTraces({
|
||||
<div className="filter-section">
|
||||
{query && (
|
||||
<QueryBuilderSearch
|
||||
query={query}
|
||||
query={query as IBuilderQuery}
|
||||
onChange={(value): void =>
|
||||
handleChangeTracesFilters(value, VIEWS.TRACES)
|
||||
}
|
||||
|
||||
@@ -216,15 +216,17 @@ function HostMetricsDetails({
|
||||
const handleChangeLogFilters = useCallback(
|
||||
(value: IBuilderQuery['filters'], view: VIEWS) => {
|
||||
setLogFilters((prevFilters) => {
|
||||
const hostNameFilter = prevFilters.items.find(
|
||||
const hostNameFilter = prevFilters?.items?.find(
|
||||
(item) => item.key?.key === 'host.name',
|
||||
);
|
||||
const paginationFilter = value.items.find((item) => item.key?.key === 'id');
|
||||
const newFilters = value.items.filter(
|
||||
const paginationFilter = value?.items?.find(
|
||||
(item) => item.key?.key === 'id',
|
||||
);
|
||||
const newFilters = value?.items?.filter(
|
||||
(item) => item.key?.key !== 'id' && item.key?.key !== 'host.name',
|
||||
);
|
||||
|
||||
if (newFilters.length > 0) {
|
||||
if (newFilters && newFilters?.length > 0) {
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
view: InfraMonitoringEvents.LogsView,
|
||||
@@ -236,7 +238,7 @@ function HostMetricsDetails({
|
||||
op: 'AND',
|
||||
items: [
|
||||
hostNameFilter,
|
||||
...newFilters,
|
||||
...(newFilters || []),
|
||||
...(paginationFilter ? [paginationFilter] : []),
|
||||
].filter((item): item is TagFilterItem => item !== undefined),
|
||||
};
|
||||
@@ -258,11 +260,11 @@ function HostMetricsDetails({
|
||||
const handleChangeTracesFilters = useCallback(
|
||||
(value: IBuilderQuery['filters'], view: VIEWS) => {
|
||||
setTracesFilters((prevFilters) => {
|
||||
const hostNameFilter = prevFilters.items.find(
|
||||
const hostNameFilter = prevFilters?.items?.find(
|
||||
(item) => item.key?.key === 'host.name',
|
||||
);
|
||||
|
||||
if (value.items.length > 0) {
|
||||
if (value?.items && value?.items?.length > 0) {
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
view: InfraMonitoringEvents.TracesView,
|
||||
@@ -274,7 +276,7 @@ function HostMetricsDetails({
|
||||
op: 'AND',
|
||||
items: [
|
||||
hostNameFilter,
|
||||
...value.items.filter((item) => item.key?.key !== 'host.name'),
|
||||
...(value?.items?.filter((item) => item.key?.key !== 'host.name') || []),
|
||||
].filter((item): item is TagFilterItem => item !== undefined),
|
||||
};
|
||||
|
||||
@@ -311,7 +313,7 @@ function HostMetricsDetails({
|
||||
if (selectedView === VIEW_TYPES.LOGS) {
|
||||
const filtersWithoutPagination = {
|
||||
...logFilters,
|
||||
items: logFilters.items.filter((item) => item.key?.key !== 'id'),
|
||||
items: logFilters?.items?.filter((item) => item.key?.key !== 'id') || [],
|
||||
};
|
||||
|
||||
const compositeQuery = {
|
||||
|
||||
@@ -52,14 +52,16 @@ function HostMetricLogsDetailedView({
|
||||
...currentQuery.builder.queryData[0].aggregateAttribute,
|
||||
},
|
||||
filters: {
|
||||
items: logFilters.items.filter((item) => item.key?.key !== 'host.name'),
|
||||
items:
|
||||
logFilters?.items?.filter((item) => item.key?.key !== 'host.name') ||
|
||||
[],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
[currentQuery, logFilters.items],
|
||||
[currentQuery, logFilters?.items],
|
||||
);
|
||||
|
||||
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
|
||||
@@ -70,7 +72,7 @@ function HostMetricLogsDetailedView({
|
||||
<div className="filter-section">
|
||||
{query && (
|
||||
<QueryBuilderSearch
|
||||
query={query}
|
||||
query={query as IBuilderQuery}
|
||||
onChange={(value): void => handleChangeLogFilters(value, VIEWS.LOGS)}
|
||||
disableNavigationShortcuts
|
||||
/>
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import {
|
||||
verifyFiltersAndOrderBy,
|
||||
verifyPayload,
|
||||
} from 'container/LogsExplorerViews/tests/LogsExplorerPagination.test';
|
||||
import { logsPaginationQueryRangeSuccessResponse } from 'mocks-server/__mockdata__/logs_query_range';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
RenderResult,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { QueryRangePayload } from 'types/api/metrics/getQueryRange';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import HostMetricsLogs from '../HostMetricsLogs';
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'components/OverlayScrollbar/OverlayScrollbar',
|
||||
() =>
|
||||
function MockOverlayScrollbar({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
);
|
||||
|
||||
describe.skip('HostMetricsLogs', () => {
|
||||
let capturedQueryRangePayloads: QueryRangePayload[] = [];
|
||||
const itemHeight = 100;
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.post(
|
||||
`${ENVIRONMENT.baseURL}/api/v3/query_range`,
|
||||
async (req, res, ctx) => {
|
||||
capturedQueryRangePayloads.push(await req.json());
|
||||
|
||||
const lastPayload =
|
||||
capturedQueryRangePayloads[capturedQueryRangePayloads.length - 1];
|
||||
|
||||
const queryData = lastPayload?.compositeQuery.builderQueries
|
||||
?.A as IBuilderQuery;
|
||||
|
||||
const offset = queryData?.offset ?? 0;
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(logsPaginationQueryRangeSuccessResponse({ offset })),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
capturedQueryRangePayloads = [];
|
||||
});
|
||||
it('should check if host logs pagination flows work properly', async () => {
|
||||
let renderResult: RenderResult;
|
||||
let scrollableElement: HTMLElement;
|
||||
|
||||
await act(async () => {
|
||||
renderResult = render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight }}>
|
||||
<HostMetricsLogs
|
||||
timeRange={{ startTime: 0, endTime: 0 }}
|
||||
filters={{ items: [], op: 'AND' }}
|
||||
/>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedQueryRangePayloads.length).toBe(1);
|
||||
});
|
||||
|
||||
await waitFor(async () => {
|
||||
// Find the Virtuoso scroller element by its data-test-id
|
||||
scrollableElement = renderResult.container.querySelector(
|
||||
'[data-test-id="virtuoso-scroller"]',
|
||||
) as HTMLElement;
|
||||
|
||||
// Ensure the element exists
|
||||
expect(scrollableElement).not.toBeNull();
|
||||
|
||||
if (scrollableElement) {
|
||||
// Set the scrollTop property to simulate scrolling to the calculated end position
|
||||
scrollableElement.scrollTop = 99 * itemHeight;
|
||||
|
||||
act(() => {
|
||||
fireEvent.scroll(scrollableElement);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedQueryRangePayloads.length).toBe(2);
|
||||
});
|
||||
|
||||
const firstPayload = capturedQueryRangePayloads[0];
|
||||
verifyPayload({
|
||||
payload: firstPayload,
|
||||
expectedOffset: 0,
|
||||
});
|
||||
|
||||
// Store the time range from the first payload, which should be consistent in subsequent requests
|
||||
const initialTimeRange = {
|
||||
start: firstPayload.start,
|
||||
end: firstPayload.end,
|
||||
};
|
||||
|
||||
const secondPayload = capturedQueryRangePayloads[1];
|
||||
const secondQueryData = verifyPayload({
|
||||
payload: secondPayload,
|
||||
expectedOffset: 100,
|
||||
initialTimeRange,
|
||||
});
|
||||
verifyFiltersAndOrderBy(secondQueryData);
|
||||
|
||||
await waitFor(async () => {
|
||||
// Find the Virtuoso scroller element by its data-test-id
|
||||
scrollableElement = renderResult.container.querySelector(
|
||||
'[data-test-id="virtuoso-scroller"]',
|
||||
) as HTMLElement;
|
||||
|
||||
// Ensure the element exists
|
||||
expect(scrollableElement).not.toBeNull();
|
||||
|
||||
if (scrollableElement) {
|
||||
// Set the scrollTop property to simulate scrolling to the calculated end position
|
||||
scrollableElement.scrollTop = 199 * itemHeight;
|
||||
|
||||
act(() => {
|
||||
fireEvent.scroll(scrollableElement);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedQueryRangePayloads.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
const thirdPayload = capturedQueryRangePayloads[2];
|
||||
const thirdQueryData = verifyPayload({
|
||||
payload: thirdPayload,
|
||||
expectedOffset: 200,
|
||||
initialTimeRange,
|
||||
});
|
||||
verifyFiltersAndOrderBy(thirdQueryData);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/config';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { useMultiIntersectionObserver } from 'hooks/useMultiIntersectionObserver';
|
||||
@@ -86,6 +87,7 @@ function Metrics({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const chartData = useMemo(
|
||||
() => queries.map(({ data }) => getUPlotChartData(data?.payload)),
|
||||
@@ -144,9 +146,17 @@ function Metrics({
|
||||
minTimeScale: graphTimeIntervals[idx].start,
|
||||
maxTimeScale: graphTimeIntervals[idx].end,
|
||||
onDragSelect: (start, end) => onDragSelect(start, end, idx),
|
||||
query: currentQuery,
|
||||
}),
|
||||
),
|
||||
[queries, isDarkMode, dimensions, graphTimeIntervals, onDragSelect],
|
||||
[
|
||||
queries,
|
||||
isDarkMode,
|
||||
dimensions,
|
||||
graphTimeIntervals,
|
||||
onDragSelect,
|
||||
currentQuery,
|
||||
],
|
||||
);
|
||||
|
||||
const renderCardContent = (
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
.input-with-label {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
|
||||
.label {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: 0.56px;
|
||||
|
||||
max-width: 150px;
|
||||
min-width: 60px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
padding: 0px 8px;
|
||||
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
font-weight: var(--font-weight-light);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
|
||||
border-right: none;
|
||||
border-left: none;
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
}
|
||||
|
||||
&.labelAfter {
|
||||
.input {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
|
||||
.label {
|
||||
border-left: none;
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.input-with-label {
|
||||
.label {
|
||||
color: var(--bg-ink-500) !important;
|
||||
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
|
||||
.input {
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
|
||||
&.labelAfter {
|
||||
.input {
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
74
frontend/src/components/InputWithLabel/InputWithLabel.tsx
Normal file
74
frontend/src/components/InputWithLabel/InputWithLabel.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import './InputWithLabel.styles.scss';
|
||||
|
||||
import { Button, Input, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
function InputWithLabel({
|
||||
label,
|
||||
initialValue,
|
||||
placeholder,
|
||||
type,
|
||||
onClose,
|
||||
labelAfter,
|
||||
onChange,
|
||||
className,
|
||||
closeIcon,
|
||||
}: {
|
||||
label: string;
|
||||
initialValue?: string | number;
|
||||
placeholder: string;
|
||||
type?: string;
|
||||
onClose?: () => void;
|
||||
labelAfter?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
closeIcon?: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
const [inputValue, setInputValue] = useState<string>(
|
||||
initialValue ? initialValue.toString() : '',
|
||||
);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setInputValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('input-with-label', className, {
|
||||
labelAfter,
|
||||
})}
|
||||
>
|
||||
{!labelAfter && <Typography.Text className="label">{label}</Typography.Text>}
|
||||
<Input
|
||||
className="input"
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
name={label.toLowerCase()}
|
||||
/>
|
||||
{labelAfter && <Typography.Text className="label">{label}</Typography.Text>}
|
||||
{onClose && (
|
||||
<Button
|
||||
className="periscope-btn ghost close-btn"
|
||||
icon={closeIcon || <X size={16} />}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
InputWithLabel.defaultProps = {
|
||||
type: 'text',
|
||||
onClose: undefined,
|
||||
labelAfter: false,
|
||||
initialValue: undefined,
|
||||
className: undefined,
|
||||
closeIcon: undefined,
|
||||
};
|
||||
|
||||
export default InputWithLabel;
|
||||
@@ -20,3 +20,7 @@ export type LogDetailProps = {
|
||||
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
|
||||
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
|
||||
Pick<DrawerProps, 'onClose'>;
|
||||
|
||||
export type LogDetailInnerProps = LogDetailProps & {
|
||||
log: NonNullable<LogDetailProps['log']>;
|
||||
};
|
||||
|
||||
@@ -62,6 +62,10 @@
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&-query-container {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.log-detail-drawer__log {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Button, Divider, Drawer, Radio, Tooltip, Typography } from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/lib';
|
||||
import cx from 'classnames';
|
||||
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -19,19 +21,20 @@ import {
|
||||
getSanitizedLogBody,
|
||||
removeEscapeCharacters,
|
||||
} from 'container/LogDetailedView/utils';
|
||||
import useInitialQuery from 'container/LogsExplorerContext/useInitialQuery';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import {
|
||||
BarChart2,
|
||||
Braces,
|
||||
Compass,
|
||||
Copy,
|
||||
Filter,
|
||||
HardHat,
|
||||
Table,
|
||||
TextSelect,
|
||||
X,
|
||||
@@ -45,10 +48,9 @@ import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
|
||||
import { LogDetailProps } from './LogDetail.interfaces';
|
||||
import QueryBuilderSearchWrapper from './QueryBuilderSearchWrapper';
|
||||
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
|
||||
|
||||
function LogDetail({
|
||||
function LogDetailInner({
|
||||
log,
|
||||
onClose,
|
||||
onAddToQuery,
|
||||
@@ -57,13 +59,16 @@ function LogDetail({
|
||||
selectedTab,
|
||||
isListViewPanel = false,
|
||||
listViewPanelSelectedFields,
|
||||
}: LogDetailProps): JSX.Element {
|
||||
}: LogDetailInnerProps): JSX.Element {
|
||||
const initialContextQuery = useInitialQuery(log);
|
||||
const [contextQuery, setContextQuery] = useState<Query | undefined>(
|
||||
initialContextQuery,
|
||||
);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const [selectedView, setSelectedView] = useState<VIEWS>(selectedTab);
|
||||
|
||||
const [isFilterVisibile, setIsFilterVisible] = useState<boolean>(false);
|
||||
const [isFilterVisible, setIsFilterVisible] = useState<boolean>(false);
|
||||
|
||||
const [contextQuery, setContextQuery] = useState<Query | undefined>();
|
||||
const [filters, setFilters] = useState<TagFilter | null>(null);
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
const { stagedQuery, updateAllQueriesOperators } = useQueryBuilder();
|
||||
@@ -98,7 +103,7 @@ function LogDetail({
|
||||
};
|
||||
|
||||
const handleFilterVisible = (): void => {
|
||||
setIsFilterVisible(!isFilterVisibile);
|
||||
setIsFilterVisible(!isFilterVisible);
|
||||
setIsEdit(!isEdit);
|
||||
};
|
||||
|
||||
@@ -141,6 +146,44 @@ function LogDetail({
|
||||
safeNavigate(`${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`);
|
||||
};
|
||||
|
||||
const handleRunQuery = (expression: string): void => {
|
||||
let updatedContextQuery = cloneDeep(contextQuery);
|
||||
|
||||
if (!updatedContextQuery || !updatedContextQuery.builder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newFilters: TagFilter = {
|
||||
items: expression ? convertExpressionToFilters(expression) : [],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
updatedContextQuery = {
|
||||
...updatedContextQuery,
|
||||
builder: {
|
||||
...updatedContextQuery?.builder,
|
||||
queryData: updatedContextQuery?.builder.queryData.map((queryData) => ({
|
||||
...queryData,
|
||||
filter: {
|
||||
...queryData.filter,
|
||||
expression,
|
||||
},
|
||||
filters: {
|
||||
...queryData.filters,
|
||||
...newFilters,
|
||||
op: queryData.filters?.op ?? 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
setContextQuery(updatedContextQuery);
|
||||
|
||||
if (newFilters) {
|
||||
setFilters(newFilters);
|
||||
}
|
||||
};
|
||||
|
||||
// Only show when opened from infra monitoring page
|
||||
const showOpenInExplorerBtn = useMemo(
|
||||
() => location.pathname?.includes('/infrastructure-monitoring'),
|
||||
@@ -148,11 +191,6 @@ function LogDetail({
|
||||
[],
|
||||
);
|
||||
|
||||
if (!log) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const logType = log?.attributes_string?.log_level || LogType.INFO;
|
||||
|
||||
return (
|
||||
@@ -268,18 +306,16 @@ function LogDetail({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<QueryBuilderSearchWrapper
|
||||
isEdit={isEdit}
|
||||
log={log}
|
||||
filters={filters}
|
||||
setContextQuery={setContextQuery}
|
||||
setFilters={setFilters}
|
||||
contextQuery={contextQuery}
|
||||
suffixIcon={
|
||||
<HardHat size={12} style={{ paddingRight: Spacing.PADDING_2 }} />
|
||||
}
|
||||
/>
|
||||
{isFilterVisible && contextQuery?.builder.queryData[0] && (
|
||||
<div className="log-detail-drawer-query-container">
|
||||
<QuerySearch
|
||||
onChange={(): void => {}}
|
||||
dataSource={DataSource.LOGS}
|
||||
queryData={contextQuery?.builder.queryData[0]}
|
||||
onRun={handleRunQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.OVERVIEW && (
|
||||
<Overview
|
||||
@@ -315,4 +351,15 @@ function LogDetail({
|
||||
);
|
||||
}
|
||||
|
||||
function LogDetail(props: LogDetailProps): JSX.Element {
|
||||
const { log } = props;
|
||||
if (!log) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
return <LogDetailInner {...(props as LogDetailInnerProps)} />;
|
||||
}
|
||||
|
||||
export default LogDetail;
|
||||
|
||||
@@ -410,18 +410,18 @@ export default function LogsFormatOptionsMenu({
|
||||
)}
|
||||
|
||||
<div className="column-format">
|
||||
{addColumn?.value?.map(({ key, id }) => (
|
||||
<div className="column-name" key={id}>
|
||||
{addColumn?.value?.map(({ name }) => (
|
||||
<div className="column-name" key={name}>
|
||||
<div className="name">
|
||||
<Tooltip placement="left" title={key}>
|
||||
{key}
|
||||
<Tooltip placement="left" title={name}>
|
||||
{name}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{addColumn?.value?.length > 1 && (
|
||||
<X
|
||||
className="delete-btn"
|
||||
size={14}
|
||||
onClick={(): void => addColumn.onRemove(id as string)}
|
||||
onClick={(): void => addColumn.onRemove(name)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
|
||||
import {
|
||||
filterOptionsBySearch,
|
||||
handleScrollToBottom,
|
||||
prioritizeOrAddOptionForMultiSelect,
|
||||
SPACEKEY,
|
||||
} from './utils';
|
||||
@@ -37,7 +38,7 @@ enum ToggleTagValue {
|
||||
All = 'All',
|
||||
}
|
||||
|
||||
const ALL_SELECTED_VALUE = '__all__'; // Constant for the special value
|
||||
const ALL_SELECTED_VALUE = '__ALL__'; // Constant for the special value
|
||||
|
||||
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
placeholder = 'Search...',
|
||||
@@ -62,6 +63,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
allowClear = false,
|
||||
onRetry,
|
||||
maxTagTextLength,
|
||||
onDropdownVisibleChange,
|
||||
showIncompleteDataMessage = false,
|
||||
...rest
|
||||
}) => {
|
||||
// ===== State & Refs =====
|
||||
@@ -78,6 +81,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
const [visibleOptions, setVisibleOptions] = useState<OptionData[]>([]);
|
||||
const isClickInsideDropdownRef = useRef(false);
|
||||
const justOpenedRef = useRef<boolean>(false);
|
||||
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
|
||||
|
||||
// Convert single string value to array for consistency
|
||||
const selectedValues = useMemo(
|
||||
@@ -124,6 +129,12 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
return allAvailableValues.every((val) => selectedValues.includes(val));
|
||||
}, [selectedValues, allAvailableValues, enableAllSelection]);
|
||||
|
||||
// Define allOptionShown earlier in the code
|
||||
const allOptionShown = useMemo(
|
||||
() => value === ALL_SELECTED_VALUE || value === 'ALL',
|
||||
[value],
|
||||
);
|
||||
|
||||
// Value passed to the underlying Ant Select component
|
||||
const displayValue = useMemo(
|
||||
() => (isAllSelected ? [ALL_SELECTED_VALUE] : selectedValues),
|
||||
@@ -132,10 +143,18 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
|
||||
// ===== Internal onChange Handler =====
|
||||
const handleInternalChange = useCallback(
|
||||
(newValue: string | string[]): void => {
|
||||
(newValue: string | string[], directCaller?: boolean): void => {
|
||||
// Ensure newValue is an array
|
||||
const currentNewValue = Array.isArray(newValue) ? newValue : [];
|
||||
|
||||
if (
|
||||
(allOptionShown || isAllSelected) &&
|
||||
!directCaller &&
|
||||
currentNewValue.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!onChange) return;
|
||||
|
||||
// Case 1: Cleared (empty array or undefined)
|
||||
@@ -144,7 +163,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 2: "__all__" is selected (means select all actual values)
|
||||
// Case 2: "__ALL__" is selected (means select all actual values)
|
||||
if (currentNewValue.includes(ALL_SELECTED_VALUE)) {
|
||||
const allActualOptions = allAvailableValues.map(
|
||||
(v) => options.flat().find((o) => o.value === v) || { label: v, value: v },
|
||||
@@ -175,7 +194,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[onChange, allAvailableValues, options, enableAllSelection],
|
||||
[
|
||||
allOptionShown,
|
||||
isAllSelected,
|
||||
onChange,
|
||||
allAvailableValues,
|
||||
options,
|
||||
enableAllSelection,
|
||||
],
|
||||
);
|
||||
|
||||
// ===== Existing Callbacks (potentially needing adjustment later) =====
|
||||
@@ -510,11 +536,19 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}
|
||||
|
||||
// Normal single value handling
|
||||
setSearchText(value.trim());
|
||||
const trimmedValue = value.trim();
|
||||
setSearchText(trimmedValue);
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
justOpenedRef.current = true;
|
||||
}
|
||||
if (onSearch) onSearch(value.trim());
|
||||
|
||||
// Reset active index when search changes if dropdown is open
|
||||
if (isOpen && trimmedValue) {
|
||||
setActiveIndex(0);
|
||||
}
|
||||
|
||||
if (onSearch) onSearch(trimmedValue);
|
||||
},
|
||||
[onSearch, isOpen, selectedValues, onChange],
|
||||
);
|
||||
@@ -528,28 +562,34 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
(text: string, searchQuery: string): React.ReactNode => {
|
||||
if (!searchQuery || !highlightSearch) return text;
|
||||
|
||||
const parts = text.split(
|
||||
new RegExp(
|
||||
`(${searchQuery.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')})`,
|
||||
'gi',
|
||||
),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
// Create a unique key that doesn't rely on array index
|
||||
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
||||
try {
|
||||
const parts = text.split(
|
||||
new RegExp(
|
||||
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
|
||||
'gi',
|
||||
),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
// Create a unique key that doesn't rely on array index
|
||||
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
||||
|
||||
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||
<span key={uniqueKey} className="highlight-text">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||
<span key={uniqueKey} className="highlight-text">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
} catch (error) {
|
||||
// If regex fails, return the original text without highlighting
|
||||
console.error('Error in text highlighting:', error);
|
||||
return text;
|
||||
}
|
||||
},
|
||||
[highlightSearch],
|
||||
);
|
||||
@@ -560,10 +600,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
|
||||
if (isAllSelected) {
|
||||
// If all are selected, deselect all
|
||||
handleInternalChange([]);
|
||||
handleInternalChange([], true);
|
||||
} else {
|
||||
// Otherwise, select all
|
||||
handleInternalChange([ALL_SELECTED_VALUE]);
|
||||
handleInternalChange([ALL_SELECTED_VALUE], true);
|
||||
}
|
||||
}, [options, isAllSelected, handleInternalChange]);
|
||||
|
||||
@@ -738,6 +778,26 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
// Enhanced keyboard navigation with support for maxTagCount
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLElement>): void => {
|
||||
// Simple early return if ALL is selected - block all possible keyboard interactions
|
||||
// that could remove the ALL tag, but still allow dropdown navigation and search
|
||||
if (
|
||||
(allOptionShown || isAllSelected) &&
|
||||
(e.key === 'Backspace' || e.key === 'Delete')
|
||||
) {
|
||||
// Only prevent default if the input is empty or cursor is at start position
|
||||
const activeElement = document.activeElement as HTMLInputElement;
|
||||
const isInputActive = activeElement?.tagName === 'INPUT';
|
||||
const isInputEmpty = isInputActive && !activeElement?.value;
|
||||
const isCursorAtStart =
|
||||
isInputActive && activeElement?.selectionStart === 0;
|
||||
|
||||
if (isInputEmpty || isCursorAtStart) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get flattened list of all selectable options
|
||||
const getFlatOptions = (): OptionData[] => {
|
||||
if (!visibleOptions) return [];
|
||||
@@ -752,7 +812,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
if (hasAll) {
|
||||
flatList.push({
|
||||
label: 'ALL',
|
||||
value: '__all__', // Special value for the ALL option
|
||||
value: ALL_SELECTED_VALUE, // Special value for the ALL option
|
||||
type: 'defined',
|
||||
});
|
||||
}
|
||||
@@ -784,6 +844,17 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
|
||||
const flatOptions = getFlatOptions();
|
||||
|
||||
// If we just opened the dropdown and have options, set first option as active
|
||||
if (justOpenedRef.current && flatOptions.length > 0) {
|
||||
setActiveIndex(0);
|
||||
justOpenedRef.current = false;
|
||||
}
|
||||
|
||||
// If no option is active but we have options and dropdown is open, activate the first one
|
||||
if (isOpen && activeIndex === -1 && flatOptions.length > 0) {
|
||||
setActiveIndex(0);
|
||||
}
|
||||
|
||||
// Get the active input element to check cursor position
|
||||
const activeElement = document.activeElement as HTMLInputElement;
|
||||
const isInputActive = activeElement?.tagName === 'INPUT';
|
||||
@@ -1129,7 +1200,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
// If there's an active option in the dropdown, prioritize selecting it
|
||||
if (activeIndex >= 0 && activeIndex < flatOptions.length) {
|
||||
const selectedOption = flatOptions[activeIndex];
|
||||
if (selectedOption.value === '__all__') {
|
||||
if (selectedOption.value === ALL_SELECTED_VALUE) {
|
||||
handleSelectAll();
|
||||
} else if (selectedOption.value && onChange) {
|
||||
const newValues = selectedValues.includes(selectedOption.value)
|
||||
@@ -1159,6 +1230,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setActiveIndex(-1);
|
||||
// Call onDropdownVisibleChange when Escape is pressed to close dropdown
|
||||
if (onDropdownVisibleChange) {
|
||||
onDropdownVisibleChange(false);
|
||||
}
|
||||
break;
|
||||
|
||||
case SPACEKEY:
|
||||
@@ -1168,7 +1243,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
const selectedOption = flatOptions[activeIndex];
|
||||
|
||||
// Check if it's the ALL option
|
||||
if (selectedOption.value === '__all__') {
|
||||
if (selectedOption.value === ALL_SELECTED_VALUE) {
|
||||
handleSelectAll();
|
||||
} else if (selectedOption.value && onChange) {
|
||||
const newValues = selectedValues.includes(selectedOption.value)
|
||||
@@ -1214,7 +1289,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
setActiveIndex(0);
|
||||
justOpenedRef.current = true; // Set flag to initialize active option on next render
|
||||
setActiveChipIndex(-1);
|
||||
break;
|
||||
|
||||
@@ -1260,9 +1335,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}
|
||||
},
|
||||
[
|
||||
allOptionShown,
|
||||
isAllSelected,
|
||||
isOpen,
|
||||
activeIndex,
|
||||
getVisibleChipIndices,
|
||||
getLastVisibleChipIndex,
|
||||
selectedChips,
|
||||
isSelectionMode,
|
||||
isOpen,
|
||||
activeChipIndex,
|
||||
selectedValues,
|
||||
visibleOptions,
|
||||
@@ -1278,10 +1358,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
startSelection,
|
||||
selectionEnd,
|
||||
extendSelection,
|
||||
activeIndex,
|
||||
onDropdownVisibleChange,
|
||||
handleSelectAll,
|
||||
getVisibleChipIndices,
|
||||
getLastVisibleChipIndex,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1306,6 +1384,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
// Add a scroll handler for the dropdown
|
||||
const handleDropdownScroll = useCallback(
|
||||
(e: React.UIEvent<HTMLDivElement>): void => {
|
||||
setIsScrolledToBottom(handleScrollToBottom(e));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Custom dropdown render with sections support
|
||||
const customDropdownRender = useCallback((): React.ReactElement => {
|
||||
// Process options based on current search
|
||||
@@ -1382,6 +1468,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
onMouseDown={handleDropdownMouseDown}
|
||||
onClick={handleDropdownClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={handleDropdownScroll}
|
||||
onBlur={handleBlur}
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
@@ -1460,15 +1547,18 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
|
||||
{/* Navigation help footer */}
|
||||
<div className="navigation-footer" role="note">
|
||||
{!loading && !errorMessage && !noDataMessage && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
<ArrowUp size={8} className="icons" />
|
||||
<ArrowLeft size={8} className="icons" />
|
||||
<ArrowRight size={8} className="icons" />
|
||||
<span className="keyboard-text">to navigate</span>
|
||||
</section>
|
||||
)}
|
||||
{!loading &&
|
||||
!errorMessage &&
|
||||
!noDataMessage &&
|
||||
!(showIncompleteDataMessage && isScrolledToBottom) && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
<ArrowUp size={8} className="icons" />
|
||||
<ArrowLeft size={8} className="icons" />
|
||||
<ArrowRight size={8} className="icons" />
|
||||
<span className="keyboard-text">to navigate</span>
|
||||
</section>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="navigation-loading">
|
||||
<div className="navigation-icons">
|
||||
@@ -1494,9 +1584,19 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noDataMessage && !loading && (
|
||||
<div className="navigation-text">{noDataMessage}</div>
|
||||
)}
|
||||
{showIncompleteDataMessage &&
|
||||
isScrolledToBottom &&
|
||||
!loading &&
|
||||
!errorMessage && (
|
||||
<div className="navigation-text-incomplete">
|
||||
Use search for more options
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noDataMessage &&
|
||||
!loading &&
|
||||
!(showIncompleteDataMessage && isScrolledToBottom) &&
|
||||
!errorMessage && <div className="navigation-text">{noDataMessage}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1513,6 +1613,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
handleDropdownMouseDown,
|
||||
handleDropdownClick,
|
||||
handleKeyDown,
|
||||
handleDropdownScroll,
|
||||
handleBlur,
|
||||
activeIndex,
|
||||
loading,
|
||||
@@ -1522,8 +1623,31 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
renderOptionWithIndex,
|
||||
handleSelectAll,
|
||||
onRetry,
|
||||
showIncompleteDataMessage,
|
||||
isScrolledToBottom,
|
||||
]);
|
||||
|
||||
// Custom handler for dropdown visibility changes
|
||||
const handleDropdownVisibleChange = useCallback(
|
||||
(visible: boolean): void => {
|
||||
setIsOpen(visible);
|
||||
if (visible) {
|
||||
justOpenedRef.current = true;
|
||||
setActiveIndex(0);
|
||||
setActiveChipIndex(-1);
|
||||
} else {
|
||||
setSearchText('');
|
||||
setActiveIndex(-1);
|
||||
// Don't clear activeChipIndex when dropdown closes to maintain tag focus
|
||||
}
|
||||
// Pass through to the parent component's handler if provided
|
||||
if (onDropdownVisibleChange) {
|
||||
onDropdownVisibleChange(visible);
|
||||
}
|
||||
},
|
||||
[onDropdownVisibleChange],
|
||||
);
|
||||
|
||||
// ===== Side Effects =====
|
||||
|
||||
// Clear search when dropdown closes
|
||||
@@ -1588,52 +1712,9 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
const { label, value, closable, onClose } = props;
|
||||
|
||||
// If the display value is the special ALL value, render the ALL tag
|
||||
if (value === ALL_SELECTED_VALUE && isAllSelected) {
|
||||
const handleAllTagClose = (
|
||||
e: React.MouseEvent | React.KeyboardEvent,
|
||||
): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleInternalChange([]); // Clear selection when ALL tag is closed
|
||||
};
|
||||
|
||||
const handleAllTagKeyDown = (e: React.KeyboardEvent): void => {
|
||||
if (e.key === 'Enter' || e.key === SPACEKEY) {
|
||||
handleAllTagClose(e);
|
||||
}
|
||||
// Prevent Backspace/Delete propagation if needed, handle in main keydown handler
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('ant-select-selection-item', {
|
||||
'ant-select-selection-item-active': activeChipIndex === 0, // Treat ALL tag as index 0 when active
|
||||
'ant-select-selection-item-selected': selectedChips.includes(0),
|
||||
})}
|
||||
style={
|
||||
activeChipIndex === 0 || selectedChips.includes(0)
|
||||
? {
|
||||
borderColor: Color.BG_ROBIN_500,
|
||||
backgroundColor: Color.BG_SLATE_400,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="ant-select-selection-item-content">ALL</span>
|
||||
{closable && (
|
||||
<span
|
||||
className="ant-select-selection-item-remove"
|
||||
onClick={handleAllTagClose}
|
||||
onKeyDown={handleAllTagKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Remove ALL tag (deselect all)"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
if (allOptionShown) {
|
||||
// Don't render a visible tag - will be shown as placeholder
|
||||
return <div style={{ display: 'none' }} />;
|
||||
}
|
||||
|
||||
// If not isAllSelected, render individual tags using previous logic
|
||||
@@ -1713,52 +1794,69 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
// Fallback for safety, should not be reached
|
||||
return <div />;
|
||||
},
|
||||
[
|
||||
isAllSelected,
|
||||
handleInternalChange,
|
||||
activeChipIndex,
|
||||
selectedChips,
|
||||
selectedValues,
|
||||
maxTagCount,
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[isAllSelected, activeChipIndex, selectedChips, selectedValues, maxTagCount],
|
||||
);
|
||||
|
||||
// Simple onClear handler to prevent clearing ALL
|
||||
const onClearHandler = useCallback((): void => {
|
||||
// Skip clearing if ALL is selected
|
||||
if (allOptionShown || isAllSelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal clear behavior
|
||||
handleInternalChange([], true);
|
||||
if (onClear) onClear();
|
||||
}, [onClear, handleInternalChange, allOptionShown, isAllSelected]);
|
||||
|
||||
// ===== Component Rendering =====
|
||||
return (
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-multiselect', className, {
|
||||
'has-selection': selectedChips.length > 0 && !isAllSelected,
|
||||
'is-all-selected': isAllSelected,
|
||||
<div
|
||||
className={cx('custom-multiselect-wrapper', {
|
||||
'all-selected': allOptionShown || isAllSelected,
|
||||
})}
|
||||
placeholder={placeholder}
|
||||
mode="multiple"
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={displayValue}
|
||||
onChange={handleInternalChange}
|
||||
onClear={(): void => handleInternalChange([])}
|
||||
onDropdownVisibleChange={setIsOpen}
|
||||
open={isOpen}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={dropdownMatchSelectWidth}
|
||||
allowClear={allowClear}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
tagRender={tagRender as any}
|
||||
placement={placement}
|
||||
listHeight={300}
|
||||
searchValue={searchText}
|
||||
maxTagTextLength={maxTagTextLength}
|
||||
maxTagCount={isAllSelected ? 1 : maxTagCount}
|
||||
{...rest}
|
||||
/>
|
||||
>
|
||||
{(allOptionShown || isAllSelected) && !searchText && (
|
||||
<div className="all-text">ALL</div>
|
||||
)}
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-multiselect', className, {
|
||||
'has-selection': selectedChips.length > 0 && !isAllSelected,
|
||||
'is-all-selected': isAllSelected,
|
||||
})}
|
||||
placeholder={placeholder}
|
||||
mode="multiple"
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={displayValue}
|
||||
onChange={(newValue): void => {
|
||||
handleInternalChange(newValue, false);
|
||||
}}
|
||||
onClear={onClearHandler}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={dropdownMatchSelectWidth}
|
||||
allowClear={allowClear}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
tagRender={tagRender as any}
|
||||
placement={placement}
|
||||
listHeight={300}
|
||||
searchValue={searchText}
|
||||
maxTagTextLength={maxTagTextLength}
|
||||
maxTagCount={isAllSelected ? undefined : maxTagCount}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { CustomSelectProps, OptionData } from './types';
|
||||
import {
|
||||
filterOptionsBySearch,
|
||||
handleScrollToBottom,
|
||||
prioritizeOrAddOptionForSingleSelect,
|
||||
SPACEKEY,
|
||||
} from './utils';
|
||||
@@ -57,17 +58,29 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
errorMessage,
|
||||
allowClear = false,
|
||||
onRetry,
|
||||
showIncompleteDataMessage = false,
|
||||
...rest
|
||||
}) => {
|
||||
// ===== State & Refs =====
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(-1);
|
||||
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
|
||||
|
||||
// Refs for element access and scroll behavior
|
||||
const selectRef = useRef<BaseSelectRef>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
// Flag to track if dropdown just opened
|
||||
const justOpenedRef = useRef<boolean>(false);
|
||||
|
||||
// Add a scroll handler for the dropdown
|
||||
const handleDropdownScroll = useCallback(
|
||||
(e: React.UIEvent<HTMLDivElement>): void => {
|
||||
setIsScrolledToBottom(handleScrollToBottom(e));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ===== Option Filtering & Processing Utilities =====
|
||||
|
||||
@@ -130,23 +143,33 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
(text: string, searchQuery: string): React.ReactNode => {
|
||||
if (!searchQuery || !highlightSearch) return text;
|
||||
|
||||
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
// Create a deterministic but unique key
|
||||
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
||||
try {
|
||||
const parts = text.split(
|
||||
new RegExp(
|
||||
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
|
||||
'gi',
|
||||
),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
// Create a deterministic but unique key
|
||||
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
||||
|
||||
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||
<span key={uniqueKey} className="highlight-text">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||
<span key={uniqueKey} className="highlight-text">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in text highlighting:', error);
|
||||
return text;
|
||||
}
|
||||
},
|
||||
[highlightSearch],
|
||||
);
|
||||
@@ -246,9 +269,14 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
const trimmedValue = value.trim();
|
||||
setSearchText(trimmedValue);
|
||||
|
||||
// Reset active option index when search changes
|
||||
if (isOpen) {
|
||||
setActiveOptionIndex(0);
|
||||
}
|
||||
|
||||
if (onSearch) onSearch(trimmedValue);
|
||||
},
|
||||
[onSearch],
|
||||
[onSearch, isOpen],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -272,14 +300,23 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
const flatList: OptionData[] = [];
|
||||
|
||||
// Process options
|
||||
let processedOptions = isEmpty(value)
|
||||
? filteredOptions
|
||||
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value);
|
||||
|
||||
if (!isEmpty(searchText)) {
|
||||
processedOptions = filterOptionsBySearch(processedOptions, searchText);
|
||||
}
|
||||
|
||||
const { sectionOptions, nonSectionOptions } = splitOptions(
|
||||
isEmpty(value)
|
||||
? filteredOptions
|
||||
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value),
|
||||
processedOptions,
|
||||
);
|
||||
|
||||
// Add custom option if needed
|
||||
if (!isEmpty(searchText) && !isLabelPresent(filteredOptions, searchText)) {
|
||||
if (
|
||||
!isEmpty(searchText) &&
|
||||
!isLabelPresent(processedOptions, searchText)
|
||||
) {
|
||||
flatList.push({
|
||||
label: searchText,
|
||||
value: searchText,
|
||||
@@ -300,33 +337,52 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
|
||||
const options = getFlatOptions();
|
||||
|
||||
// If we just opened the dropdown and have options, set first option as active
|
||||
if (justOpenedRef.current && options.length > 0) {
|
||||
setActiveOptionIndex(0);
|
||||
justOpenedRef.current = false;
|
||||
}
|
||||
|
||||
// If no option is active but we have options, activate the first one
|
||||
if (activeOptionIndex === -1 && options.length > 0) {
|
||||
setActiveOptionIndex(0);
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
if (options.length > 0) {
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : options.length - 1,
|
||||
);
|
||||
if (options.length > 0) {
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : options.length - 1,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Tab':
|
||||
// Tab navigation with Shift key support
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : options.length - 1,
|
||||
);
|
||||
if (options.length > 0) {
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : options.length - 1,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
if (options.length > 0) {
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -339,6 +395,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
onChange(selectedOption.value, selectedOption);
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
setSearchText('');
|
||||
}
|
||||
} else if (!isEmpty(searchText)) {
|
||||
// Add custom value when no option is focused
|
||||
@@ -351,6 +408,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
onChange(customOption.value, customOption);
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
setSearchText('');
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -359,6 +417,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
setSearchText('');
|
||||
break;
|
||||
|
||||
case ' ': // Space key
|
||||
@@ -369,6 +428,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
onChange(selectedOption.value, selectedOption);
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
setSearchText('');
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -379,7 +439,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
// Open dropdown when Down or Tab is pressed while closed
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
setActiveOptionIndex(0);
|
||||
justOpenedRef.current = true; // Set flag to initialize active option on next render
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -444,6 +504,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
className="custom-select-dropdown"
|
||||
onClick={handleDropdownClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={handleDropdownScroll}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-activedescendant={
|
||||
@@ -454,7 +515,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
<div className="no-section-options">
|
||||
{nonSectionOptions.length > 0 && mapOptions(nonSectionOptions)}
|
||||
</div>
|
||||
|
||||
{/* Section options */}
|
||||
{sectionOptions.length > 0 &&
|
||||
sectionOptions.map((section) =>
|
||||
@@ -472,13 +532,16 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
|
||||
{/* Navigation help footer */}
|
||||
<div className="navigation-footer" role="note">
|
||||
{!loading && !errorMessage && !noDataMessage && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
<ArrowUp size={8} className="icons" />
|
||||
<span className="keyboard-text">to navigate</span>
|
||||
</section>
|
||||
)}
|
||||
{!loading &&
|
||||
!errorMessage &&
|
||||
!noDataMessage &&
|
||||
!(showIncompleteDataMessage && isScrolledToBottom) && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
<ArrowUp size={8} className="icons" />
|
||||
<span className="keyboard-text">to navigate</span>
|
||||
</section>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="navigation-loading">
|
||||
<div className="navigation-icons">
|
||||
@@ -504,9 +567,19 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noDataMessage && !loading && (
|
||||
<div className="navigation-text">{noDataMessage}</div>
|
||||
)}
|
||||
{showIncompleteDataMessage &&
|
||||
isScrolledToBottom &&
|
||||
!loading &&
|
||||
!errorMessage && (
|
||||
<div className="navigation-text-incomplete">
|
||||
Use search for more options
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noDataMessage &&
|
||||
!loading &&
|
||||
!(showIncompleteDataMessage && isScrolledToBottom) &&
|
||||
!errorMessage && <div className="navigation-text">{noDataMessage}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -520,6 +593,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
isLabelPresent,
|
||||
handleDropdownClick,
|
||||
handleKeyDown,
|
||||
handleDropdownScroll,
|
||||
activeOptionIndex,
|
||||
loading,
|
||||
errorMessage,
|
||||
@@ -527,8 +601,22 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
dropdownRender,
|
||||
renderOptionWithIndex,
|
||||
onRetry,
|
||||
showIncompleteDataMessage,
|
||||
isScrolledToBottom,
|
||||
]);
|
||||
|
||||
// Handle dropdown visibility changes
|
||||
const handleDropdownVisibleChange = useCallback((visible: boolean): void => {
|
||||
setIsOpen(visible);
|
||||
if (visible) {
|
||||
justOpenedRef.current = true;
|
||||
setActiveOptionIndex(0);
|
||||
} else {
|
||||
setSearchText('');
|
||||
setActiveOptionIndex(-1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ===== Side Effects =====
|
||||
|
||||
// Clear search text when dropdown closes
|
||||
@@ -582,7 +670,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
onSearch={handleSearch}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={setIsOpen}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
options={optionsWithHighlight}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
|
||||
@@ -35,6 +35,43 @@ $custom-border-color: #2c3044;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
&.is-all-selected {
|
||||
.ant-select-selection-search-input {
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
opacity: 1 !important;
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
font-weight: 500;
|
||||
visibility: visible !important;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
|
||||
.lightMode & {
|
||||
color: rgba(0, 0, 0, 0.85) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selection-placeholder {
|
||||
opacity: 0.45 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.all-selected-text {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--bg-vanilla-400);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
|
||||
.lightMode & {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
@@ -158,7 +195,7 @@ $custom-border-color: #2c3044;
|
||||
// Custom dropdown styles for single select
|
||||
.custom-select-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
max-height: 500px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
@@ -276,6 +313,10 @@ $custom-border-color: #2c3044;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.navigation-text-incomplete {
|
||||
color: var(--bg-amber-600) !important;
|
||||
}
|
||||
|
||||
.navigation-error {
|
||||
.navigation-text,
|
||||
.navigation-icons {
|
||||
@@ -322,7 +363,7 @@ $custom-border-color: #2c3044;
|
||||
// Custom dropdown styles for multi-select
|
||||
.custom-multiselect-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
max-height: 500px;
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
@@ -656,6 +697,10 @@ $custom-border-color: #2c3044;
|
||||
border: 1px solid #e8e8e8;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
font-size: 12px !important;
|
||||
height: 20px;
|
||||
line-height: 18px;
|
||||
|
||||
.ant-select-selection-item-content {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
@@ -836,3 +881,38 @@ $custom-border-color: #2c3044;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-multiselect-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&.all-selected {
|
||||
.all-text {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--bg-vanilla-400);
|
||||
font-weight: 500;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
|
||||
.lightMode & {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-within .all-text {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.ant-select-selection-search-input {
|
||||
caret-color: auto;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,10 @@ export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
|
||||
highlightSearch?: boolean;
|
||||
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
||||
popupMatchSelectWidth?: boolean;
|
||||
errorMessage?: string;
|
||||
errorMessage?: string | null;
|
||||
allowClear?: SelectProps['allowClear'];
|
||||
onRetry?: () => void;
|
||||
showIncompleteDataMessage?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomTagProps {
|
||||
@@ -51,10 +52,12 @@ export interface CustomMultiSelectProps
|
||||
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
|
||||
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
|
||||
highlightSearch?: boolean;
|
||||
errorMessage?: string;
|
||||
errorMessage?: string | null;
|
||||
popupClassName?: string;
|
||||
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
||||
maxTagCount?: number;
|
||||
allowClear?: SelectProps['allowClear'];
|
||||
onRetry?: () => void;
|
||||
maxTagTextLength?: number;
|
||||
showIncompleteDataMessage?: boolean;
|
||||
}
|
||||
|
||||
@@ -133,3 +133,15 @@ export const filterOptionsBySearch = (
|
||||
})
|
||||
.filter(Boolean) as OptionData[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to handle dropdown scroll and detect when scrolled to bottom
|
||||
* Returns true when scrolled to within 20px of the bottom
|
||||
*/
|
||||
export const handleScrollToBottom = (
|
||||
e: React.UIEvent<HTMLDivElement>,
|
||||
): boolean => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
// Consider "scrolled to bottom" when within 20px of the bottom or at the bottom
|
||||
return scrollHeight - scrollTop - clientHeight < 20;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.order-by-loading-container {
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
115
frontend/src/components/OrderBy/ListViewOrderBy.tsx
Normal file
115
frontend/src/components/OrderBy/ListViewOrderBy.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import './ListViewOrderBy.styles.scss';
|
||||
|
||||
import { Select, Spin } from 'antd';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
interface ListViewOrderByProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
dataSource: DataSource;
|
||||
}
|
||||
|
||||
// Loader component for the dropdown when loading or no results
|
||||
function Loader({ isLoading }: { isLoading: boolean }): JSX.Element {
|
||||
return (
|
||||
<div className="order-by-loading-container">
|
||||
{isLoading ? <Spin size="default" /> : 'No results found'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListViewOrderBy({
|
||||
value,
|
||||
onChange,
|
||||
dataSource,
|
||||
}: ListViewOrderByProps): JSX.Element {
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [debouncedInput, setDebouncedInput] = useState('');
|
||||
const [selectOptions, setSelectOptions] = useState<
|
||||
{ label: string; value: string }[]
|
||||
>([]);
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Fetch key suggestions based on debounced input
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['orderByKeySuggestions', dataSource, debouncedInput],
|
||||
queryFn: async () => {
|
||||
const response = await getKeySuggestions({
|
||||
signal: dataSource,
|
||||
searchText: debouncedInput,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Update options when API data changes
|
||||
useEffect(() => {
|
||||
const rawKeys: QueryKeyDataSuggestionsProps[] = data?.data?.keys
|
||||
? Object.values(data.data?.keys).flat()
|
||||
: [];
|
||||
|
||||
const keyNames = rawKeys.map((key) => key.name);
|
||||
const uniqueKeys = [
|
||||
...new Set(searchInput ? keyNames : ['timestamp', ...keyNames]),
|
||||
];
|
||||
|
||||
const updatedOptions = uniqueKeys.flatMap((key) => [
|
||||
{ label: `${key} (desc)`, value: `${key}:desc` },
|
||||
{ label: `${key} (asc)`, value: `${key}:asc` },
|
||||
]);
|
||||
|
||||
setSelectOptions(updatedOptions);
|
||||
}, [data, searchInput]);
|
||||
|
||||
// Handle search input with debounce
|
||||
const handleSearch = (input: string): void => {
|
||||
setSearchInput(input);
|
||||
|
||||
// Filter current options for instant client-side match
|
||||
const filteredOptions = selectOptions.filter((option) =>
|
||||
option.value.toLowerCase().includes(input.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
// If no match found or input is empty, trigger debounced fetch
|
||||
if (filteredOptions.length === 0 || input === '') {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
setDebouncedInput(input);
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSearch={handleSearch}
|
||||
notFoundContent={<Loader isLoading={isLoading} />}
|
||||
placeholder="Select an attribute"
|
||||
style={{ width: 200 }}
|
||||
options={selectOptions}
|
||||
filterOption={(input, option): boolean =>
|
||||
(option?.value ?? '').toLowerCase().includes(input.trim().toLowerCase())
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListViewOrderBy;
|
||||
@@ -0,0 +1,19 @@
|
||||
.loading-panel-data {
|
||||
padding: 24px 0;
|
||||
height: 240px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
|
||||
.loading-panel-data-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
.loading-gif {
|
||||
height: 72px;
|
||||
margin-left: -24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import './PanelDataLoading.styles.scss';
|
||||
|
||||
import { Typography } from 'antd';
|
||||
|
||||
export function PanelDataLoading(): JSX.Element {
|
||||
return (
|
||||
<div className="loading-panel-data">
|
||||
<div className="loading-panel-data-content">
|
||||
<img
|
||||
className="loading-gif"
|
||||
src="/Icons/loading-plane.gif"
|
||||
alt="wait-icon"
|
||||
/>
|
||||
|
||||
<Typography.Text>Fetching data...</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
.query-builder-v2 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
|
||||
width: 100%;
|
||||
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
border-top: 1px solid var(--bg-slate-400);
|
||||
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', sans-serif;
|
||||
|
||||
border-right: none;
|
||||
border-left: none;
|
||||
|
||||
.qb-content-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: calc(100% - 44px);
|
||||
|
||||
flex: 1;
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.qb-content-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
|
||||
flex: 1;
|
||||
|
||||
.qb-header-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
margin-left: 32px;
|
||||
|
||||
.query-actions-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.qb-elements-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
margin-left: 108px;
|
||||
|
||||
.code-mirror-where-clause,
|
||||
.query-aggregation-container,
|
||||
.query-add-ons,
|
||||
.metrics-aggregation-section-content {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
top: 12px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-left: 6px dotted #1d212d;
|
||||
}
|
||||
|
||||
/* Horizontal line pointing from vertical to the item */
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
top: 15px;
|
||||
width: 24px;
|
||||
height: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to right,
|
||||
#1d212d,
|
||||
#1d212d 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.where-clause-view {
|
||||
.qb-content-section {
|
||||
.qb-elements-container {
|
||||
margin-left: 0px;
|
||||
|
||||
.code-mirror-where-clause,
|
||||
.query-aggregation-container,
|
||||
.query-add-ons,
|
||||
.metrics-aggregation-section-content {
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-names-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
width: 44px;
|
||||
padding: 8px;
|
||||
|
||||
border-left: 1px solid var(--bg-slate-400);
|
||||
|
||||
.query-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
padding: 4px;
|
||||
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgba(242, 71, 105, 0.2);
|
||||
background: rgba(242, 71, 105, 0.1);
|
||||
|
||||
color: var(--Sakura-400, #f56c87);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px; /* 128.571% */
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.formula-name {
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
padding: 4px;
|
||||
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgba(173, 127, 88, 0.2);
|
||||
background: rgba(173, 127, 88, 0.1);
|
||||
|
||||
color: var(--Sienna-500, #ad7f58);
|
||||
font-family: 'Space Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px; /* 128.571% */
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.qb-formulas-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
margin-left: 32px;
|
||||
padding-bottom: 16px;
|
||||
padding-left: 8px;
|
||||
|
||||
.qb-formula {
|
||||
.ant-row {
|
||||
row-gap: 8px !important;
|
||||
}
|
||||
|
||||
.qb-entity-options {
|
||||
margin-left: 8px;
|
||||
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.formula-container {
|
||||
margin-left: 82px;
|
||||
padding: 4px 0px;
|
||||
|
||||
.ant-col {
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
top: 12px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-left: 6px dotted #1d212d;
|
||||
}
|
||||
|
||||
/* Horizontal line pointing from vertical to the item */
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
top: 15px;
|
||||
width: 24px;
|
||||
height: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to right,
|
||||
#1d212d,
|
||||
#1d212d 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.formula-expression {
|
||||
border-bottom-left-radius: 0px !important;
|
||||
border-bottom-right-radius: 0px !important;
|
||||
|
||||
font-family: 'Space Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px; /* 128.571% */
|
||||
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.formula-legend {
|
||||
border-top-left-radius: 0px !important;
|
||||
border-top-right-radius: 0px !important;
|
||||
|
||||
.ant-input-group-addon {
|
||||
border-top-left-radius: 0px !important;
|
||||
border-top-right-radius: 0px !important;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
border-top-left-radius: 0px !important;
|
||||
border-top-right-radius: 0px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qb-footer {
|
||||
padding: 0 8px 16px 8px;
|
||||
|
||||
.qb-footer-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
|
||||
margin-left: 32px;
|
||||
|
||||
.qb-add-new-query {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
height: calc(100% - 82px);
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 56px;
|
||||
top: 31px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
#1d212d,
|
||||
#1d212d 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qb-entity-options {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
|
||||
.options {
|
||||
.query-name {
|
||||
border-radius: 0px 2px 2px 0px !important;
|
||||
border: 1px solid rgba(242, 71, 105, 0.2) !important;
|
||||
background: rgba(242, 71, 105, 0.1) !important;
|
||||
|
||||
color: var(--Sakura-400, #f56c87) !important;
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
height: 120px;
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 31px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
#1d212d,
|
||||
#1d212d 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
left: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.formula-name {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid rgba(173, 127, 88, 0.2);
|
||||
background: rgba(173, 127, 88, 0.1);
|
||||
|
||||
font-family: 'Space Mono';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
height: 65px;
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 31px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
#1d212d,
|
||||
#1d212d 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
left: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-data-source {
|
||||
margin-left: 8px;
|
||||
|
||||
.ant-select-selector {
|
||||
min-width: 120px;
|
||||
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--Slate-400, #1d212d);
|
||||
background: var(--Ink-300, #16181d);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qb-search-container {
|
||||
.metrics-select-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.qb-search-filter-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
flex-wrap: wrap;
|
||||
|
||||
.query-search-container {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.traces-search-filter-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--Slate-400, #1d212d) !important;
|
||||
background: var(--Ink-300, #16181d) !important;
|
||||
height: 34px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.query-actions-dropdown {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.query-builder-v2 {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
border-top: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.qb-content-section {
|
||||
.qb-elements-container {
|
||||
.code-mirror-where-clause,
|
||||
.query-aggregation-container,
|
||||
.query-add-ons,
|
||||
.metrics-aggregation-section-content {
|
||||
&::before {
|
||||
border-left: 6px dotted var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
/* Horizontal line pointing from vertical to the item */
|
||||
&::after {
|
||||
background: repeating-linear-gradient(
|
||||
to right,
|
||||
var(--bg-vanilla-300),
|
||||
var(--bg-vanilla-300) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-names-section {
|
||||
border-left: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.qb-formulas-container {
|
||||
.qb-formula {
|
||||
.formula-container {
|
||||
.ant-col {
|
||||
&::before {
|
||||
border-left: 6px dotted var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
/* Horizontal line pointing from vertical to the item */
|
||||
&::after {
|
||||
background: repeating-linear-gradient(
|
||||
to right,
|
||||
var(--bg-vanilla-300),
|
||||
var(--bg-vanilla-300) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qb-footer {
|
||||
.qb-footer-container {
|
||||
.qb-add-new-query {
|
||||
&::before {
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--bg-vanilla-300),
|
||||
var(--bg-vanilla-300) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qb-entity-options {
|
||||
.options {
|
||||
.query-name {
|
||||
&::before {
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--bg-vanilla-300),
|
||||
var(--bg-vanilla-300) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.formula-name {
|
||||
&::before {
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--bg-vanilla-300),
|
||||
var(--bg-vanilla-300) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
left: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-data-source {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qb-search-filter-container {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
187
frontend/src/components/QueryBuilderV2/QueryBuilderV2.tsx
Normal file
187
frontend/src/components/QueryBuilderV2/QueryBuilderV2.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import './QueryBuilderV2.styles.scss';
|
||||
|
||||
import { OPERATORS, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Formula } from 'container/QueryBuilder/components/Formula';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { memo, useEffect, useMemo, useRef } from 'react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderV2Provider } from './QueryBuilderV2Context';
|
||||
import QueryFooter from './QueryV2/QueryFooter/QueryFooter';
|
||||
import { QueryV2 } from './QueryV2/QueryV2';
|
||||
|
||||
export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
config,
|
||||
panelType: newPanelType,
|
||||
filterConfigs = {},
|
||||
queryComponents,
|
||||
isListViewPanel = false,
|
||||
showOnlyWhereClause = false,
|
||||
version,
|
||||
}: QueryBuilderProps): JSX.Element {
|
||||
const {
|
||||
currentQuery,
|
||||
addNewBuilderQuery,
|
||||
addNewFormula,
|
||||
handleSetConfig,
|
||||
panelType,
|
||||
initialDataSource,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const containerRef = useRef(null);
|
||||
|
||||
const currentDataSource = useMemo(
|
||||
() =>
|
||||
(config && config.queryVariant === 'static' && config.initialDataSource) ||
|
||||
null,
|
||||
[config],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentDataSource !== initialDataSource || newPanelType !== panelType) {
|
||||
if (newPanelType === PANEL_TYPES.BAR) {
|
||||
handleSetConfig(PANEL_TYPES.BAR, DataSource.METRICS);
|
||||
return;
|
||||
}
|
||||
handleSetConfig(newPanelType, currentDataSource);
|
||||
}
|
||||
}, [
|
||||
handleSetConfig,
|
||||
panelType,
|
||||
initialDataSource,
|
||||
currentDataSource,
|
||||
newPanelType,
|
||||
]);
|
||||
|
||||
const listViewLogFilterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
filters: {
|
||||
customKey: 'body',
|
||||
customOp: OPERATORS.CONTAINS,
|
||||
},
|
||||
};
|
||||
|
||||
return config;
|
||||
}, []);
|
||||
|
||||
const listViewTracesFilterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
limit: { isHidden: true, isDisabled: true },
|
||||
filters: {
|
||||
customKey: 'body',
|
||||
customOp: OPERATORS.CONTAINS,
|
||||
},
|
||||
};
|
||||
|
||||
return config;
|
||||
}, []);
|
||||
|
||||
const queryFilterConfigs = useMemo(() => {
|
||||
if (isListViewPanel) {
|
||||
return currentQuery.builder.queryData[0].dataSource === DataSource.TRACES
|
||||
? listViewTracesFilterConfigs
|
||||
: listViewLogFilterConfigs;
|
||||
}
|
||||
|
||||
return filterConfigs;
|
||||
}, [
|
||||
isListViewPanel,
|
||||
filterConfigs,
|
||||
currentQuery.builder.queryData,
|
||||
listViewLogFilterConfigs,
|
||||
listViewTracesFilterConfigs,
|
||||
]);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2Provider>
|
||||
<div className="query-builder-v2">
|
||||
<div className="qb-content-container">
|
||||
{isListViewPanel && (
|
||||
<QueryV2
|
||||
ref={containerRef}
|
||||
key={currentQuery.builder.queryData[0].queryName}
|
||||
index={0}
|
||||
query={currentQuery.builder.queryData[0]}
|
||||
filterConfigs={queryFilterConfigs}
|
||||
queryComponents={queryComponents}
|
||||
version={version}
|
||||
isAvailableToDisable={false}
|
||||
queryVariant={config?.queryVariant || 'dropdown'}
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
isListViewPanel={isListViewPanel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isListViewPanel &&
|
||||
currentQuery.builder.queryData.map((query, index) => (
|
||||
<QueryV2
|
||||
ref={containerRef}
|
||||
key={query.queryName}
|
||||
index={index}
|
||||
query={query}
|
||||
filterConfigs={queryFilterConfigs}
|
||||
queryComponents={queryComponents}
|
||||
version={version}
|
||||
isAvailableToDisable={false}
|
||||
queryVariant={config?.queryVariant || 'dropdown'}
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
isListViewPanel={isListViewPanel}
|
||||
signalSource={config?.signalSource || ''}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!showOnlyWhereClause && currentQuery.builder.queryFormulas.length > 0 && (
|
||||
<div className="qb-formulas-container">
|
||||
{currentQuery.builder.queryFormulas.map((formula, index) => {
|
||||
const query =
|
||||
currentQuery.builder.queryData[index] ||
|
||||
currentQuery.builder.queryData[0];
|
||||
|
||||
return (
|
||||
<div key={formula.queryName} className="qb-formula">
|
||||
<Formula
|
||||
filterConfigs={filterConfigs}
|
||||
query={query}
|
||||
formula={formula}
|
||||
index={index}
|
||||
isAdditionalFilterEnable={false}
|
||||
isQBV2
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showOnlyWhereClause && !isListViewPanel && (
|
||||
<QueryFooter
|
||||
addNewBuilderQuery={addNewBuilderQuery}
|
||||
addNewFormula={addNewFormula}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!showOnlyWhereClause && !isListViewPanel && (
|
||||
<div className="query-names-section">
|
||||
{currentQuery.builder.queryData.map((query) => (
|
||||
<div key={query.queryName} className="query-name">
|
||||
{query.queryName}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{currentQuery.builder.queryFormulas.map((formula) => (
|
||||
<div key={formula.queryName} className="formula-name">
|
||||
{formula.queryName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</QueryBuilderV2Provider>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
// Types for the context state
|
||||
export type AggregationOption = { func: string; arg: string };
|
||||
|
||||
interface QueryBuilderV2ContextType {
|
||||
searchText: string;
|
||||
setSearchText: (text: string) => void;
|
||||
aggregationOptionsMap: Record<string, AggregationOption[]>;
|
||||
setAggregationOptions: (
|
||||
queryName: string,
|
||||
options: AggregationOption[],
|
||||
) => void;
|
||||
getAggregationOptions: (queryName: string) => AggregationOption[];
|
||||
aggregationInterval: string;
|
||||
setAggregationInterval: (interval: string) => void;
|
||||
queryAddValues: any; // Replace 'any' with a more specific type if available
|
||||
setQueryAddValues: (values: any) => void;
|
||||
}
|
||||
|
||||
const QueryBuilderV2Context = createContext<
|
||||
QueryBuilderV2ContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function QueryBuilderV2Provider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [aggregationOptionsMap, setAggregationOptionsMap] = useState<
|
||||
Record<string, AggregationOption[]>
|
||||
>({});
|
||||
const [aggregationInterval, setAggregationInterval] = useState('');
|
||||
const [queryAddValues, setQueryAddValues] = useState<any>(null); // Replace 'any' if you have a type
|
||||
|
||||
const setAggregationOptions = useCallback(
|
||||
(queryName: string, options: AggregationOption[]): void => {
|
||||
setAggregationOptionsMap((prev) => ({
|
||||
...prev,
|
||||
[queryName]: options,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getAggregationOptions = useCallback(
|
||||
(queryName: string): AggregationOption[] =>
|
||||
aggregationOptionsMap[queryName] || [],
|
||||
[aggregationOptionsMap],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2Context.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
searchText,
|
||||
setSearchText,
|
||||
aggregationOptionsMap,
|
||||
setAggregationOptions,
|
||||
getAggregationOptions,
|
||||
aggregationInterval,
|
||||
setAggregationInterval,
|
||||
queryAddValues,
|
||||
setQueryAddValues,
|
||||
}),
|
||||
[
|
||||
searchText,
|
||||
aggregationOptionsMap,
|
||||
aggregationInterval,
|
||||
queryAddValues,
|
||||
getAggregationOptions,
|
||||
setAggregationOptions,
|
||||
],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</QueryBuilderV2Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useQueryBuilderV2Context = (): QueryBuilderV2ContextType => {
|
||||
const context = useContext(QueryBuilderV2Context);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useQueryBuilderV2Context must be used within a QueryBuilderV2Provider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
.metrics-aggregate-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin: 4px 0;
|
||||
|
||||
.metrics-time-aggregation-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.non-histogram-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&:not(.is-histogram) {
|
||||
.metrics-time-aggregation-section,
|
||||
.metrics-space-aggregation-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.metrics-aggregation-section-content {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-space-aggregation-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.metrics-space-aggregation-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
color: var(--Slate-50, #62687c);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.48px;
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-aggregation-section-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.group-by-filter-container {
|
||||
min-width: 340px !important;
|
||||
}
|
||||
|
||||
.metrics-aggregation-section-content-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.metrics-aggregation-section-content-item-label {
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
&.main-label {
|
||||
color: var(--Slate-50, #62687c);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-aggregation-section-content-item-value {
|
||||
min-width: 140px;
|
||||
|
||||
.ant-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 2px;
|
||||
border: 1.005px solid var(--Slate-400, #1d212d);
|
||||
background: var(--Ink-300, #16181d);
|
||||
}
|
||||
|
||||
.input-with-label {
|
||||
.label {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: initial;
|
||||
width: 100px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-histogram {
|
||||
.group-by-filter-container {
|
||||
width: 420px;
|
||||
}
|
||||
|
||||
.histogram-every-input {
|
||||
.input {
|
||||
flex: initial;
|
||||
width: 100px !important;
|
||||
}
|
||||
|
||||
.label {
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-operators-select {
|
||||
border-radius: 2px;
|
||||
border: 1.005px solid var(--Slate-400, #1d212d);
|
||||
background: var(--Ink-300, #16181d);
|
||||
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.metrics-aggregate-section {
|
||||
.metrics-aggregation-section-content {
|
||||
.metrics-aggregation-section-content-item {
|
||||
.metrics-aggregation-section-content-item-label {
|
||||
color: var(--text-ink-200);
|
||||
|
||||
&.main-label {
|
||||
color: var(--text-slate-100);
|
||||
}
|
||||
}
|
||||
|
||||
.metrics-aggregation-section-content-item-value {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.metrics-operators-select {
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
color: var(--text-ink-100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import './MetricsAggregateSection.styles.scss';
|
||||
|
||||
import { Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import SpaceAggregationOptions from 'container/QueryBuilder/components/SpaceAggregationOptions/SpaceAggregationOptions';
|
||||
import { GroupByFilter, OperatorsSelect } from 'container/QueryBuilder/filters';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
|
||||
import { useQueryBuilderV2Context } from '../../QueryBuilderV2Context';
|
||||
|
||||
const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
query,
|
||||
index,
|
||||
version,
|
||||
panelType,
|
||||
signalSource = '',
|
||||
}: {
|
||||
query: IBuilderQuery;
|
||||
index: number;
|
||||
version: string;
|
||||
panelType: PANEL_TYPES | null;
|
||||
signalSource: string;
|
||||
}): JSX.Element {
|
||||
const { setAggregationOptions } = useQueryBuilderV2Context();
|
||||
const {
|
||||
operators,
|
||||
spaceAggregationOptions,
|
||||
handleChangeQueryData,
|
||||
handleChangeOperator,
|
||||
handleSpaceAggregationChange,
|
||||
} = useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
entityVersion: version,
|
||||
});
|
||||
|
||||
// this function is only relevant for metrics and now operators are part of aggregations
|
||||
const queryAggregation = useMemo(
|
||||
() => query.aggregations?.[0] as MetricAggregation,
|
||||
[query.aggregations],
|
||||
);
|
||||
|
||||
const isHistogram = useMemo(
|
||||
() => query.aggregateAttribute?.type === ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
[query.aggregateAttribute?.type],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setAggregationOptions(query.queryName, [
|
||||
{
|
||||
func: queryAggregation.spaceAggregation || 'count',
|
||||
arg: queryAggregation.metricName || '',
|
||||
},
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
queryAggregation.spaceAggregation,
|
||||
queryAggregation.metricName,
|
||||
query.queryName,
|
||||
]);
|
||||
|
||||
const handleChangeGroupByKeys = useCallback(
|
||||
(value: IBuilderQuery['groupBy']) => {
|
||||
handleChangeQueryData('groupBy', value);
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
const handleChangeAggregateEvery = useCallback(
|
||||
(value: string) => {
|
||||
handleChangeQueryData('stepInterval', Number(value));
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
const showAggregationInterval = useMemo(() => {
|
||||
// eslint-disable-next-line sonarjs/prefer-single-boolean-return
|
||||
if (panelType === PANEL_TYPES.VALUE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [panelType]);
|
||||
|
||||
const disableOperatorSelector =
|
||||
!queryAggregation.metricName || queryAggregation.metricName === '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('metrics-aggregate-section', {
|
||||
'is-histogram': isHistogram,
|
||||
})}
|
||||
>
|
||||
{!isHistogram && (
|
||||
<div className="non-histogram-container">
|
||||
<div className="metrics-time-aggregation-section">
|
||||
<div className="metrics-aggregation-section-content">
|
||||
<div className="metrics-aggregation-section-content-item">
|
||||
<Tooltip
|
||||
title={
|
||||
<a
|
||||
href="https://signoz.io/docs/metrics-management/types-and-aggregation/#aggregation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
>
|
||||
Learn more about temporal aggregation
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<div className="metrics-aggregation-section-content-item-label main-label">
|
||||
AGGREGATE WITHIN TIME SERIES{' '}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="metrics-aggregation-section-content-item-value">
|
||||
<OperatorsSelect
|
||||
value={queryAggregation.timeAggregation || ''}
|
||||
onChange={handleChangeOperator}
|
||||
operators={operators}
|
||||
className="metrics-operators-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAggregationInterval && (
|
||||
<div className="metrics-aggregation-section-content-item">
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
>
|
||||
Learn about step intervals
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<div
|
||||
className="metrics-aggregation-section-content-item-label"
|
||||
style={{ cursor: 'help' }}
|
||||
>
|
||||
every
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div className="metrics-aggregation-section-content-item-value">
|
||||
<InputWithLabel
|
||||
onChange={handleChangeAggregateEvery}
|
||||
label="Seconds"
|
||||
placeholder="Auto"
|
||||
labelAfter
|
||||
initialValue={query?.stepInterval ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="metrics-space-aggregation-section">
|
||||
<div className="metrics-aggregation-section-content">
|
||||
<div className="metrics-aggregation-section-content-item">
|
||||
<Tooltip
|
||||
title={
|
||||
<a
|
||||
href="https://signoz.io/docs/metrics-management/types-and-aggregation/#aggregation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
>
|
||||
Learn more about spatial aggregation
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<div className="metrics-aggregation-section-content-item-label main-label">
|
||||
AGGREGATE ACROSS TIME SERIES
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="metrics-aggregation-section-content-item-value">
|
||||
<SpaceAggregationOptions
|
||||
panelType={panelType}
|
||||
key={`${panelType}${queryAggregation.spaceAggregation}${queryAggregation.timeAggregation}`}
|
||||
aggregatorAttributeType={
|
||||
query?.aggregateAttribute?.type as ATTRIBUTE_TYPES
|
||||
}
|
||||
selectedValue={queryAggregation.spaceAggregation || ''}
|
||||
disabled={disableOperatorSelector}
|
||||
onSelect={handleSpaceAggregationChange}
|
||||
operators={spaceAggregationOptions}
|
||||
qbVersion="v3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metrics-aggregation-section-content-item">
|
||||
<div className="metrics-aggregation-section-content-item-label">by</div>
|
||||
|
||||
<div className="metrics-aggregation-section-content-item-value group-by-filter-container">
|
||||
<GroupByFilter
|
||||
disabled={!queryAggregation.metricName}
|
||||
query={query}
|
||||
onChange={handleChangeGroupByKeys}
|
||||
signalSource={signalSource}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHistogram && (
|
||||
<div className="metrics-space-aggregation-section">
|
||||
<div className="metrics-aggregation-section-content">
|
||||
<div className="metrics-aggregation-section-content-item">
|
||||
<div className="metrics-aggregation-section-content-item-value">
|
||||
<SpaceAggregationOptions
|
||||
panelType={panelType}
|
||||
key={`${panelType}${queryAggregation.spaceAggregation}${queryAggregation.timeAggregation}`}
|
||||
aggregatorAttributeType={
|
||||
query?.aggregateAttribute?.type as ATTRIBUTE_TYPES
|
||||
}
|
||||
selectedValue={queryAggregation.spaceAggregation || ''}
|
||||
disabled={disableOperatorSelector}
|
||||
onSelect={handleSpaceAggregationChange}
|
||||
operators={spaceAggregationOptions}
|
||||
qbVersion="v3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metrics-aggregation-section-content-item">
|
||||
<div className="metrics-aggregation-section-content-item-label">by</div>
|
||||
|
||||
<div className="metrics-aggregation-section-content-item-value group-by-filter-container">
|
||||
<GroupByFilter
|
||||
disabled={!queryAggregation.metricName}
|
||||
query={query}
|
||||
onChange={handleChangeGroupByKeys}
|
||||
signalSource={signalSource}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="metrics-aggregation-section-content-item">
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
>
|
||||
Learn about step intervals
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<div
|
||||
className="metrics-aggregation-section-content-item-label"
|
||||
style={{ cursor: 'help' }}
|
||||
>
|
||||
every
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div className="metrics-aggregation-section-content-item-value">
|
||||
<InputWithLabel
|
||||
onChange={handleChangeAggregateEvery}
|
||||
label="Seconds"
|
||||
placeholder="Auto"
|
||||
labelAfter
|
||||
initialValue={query?.stepInterval ?? undefined}
|
||||
className="histogram-every-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default MetricsAggregateSection;
|
||||
@@ -0,0 +1,75 @@
|
||||
.metrics-select-container {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.ant-select-selector {
|
||||
width: 100%;
|
||||
border-radius: 2px;
|
||||
border: 1px solid #1d212d !important;
|
||||
background: #16181d;
|
||||
color: #fff;
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.ant-select-dropdown {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
rgba(18, 19, 23, 0.8) 0%,
|
||||
rgba(18, 19, 23, 0.9) 98.68%
|
||||
);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
.ant-select-item {
|
||||
color: #fff;
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
|
||||
&:hover {
|
||||
background: rgba(171, 189, 255, 0.04) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.metrics-select-container {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--text-ink-100);
|
||||
}
|
||||
|
||||
.ant-select-dropdown {
|
||||
background: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
backdrop-filter: none;
|
||||
|
||||
.ant-select-item {
|
||||
color: var(--text-ink-100);
|
||||
|
||||
&:hover,
|
||||
&.ant-select-item-option-active {
|
||||
background: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
|
||||
&.ant-select-item-option-selected {
|
||||
background: var(--bg-vanilla-300) !important;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import './MetricsSelect.styles.scss';
|
||||
|
||||
import { AggregatorFilter } from 'container/QueryBuilder/filters';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { memo } from 'react';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const MetricsSelect = memo(function MetricsSelect({
|
||||
query,
|
||||
index,
|
||||
version,
|
||||
signalSource,
|
||||
}: {
|
||||
query: IBuilderQuery;
|
||||
index: number;
|
||||
version: string;
|
||||
signalSource: 'meter' | '';
|
||||
}): JSX.Element {
|
||||
const { handleChangeAggregatorAttribute } = useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
entityVersion: version,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="metrics-select-container">
|
||||
<AggregatorFilter
|
||||
onChange={handleChangeAggregatorAttribute}
|
||||
query={query}
|
||||
index={index}
|
||||
signalSource={signalSource || ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import {
|
||||
autocompletion,
|
||||
closeCompletion,
|
||||
Completion,
|
||||
CompletionContext,
|
||||
completionKeymap,
|
||||
CompletionResult,
|
||||
startCompletion,
|
||||
} from '@codemirror/autocomplete';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { copilot } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror, { EditorView, keymap } from '@uiw/react-codemirror';
|
||||
import { Button } from 'antd';
|
||||
import { Having } from 'api/v5/v5';
|
||||
import { useQueryBuilderV2Context } from 'components/QueryBuilderV2/QueryBuilderV2Context';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { ChevronUp } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
const havingOperators = [
|
||||
{
|
||||
label: '=',
|
||||
value: '=',
|
||||
},
|
||||
{
|
||||
label: '!=',
|
||||
value: '!=',
|
||||
},
|
||||
{
|
||||
label: '>',
|
||||
value: '>',
|
||||
},
|
||||
{
|
||||
label: '<',
|
||||
value: '<',
|
||||
},
|
||||
{
|
||||
label: '>=',
|
||||
value: '>=',
|
||||
},
|
||||
{
|
||||
label: '<=',
|
||||
value: '<=',
|
||||
},
|
||||
{
|
||||
label: 'IN',
|
||||
value: 'IN',
|
||||
},
|
||||
{
|
||||
label: 'NOT_IN',
|
||||
value: 'NOT_IN',
|
||||
},
|
||||
];
|
||||
|
||||
const conjunctions = [
|
||||
{ label: 'AND', value: 'AND ' },
|
||||
{ label: 'OR', value: 'OR ' },
|
||||
];
|
||||
|
||||
// Custom extension to stop events from propagating to global shortcuts
|
||||
const stopEventsExtension = EditorView.domEventHandlers({
|
||||
keydown: (event) => {
|
||||
// Stop all keyboard events from propagating to global shortcuts
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
return false; // Important for CM to know you handled it
|
||||
},
|
||||
input: (event) => {
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
},
|
||||
focus: (event) => {
|
||||
// Ensure focus events don't interfere with global shortcuts
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
},
|
||||
blur: (event) => {
|
||||
// Ensure blur events don't interfere with global shortcuts
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
function HavingFilter({
|
||||
onClose,
|
||||
onChange,
|
||||
queryData,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onChange: (value: string) => void;
|
||||
queryData: IBuilderQuery;
|
||||
}): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { getAggregationOptions } = useQueryBuilderV2Context();
|
||||
const aggregationOptions = getAggregationOptions(queryData.queryName);
|
||||
const having = queryData?.having as Having;
|
||||
const [input, setInput] = useState(having?.expression || '');
|
||||
|
||||
useEffect(() => {
|
||||
setInput(having?.expression || '');
|
||||
}, [having?.expression]);
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
|
||||
const [options, setOptions] = useState<{ label: string; value: string }[]>([]);
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
setInput(value);
|
||||
onChange(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocused && editorRef.current && options.length > 0) {
|
||||
startCompletion(editorRef.current);
|
||||
}
|
||||
}, [isFocused, options]);
|
||||
|
||||
// Update options when aggregation options change
|
||||
useEffect(() => {
|
||||
const newOptions = [];
|
||||
for (let i = 0; i < aggregationOptions.length; i++) {
|
||||
const opt = aggregationOptions[i];
|
||||
for (let j = 0; j < havingOperators.length; j++) {
|
||||
const operator = havingOperators[j];
|
||||
newOptions.push({
|
||||
label: `${opt.func}(${opt.arg}) ${operator.label}`,
|
||||
value: `${opt.func}(${opt.arg}) ${operator.label} `,
|
||||
apply: (
|
||||
view: EditorView,
|
||||
completion: { label: string; value: string },
|
||||
from: number,
|
||||
to: number,
|
||||
): void => {
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: completion.value },
|
||||
selection: { anchor: from + completion.value.length },
|
||||
});
|
||||
// Trigger value suggestions immediately after operator
|
||||
setTimeout(() => {
|
||||
startCompletion(view);
|
||||
}, 0);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
setOptions(newOptions);
|
||||
}, [aggregationOptions]);
|
||||
|
||||
// Helper to check if a string is a number
|
||||
const isNumber = (token: string): boolean => /^-?\d+(\.\d+)?$/.test(token);
|
||||
|
||||
// Helper to check if we're after an operator
|
||||
const isAfterOperator = (tokens: string[]): boolean => {
|
||||
if (tokens.length === 0) return false;
|
||||
const lastToken = tokens[tokens.length - 1];
|
||||
// Check if the last token is exactly an operator or ends with an operator and space
|
||||
return havingOperators.some((op) => {
|
||||
const opWithSpace = `${op.value} `;
|
||||
return lastToken === op.value || lastToken.endsWith(opWithSpace);
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function for applying completion with space
|
||||
const applyCompletionWithSpace = (
|
||||
view: EditorView,
|
||||
completion: Completion,
|
||||
from: number,
|
||||
to: number,
|
||||
): void => {
|
||||
const insertValue =
|
||||
typeof completion.apply === 'string' ? completion.apply : completion.label;
|
||||
const newText = `${insertValue} `;
|
||||
const newPos = from + newText.length;
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: newText },
|
||||
selection: { anchor: newPos, head: newPos },
|
||||
effects: EditorView.scrollIntoView(newPos),
|
||||
});
|
||||
};
|
||||
|
||||
const havingAutocomplete = useMemo(() => {
|
||||
// Helper functions for applying completions
|
||||
const forceCompletion = (view: EditorView): void => {
|
||||
setTimeout(() => {
|
||||
if (view) {
|
||||
startCompletion(view);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const applyValueCompletion = (
|
||||
view: EditorView,
|
||||
completion: Completion,
|
||||
from: number,
|
||||
to: number,
|
||||
): void => {
|
||||
applyCompletionWithSpace(view, completion, from, to);
|
||||
forceCompletion(view);
|
||||
};
|
||||
|
||||
const applyOperatorCompletion = (
|
||||
view: EditorView,
|
||||
completion: Completion,
|
||||
from: number,
|
||||
to: number,
|
||||
): void => {
|
||||
const insertValue =
|
||||
typeof completion.apply === 'string' ? completion.apply : completion.label;
|
||||
const insertWithSpace = `${insertValue} `;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: insertWithSpace },
|
||||
selection: { anchor: from + insertWithSpace.length },
|
||||
});
|
||||
forceCompletion(view);
|
||||
};
|
||||
|
||||
return autocompletion({
|
||||
override: [
|
||||
(context: CompletionContext): CompletionResult | null => {
|
||||
const text = context.state.sliceDoc(0, context.pos);
|
||||
const trimmedText = text.trim();
|
||||
const tokens = trimmedText.split(/\s+/).filter(Boolean);
|
||||
|
||||
// Handle empty state when no aggregation options are available
|
||||
if (options.length === 0) {
|
||||
return {
|
||||
from: context.pos,
|
||||
options: [
|
||||
{
|
||||
label:
|
||||
'No aggregation functions available. Please add aggregation functions first.',
|
||||
type: 'text',
|
||||
apply: (): boolean => true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Close dropdown after operator to allow custom value entry
|
||||
if (isAfterOperator(tokens)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hide suggestions while typing a value after an operator
|
||||
if (
|
||||
!text.endsWith(' ') &&
|
||||
tokens.length >= 2 &&
|
||||
havingOperators.some((op) => op.value === tokens[tokens.length - 2])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Suggest key/operator pairs and ( for grouping
|
||||
if (
|
||||
tokens.length === 0 ||
|
||||
conjunctions.some((c) => tokens[tokens.length - 1] === c.value.trim()) ||
|
||||
tokens[tokens.length - 1] === '('
|
||||
) {
|
||||
return {
|
||||
from: context.pos,
|
||||
options: options.map((opt) => ({
|
||||
...opt,
|
||||
apply: applyOperatorCompletion,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Show suggestions when typing
|
||||
if (tokens.length > 0) {
|
||||
const lastToken = tokens[tokens.length - 1];
|
||||
const filteredOptions = options.filter((opt) =>
|
||||
opt.label.toLowerCase().includes(lastToken.toLowerCase()),
|
||||
);
|
||||
if (filteredOptions.length > 0) {
|
||||
return {
|
||||
from: context.pos - lastToken.length,
|
||||
options: filteredOptions.map((opt) => ({
|
||||
...opt,
|
||||
apply: applyOperatorCompletion,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Suggest conjunctions after a value and a space
|
||||
if (
|
||||
tokens.length > 0 &&
|
||||
(isNumber(tokens[tokens.length - 1]) ||
|
||||
tokens[tokens.length - 1] === ')') &&
|
||||
text.endsWith(' ')
|
||||
) {
|
||||
return {
|
||||
from: context.pos,
|
||||
options: conjunctions.map((conj) => ({
|
||||
...conj,
|
||||
apply: applyValueCompletion,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Show all options if no other condition matches
|
||||
return {
|
||||
from: context.pos,
|
||||
options: options.map((opt) => ({
|
||||
...opt,
|
||||
apply: applyOperatorCompletion,
|
||||
})),
|
||||
};
|
||||
},
|
||||
],
|
||||
defaultKeymap: true,
|
||||
closeOnBlur: true,
|
||||
maxRenderedOptions: 200,
|
||||
activateOnTyping: true,
|
||||
});
|
||||
}, [options]);
|
||||
|
||||
return (
|
||||
<div className="having-filter-container">
|
||||
<div className="having-filter-select-container">
|
||||
<CodeMirror
|
||||
value={input}
|
||||
onChange={handleChange}
|
||||
theme={isDarkMode ? copilot : githubLight}
|
||||
className="having-filter-select-editor"
|
||||
width="100%"
|
||||
extensions={[
|
||||
havingAutocomplete,
|
||||
javascript({ jsx: false, typescript: false }),
|
||||
stopEventsExtension,
|
||||
EditorView.lineWrapping,
|
||||
keymap.of([
|
||||
...completionKeymap,
|
||||
{
|
||||
key: 'Escape',
|
||||
run: closeCompletion,
|
||||
},
|
||||
]),
|
||||
]}
|
||||
placeholder="Type Having query like count() > 10 ..."
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
autocompletion: true,
|
||||
completionKeymap: true,
|
||||
}}
|
||||
onCreateEditor={(view: EditorView): void => {
|
||||
editorRef.current = view;
|
||||
}}
|
||||
onFocus={(): void => {
|
||||
setIsFocused(true);
|
||||
if (editorRef.current) {
|
||||
startCompletion(editorRef.current);
|
||||
}
|
||||
}}
|
||||
onBlur={(): void => {
|
||||
setIsFocused(false);
|
||||
if (editorRef.current) {
|
||||
closeCompletion(editorRef.current);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HavingFilter;
|
||||
@@ -0,0 +1,376 @@
|
||||
.add-ons-list {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.add-ons-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.add-on-tab-title {
|
||||
display: flex;
|
||||
gap: var(--margin-2);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-xs);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
border-left: none;
|
||||
min-width: 120px;
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
|
||||
&:first-child {
|
||||
border-left: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.tab::before {
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.selected-view {
|
||||
color: var(--text-robin-500);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
display: none;
|
||||
}
|
||||
|
||||
.selected-view::before {
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.compass-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.having-filter-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.having-filter-select-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.having-filter-select-editor {
|
||||
border-radius: 2px;
|
||||
flex: 1;
|
||||
width: calc(100% - 40px);
|
||||
|
||||
.cm-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
border-radius: 2px;
|
||||
background-color: transparent !important;
|
||||
position: relative !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
&.cm-focused {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--Slate-400, #1d212d);
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
padding: 0px !important;
|
||||
background-color: #121317 !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--bg-ink-200);
|
||||
}
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete {
|
||||
background: var(--bg-ink-300) !important;
|
||||
color: var(--bg-ink-500) !important;
|
||||
border-radius: 2px !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
position: absolute !important;
|
||||
top: calc(100% + 6px) !important;
|
||||
left: 0px !important;
|
||||
right: 0px !important;
|
||||
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-200, #1d212d);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
rgba(18, 19, 23, 0.8) 0%,
|
||||
rgba(18, 19, 23, 0.9) 98.68%
|
||||
) !important;
|
||||
backdrop-filter: blur(20px);
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.6);
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
color: var(--bg-vanilla-100) !important;
|
||||
|
||||
ul {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
min-height: 200px !important;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgb(136, 136, 136);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
li {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
line-height: 36px !important;
|
||||
height: 36px !important;
|
||||
padding: 4px 8px !important;
|
||||
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 8px !important;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
.cm-completionIcon {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&[aria-selected='true'] {
|
||||
// background-color: rgba(78, 116, 248, 0.7) !important;
|
||||
background: rgba(171, 189, 255, 0.04) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-gutters {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
line-height: 36px !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
background-color: #121317 !important;
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--bg-ink-100) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--bg-ink-100) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
.cm-function {
|
||||
color: var(--bg-robin-500) !important;
|
||||
}
|
||||
|
||||
.chip-decorator {
|
||||
background: rgba(36, 40, 52, 1) !important;
|
||||
color: var(--bg-vanilla-100) !important;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
background: var(--bg-ink-100) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
|
||||
border-left: transparent;
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selected-add-ons-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
||||
gap: 8px;
|
||||
padding-bottom: 8px;
|
||||
position: relative;
|
||||
|
||||
.add-on-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
min-width: 420px;
|
||||
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.add-ons-list {
|
||||
.add-ons-tabs {
|
||||
.add-on-tab-title {
|
||||
color: var(--bg-ink-500) !important;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
|
||||
&:first-child {
|
||||
border-left: 1px solid var(--bg-vanilla-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tab::before {
|
||||
background: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
|
||||
.selected-view {
|
||||
color: var(--bg-robin-500) !important;
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
}
|
||||
|
||||
.selected-view::before {
|
||||
background: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.compass-button {
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.having-filter-container {
|
||||
.having-filter-select-container {
|
||||
.having-filter-select-editor {
|
||||
.cm-editor {
|
||||
&:focus-within {
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete {
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
color: var(--bg-ink-500) !important;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
|
||||
ul {
|
||||
li {
|
||||
color: var(--bg-ink-300) !important;
|
||||
&:hover {
|
||||
background: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
|
||||
&[aria-selected='true'] {
|
||||
background: var(--bg-vanilla-300) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
background-color: var(--bg-vanilla-100) !important;
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--bg-ink-100) !important;
|
||||
}
|
||||
|
||||
.chip-decorator {
|
||||
background: var(--bg-vanilla-300) !important;
|
||||
color: var(--bg-ink-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
/* eslint-disable react/require-default-props */
|
||||
import './QueryAddOns.styles.scss';
|
||||
|
||||
import { Button, Radio, RadioChangeEvent, Tooltip } from 'antd';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
|
||||
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
|
||||
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { BarChart2, ChevronUp, ExternalLink, ScrollText } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import HavingFilter from './HavingFilter/HavingFilter';
|
||||
|
||||
interface AddOn {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
key: string;
|
||||
description?: string;
|
||||
docLink?: string;
|
||||
}
|
||||
|
||||
const ADD_ONS_KEYS = {
|
||||
GROUP_BY: 'group_by',
|
||||
HAVING: 'having',
|
||||
ORDER_BY: 'order_by',
|
||||
LIMIT: 'limit',
|
||||
LEGEND_FORMAT: 'legend_format',
|
||||
};
|
||||
|
||||
const ADD_ONS = [
|
||||
{
|
||||
icon: <BarChart2 size={14} />,
|
||||
label: 'Group By',
|
||||
key: 'group_by',
|
||||
description:
|
||||
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
|
||||
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/#grouping',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Having',
|
||||
key: 'having',
|
||||
description:
|
||||
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
|
||||
docLink:
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#conditional-filtering-with-having',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Order By',
|
||||
key: 'order_by',
|
||||
description:
|
||||
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Limit',
|
||||
key: 'limit',
|
||||
description:
|
||||
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Legend format',
|
||||
key: 'legend_format',
|
||||
description:
|
||||
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#legend-formatting',
|
||||
},
|
||||
];
|
||||
|
||||
const REDUCE_TO = {
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Reduce to',
|
||||
key: 'reduce_to',
|
||||
description:
|
||||
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations',
|
||||
};
|
||||
|
||||
// Custom tooltip content component
|
||||
function TooltipContent({
|
||||
label,
|
||||
description,
|
||||
docLink,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
docLink?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
maxWidth: '300px',
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: '14px' }}>{label}</strong>
|
||||
{description && (
|
||||
<span style={{ fontSize: '12px', lineHeight: '1.5' }}>{description}</span>
|
||||
)}
|
||||
{docLink && (
|
||||
<a
|
||||
href={docLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
color: '#4096ff',
|
||||
fontSize: '12px',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryAddOns({
|
||||
query,
|
||||
version,
|
||||
isListViewPanel,
|
||||
showReduceTo,
|
||||
panelType,
|
||||
index,
|
||||
}: {
|
||||
query: IBuilderQuery;
|
||||
version: string;
|
||||
isListViewPanel: boolean;
|
||||
showReduceTo: boolean;
|
||||
panelType: PANEL_TYPES | null;
|
||||
index: number;
|
||||
}): JSX.Element {
|
||||
const [addOns, setAddOns] = useState<AddOn[]>(ADD_ONS);
|
||||
|
||||
const [selectedViews, setSelectedViews] = useState<AddOn[]>([]);
|
||||
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
entityVersion: '',
|
||||
});
|
||||
|
||||
const { handleSetQueryData } = useQueryBuilder();
|
||||
|
||||
useEffect(() => {
|
||||
if (isListViewPanel) {
|
||||
setAddOns([]);
|
||||
|
||||
setSelectedViews([
|
||||
ADD_ONS.find((addOn) => addOn.key === ADD_ONS_KEYS.ORDER_BY) as AddOn,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let filteredAddOns: AddOn[];
|
||||
if (panelType === PANEL_TYPES.VALUE) {
|
||||
// Filter out all add-ons except legend format
|
||||
filteredAddOns = ADD_ONS.filter(
|
||||
(addOn) => addOn.key === ADD_ONS_KEYS.LEGEND_FORMAT,
|
||||
);
|
||||
} else {
|
||||
filteredAddOns = Object.values(ADD_ONS);
|
||||
|
||||
// Filter out group_by for metrics data source
|
||||
if (query.dataSource === DataSource.METRICS) {
|
||||
filteredAddOns = filteredAddOns.filter(
|
||||
(addOn) => addOn.key !== ADD_ONS_KEYS.GROUP_BY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// add reduce to if showReduceTo is true
|
||||
if (showReduceTo) {
|
||||
filteredAddOns = [...filteredAddOns, REDUCE_TO];
|
||||
}
|
||||
|
||||
setAddOns(filteredAddOns);
|
||||
|
||||
// Filter selectedViews to only include add-ons present in filteredAddOns
|
||||
setSelectedViews((prevSelectedViews) =>
|
||||
prevSelectedViews.filter((view) =>
|
||||
filteredAddOns.some((addOn) => addOn.key === view.key),
|
||||
),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [panelType, isListViewPanel, query.dataSource]);
|
||||
|
||||
const handleOptionClick = (e: RadioChangeEvent): void => {
|
||||
if (selectedViews.find((view) => view.key === e.target.value.key)) {
|
||||
setSelectedViews(
|
||||
selectedViews.filter((view) => view.key !== e.target.value.key),
|
||||
);
|
||||
} else {
|
||||
setSelectedViews([...selectedViews, e.target.value]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeGroupByKeys = useCallback(
|
||||
(value: IBuilderQuery['groupBy']) => {
|
||||
handleChangeQueryData('groupBy', value);
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
const handleChangeOrderByKeys = useCallback(
|
||||
(value: IBuilderQuery['orderBy']) => {
|
||||
handleChangeQueryData('orderBy', value);
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
const handleChangeReduceToV5 = useCallback(
|
||||
(value: ReduceOperators) => {
|
||||
handleSetQueryData(index, {
|
||||
...query,
|
||||
aggregations: [
|
||||
{
|
||||
...(query.aggregations?.[0] as MetricAggregation),
|
||||
reduceTo: value,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
[handleSetQueryData, index, query],
|
||||
);
|
||||
|
||||
const handleRemoveView = useCallback(
|
||||
(key: string): void => {
|
||||
setSelectedViews(selectedViews.filter((view) => view.key !== key));
|
||||
},
|
||||
[selectedViews],
|
||||
);
|
||||
|
||||
const handleChangeQueryLegend = useCallback(
|
||||
(value: string) => {
|
||||
handleChangeQueryData('legend', value);
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
const handleChangeLimit = useCallback(
|
||||
(value: string) => {
|
||||
handleChangeQueryData('limit', Number(value) || null);
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
const handleChangeHaving = useCallback(
|
||||
(value: string) => {
|
||||
handleChangeQueryData('having', {
|
||||
expression: value,
|
||||
});
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="query-add-ons">
|
||||
{selectedViews.length > 0 && (
|
||||
<div className="selected-add-ons-content">
|
||||
{selectedViews.find((view) => view.key === 'group_by') && (
|
||||
<div className="add-on-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label="Group By"
|
||||
description="Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments."
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#grouping"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<div className="label" style={{ cursor: 'help' }}>
|
||||
Group By
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="input">
|
||||
<GroupByFilter
|
||||
disabled={
|
||||
query.dataSource === DataSource.METRICS &&
|
||||
!(query.aggregations?.[0] as MetricAggregation)?.metricName
|
||||
}
|
||||
query={query}
|
||||
onChange={handleChangeGroupByKeys}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView('group_by')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedViews.find((view) => view.key === 'having') && (
|
||||
<div className="add-on-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label="Having"
|
||||
description="Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500"
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#conditional-filtering-with-having"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<div className="label" style={{ cursor: 'help' }}>
|
||||
Having
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="input">
|
||||
<HavingFilter
|
||||
onClose={(): void => {
|
||||
setSelectedViews(
|
||||
selectedViews.filter((view) => view.key !== 'having'),
|
||||
);
|
||||
}}
|
||||
onChange={handleChangeHaving}
|
||||
queryData={query}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedViews.find((view) => view.key === 'limit') && (
|
||||
<div className="add-on-content">
|
||||
<InputWithLabel
|
||||
label="Limit"
|
||||
onChange={handleChangeLimit}
|
||||
initialValue={query?.limit ?? undefined}
|
||||
placeholder="Enter limit"
|
||||
onClose={(): void => {
|
||||
setSelectedViews(selectedViews.filter((view) => view.key !== 'limit'));
|
||||
}}
|
||||
closeIcon={<ChevronUp size={16} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedViews.find((view) => view.key === 'order_by') && (
|
||||
<div className="add-on-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label="Order By"
|
||||
description="Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers."
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<div className="label" style={{ cursor: 'help' }}>
|
||||
Order By
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="input">
|
||||
<OrderByFilter
|
||||
entityVersion={version}
|
||||
query={query}
|
||||
onChange={handleChangeOrderByKeys}
|
||||
isListViewPanel={isListViewPanel}
|
||||
isNewQueryV2
|
||||
/>
|
||||
</div>
|
||||
{!isListViewPanel && (
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView('order_by')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedViews.find((view) => view.key === 'reduce_to') && showReduceTo && (
|
||||
<div className="add-on-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label="Reduce to"
|
||||
description="Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value."
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<div className="label" style={{ cursor: 'help' }}>
|
||||
Reduce to
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="input">
|
||||
<ReduceToFilter query={query} onChange={handleChangeReduceToV5} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView('reduce_to')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedViews.find((view) => view.key === 'legend_format') && (
|
||||
<div className="add-on-content">
|
||||
<InputWithLabel
|
||||
label="Legend format"
|
||||
placeholder="Write legend format"
|
||||
onChange={handleChangeQueryLegend}
|
||||
initialValue={isEmpty(query?.legend) ? undefined : query?.legend}
|
||||
onClose={(): void => {
|
||||
setSelectedViews(
|
||||
selectedViews.filter((view) => view.key !== 'legend_format'),
|
||||
);
|
||||
}}
|
||||
closeIcon={<ChevronUp size={16} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="add-ons-list">
|
||||
<Radio.Group
|
||||
className="add-ons-tabs"
|
||||
onChange={handleOptionClick}
|
||||
value={selectedViews}
|
||||
>
|
||||
{addOns.map((addOn) => (
|
||||
<Tooltip
|
||||
key={addOn.key}
|
||||
title={
|
||||
<TooltipContent
|
||||
label={addOn.label}
|
||||
description={addOn.description}
|
||||
docLink={addOn.docLink}
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedViews.find((view) => view.key === addOn.key)
|
||||
? 'selected-view tab'
|
||||
: 'tab'
|
||||
}
|
||||
value={addOn}
|
||||
>
|
||||
<div className="add-on-tab-title">
|
||||
{addOn.icon}
|
||||
{addOn.label}
|
||||
</div>
|
||||
</Radio.Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryAddOns;
|
||||
@@ -0,0 +1,339 @@
|
||||
.query-aggregation-container {
|
||||
display: block;
|
||||
|
||||
.aggregation-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.query-aggregation-select-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 400px;
|
||||
position: relative;
|
||||
|
||||
.query-aggregation-select-editor {
|
||||
border-radius: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&.error {
|
||||
.cm-editor {
|
||||
.cm-content {
|
||||
border-color: var(--bg-cherry-500) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.cm-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
border-radius: 2px;
|
||||
background-color: transparent !important;
|
||||
position: relative !important;
|
||||
|
||||
&.cm-focused {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--Slate-400, #1d212d);
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
padding: 0px !important;
|
||||
background-color: #121317 !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--bg-ink-200);
|
||||
}
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete {
|
||||
background: var(--bg-ink-300) !important;
|
||||
border-radius: 2px !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
min-width: 400px !important;
|
||||
position: absolute !important;
|
||||
top: calc(100% + 6px) !important;
|
||||
left: 0px !important;
|
||||
right: 0px !important;
|
||||
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-200, #1d212d);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
rgba(18, 19, 23, 0.8) 0%,
|
||||
rgba(18, 19, 23, 0.9) 98.68%
|
||||
) !important;
|
||||
backdrop-filter: blur(20px);
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.6);
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
ul {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
min-height: 200px !important;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgb(136, 136, 136);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
li {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
line-height: 36px !important;
|
||||
height: 36px !important;
|
||||
padding: 4px 8px !important;
|
||||
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 8px !important;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
.cm-completionIcon {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&[aria-selected='true'] {
|
||||
// background-color: rgba(78, 116, 248, 0.7) !important;
|
||||
background: rgba(171, 189, 255, 0.04) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-gutters {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
line-height: 36px !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
background-color: #121317 !important;
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--bg-ink-100) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--bg-ink-100) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
.cm-function {
|
||||
color: var(--bg-robin-500) !important;
|
||||
}
|
||||
|
||||
.chip-decorator {
|
||||
background: rgba(36, 40, 52, 1) !important;
|
||||
color: var(--bg-vanilla-100) !important;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
background: var(--bg-ink-100) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-aggregation-error-container {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
|
||||
.query-aggregation-error-content {
|
||||
padding: 8px;
|
||||
max-width: 300px;
|
||||
|
||||
.query-aggregation-error-message {
|
||||
color: var(--bg-cherry-500);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.query-aggregation-error-btn {
|
||||
padding: 4px;
|
||||
height: auto;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
.close-btn {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
|
||||
border-left: transparent;
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.query-aggregation-options-input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-vanilla-100);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.query-aggregation-interval {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: 360px;
|
||||
|
||||
.query-aggregation-interval-input-container {
|
||||
.query-aggregation-interval-input {
|
||||
input {
|
||||
max-width: 120px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.query-aggregation-container {
|
||||
.aggregation-container {
|
||||
.query-aggregation-options-input {
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bg-ink-400) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.query-aggregation-select-container {
|
||||
.query-aggregation-select-editor {
|
||||
.cm-editor {
|
||||
.cm-content {
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1) !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete {
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
color: var(--bg-ink-500) !important;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
|
||||
ul {
|
||||
li {
|
||||
color: var(--bg-ink-300) !important;
|
||||
|
||||
&:hover,
|
||||
&[aria-selected='true'] {
|
||||
background: var(--bg-vanilla-300) !important;
|
||||
color: var(--bg-ink-500) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
background-color: var(--bg-vanilla-100) !important;
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
.cm-function {
|
||||
color: var(--bg-robin-500) !important;
|
||||
}
|
||||
|
||||
.chip-decorator {
|
||||
background: var(--bg-vanilla-300) !important;
|
||||
color: var(--bg-ink-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// .cm-selectionBackground {
|
||||
// background: var(--bg-vanilla-100) !important;
|
||||
// opacity: 0.5 !important;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
border-color: var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.query-aggregation-error-popover {
|
||||
.ant-popover-inner {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border: none;
|
||||
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-aggregation-error-popover {
|
||||
.ant-popover-inner {
|
||||
background-color: var(--bg-slate-500);
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import './QueryAggregation.styles.scss';
|
||||
|
||||
import { Tooltip } from 'antd';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useMemo } from 'react';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import QueryAggregationSelect from './QueryAggregationSelect';
|
||||
|
||||
function QueryAggregationOptions({
|
||||
dataSource,
|
||||
panelType,
|
||||
onAggregationIntervalChange,
|
||||
onChange,
|
||||
queryData,
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
panelType?: string;
|
||||
onAggregationIntervalChange: (value: number) => void;
|
||||
onChange?: (value: string) => void;
|
||||
queryData: IBuilderQuery;
|
||||
}): JSX.Element {
|
||||
const showAggregationInterval = useMemo(() => {
|
||||
// eslint-disable-next-line sonarjs/prefer-single-boolean-return
|
||||
if (panelType === PANEL_TYPES.VALUE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dataSource === DataSource.TRACES || dataSource === DataSource.LOGS) {
|
||||
return !(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE);
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [dataSource, panelType]);
|
||||
|
||||
const handleAggregationIntervalChange = (value: string): void => {
|
||||
onAggregationIntervalChange(Number(value));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="query-aggregation-container">
|
||||
<div className="aggregation-container">
|
||||
<QueryAggregationSelect
|
||||
onChange={onChange}
|
||||
queryData={queryData}
|
||||
maxAggregations={
|
||||
panelType === PANEL_TYPES.VALUE || panelType === PANEL_TYPES.PIE
|
||||
? 1
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{showAggregationInterval && (
|
||||
<div className="query-aggregation-interval">
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
>
|
||||
Learn about step intervals
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<div
|
||||
className="metrics-aggregation-section-content-item-label"
|
||||
style={{ cursor: 'help' }}
|
||||
>
|
||||
every
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div className="query-aggregation-interval-input-container">
|
||||
<InputWithLabel
|
||||
initialValue={
|
||||
queryData?.stepInterval ? queryData?.stepInterval : undefined
|
||||
}
|
||||
className="query-aggregation-interval-input"
|
||||
label="Seconds"
|
||||
placeholder="Auto"
|
||||
type="number"
|
||||
onChange={handleAggregationIntervalChange}
|
||||
labelAfter
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
QueryAggregationOptions.defaultProps = {
|
||||
panelType: null,
|
||||
onChange: undefined,
|
||||
};
|
||||
|
||||
export default QueryAggregationOptions;
|
||||
@@ -0,0 +1,715 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
/* eslint-disable no-cond-assign */
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
/* eslint-disable class-methods-use-this */
|
||||
/* eslint-disable react/no-this-in-sfc */
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import './QueryAggregation.styles.scss';
|
||||
|
||||
import {
|
||||
autocompletion,
|
||||
closeCompletion,
|
||||
Completion,
|
||||
CompletionContext,
|
||||
completionKeymap,
|
||||
CompletionResult,
|
||||
startCompletion,
|
||||
} from '@codemirror/autocomplete';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { EditorState, RangeSetBuilder, Transaction } from '@codemirror/state';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { copilot } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror, {
|
||||
Decoration,
|
||||
EditorView,
|
||||
keymap,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
} from '@uiw/react-codemirror';
|
||||
import { Button, Popover, Tooltip } from 'antd';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { QUERY_BUILDER_KEY_TYPES } from 'constants/antlrQueryConstants';
|
||||
import { QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import { tracesAggregateOperatorOptions } from 'constants/queryBuilderOperators';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Info, TriangleAlert } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
|
||||
|
||||
import { useQueryBuilderV2Context } from '../../QueryBuilderV2Context';
|
||||
|
||||
const chipDecoration = Decoration.mark({
|
||||
class: 'chip-decorator',
|
||||
});
|
||||
|
||||
const operatorArgMeta: Record<
|
||||
string,
|
||||
{ acceptsArgs: boolean; multiple: boolean }
|
||||
> = {
|
||||
[TracesAggregatorOperator.NOOP]: { acceptsArgs: false, multiple: false },
|
||||
[TracesAggregatorOperator.COUNT]: { acceptsArgs: false, multiple: false },
|
||||
[TracesAggregatorOperator.COUNT_DISTINCT]: {
|
||||
acceptsArgs: true,
|
||||
multiple: true,
|
||||
},
|
||||
[TracesAggregatorOperator.SUM]: { acceptsArgs: true, multiple: false },
|
||||
[TracesAggregatorOperator.AVG]: { acceptsArgs: true, multiple: false },
|
||||
[TracesAggregatorOperator.MAX]: { acceptsArgs: true, multiple: false },
|
||||
[TracesAggregatorOperator.MIN]: { acceptsArgs: true, multiple: false },
|
||||
[TracesAggregatorOperator.P05]: { acceptsArgs: true, multiple: false },
|
||||
[TracesAggregatorOperator.P10]: { acceptsArgs: true, multiple: false },
|
||||
[TracesAggregatorOperator.P20]: { acceptsArgs: true, multiple: false },
|
||||
[TracesAggregatorOperator.P25]: { acceptsArgs: true, multiple: false },
|
||||
[TracesAggregatorOperator.P50]: { acceptsArgs: true, multiple: false },
|
||||
[TracesAggregatorOperator.P75]: { acceptsArgs: true, multiple: false },
|
||||
[TracesAggregatorOperator.P90]: { acceptsArgs: true, multiple: false },
|
||||
[TracesAggregatorOperator.P95]: { acceptsArgs: true, multiple: false },
|
||||
[TracesAggregatorOperator.P99]: { acceptsArgs: true, multiple: false },
|
||||
[TracesAggregatorOperator.RATE]: { acceptsArgs: false, multiple: false },
|
||||
[TracesAggregatorOperator.RATE_SUM]: { acceptsArgs: true, multiple: false },
|
||||
[TracesAggregatorOperator.RATE_AVG]: { acceptsArgs: true, multiple: false },
|
||||
[TracesAggregatorOperator.RATE_MIN]: { acceptsArgs: true, multiple: false },
|
||||
[TracesAggregatorOperator.RATE_MAX]: { acceptsArgs: true, multiple: false },
|
||||
};
|
||||
|
||||
function getFunctionContextAtCursor(
|
||||
text: string,
|
||||
cursorPos: number,
|
||||
): string | null {
|
||||
// Find the nearest function name to the left of the nearest unmatched '('
|
||||
let openParenIndex = -1;
|
||||
let funcName: string | null = null;
|
||||
let parenStack = 0;
|
||||
for (let i = cursorPos - 1; i >= 0; i--) {
|
||||
if (text[i] === ')') parenStack++;
|
||||
else if (text[i] === '(') {
|
||||
if (parenStack === 0) {
|
||||
openParenIndex = i;
|
||||
const before = text.slice(0, i);
|
||||
const match = before.match(/(\w+)\s*$/);
|
||||
if (match) funcName = match[1].toLowerCase();
|
||||
break;
|
||||
}
|
||||
parenStack--;
|
||||
}
|
||||
}
|
||||
if (openParenIndex === -1 || !funcName) return null;
|
||||
// Scan forwards to find the matching closing parenthesis
|
||||
let closeParenIndex = -1;
|
||||
let depth = 1;
|
||||
for (let j = openParenIndex + 1; j < text.length; j++) {
|
||||
if (text[j] === '(') depth++;
|
||||
else if (text[j] === ')') depth--;
|
||||
if (depth === 0) {
|
||||
closeParenIndex = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (
|
||||
cursorPos > openParenIndex &&
|
||||
(closeParenIndex === -1 || cursorPos <= closeParenIndex)
|
||||
) {
|
||||
return funcName;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Custom extension to stop events from propagating to global shortcuts
|
||||
const stopEventsExtension = EditorView.domEventHandlers({
|
||||
keydown: (event) => {
|
||||
// Stop all keyboard events from propagating to global shortcuts
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
return false; // Important for CM to know you handled it
|
||||
},
|
||||
input: (event) => {
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
},
|
||||
focus: (event) => {
|
||||
// Ensure focus events don't interfere with global shortcuts
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
},
|
||||
blur: (event) => {
|
||||
// Ensure blur events don't interfere with global shortcuts
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react/no-this-in-sfc
|
||||
function QueryAggregationSelect({
|
||||
onChange,
|
||||
queryData,
|
||||
maxAggregations,
|
||||
}: {
|
||||
onChange?: (value: string) => void;
|
||||
queryData: IBuilderQuery;
|
||||
maxAggregations?: number;
|
||||
}): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { setAggregationOptions } = useQueryBuilderV2Context();
|
||||
|
||||
const [input, setInput] = useState(
|
||||
queryData?.aggregations?.map((i: any) => i.expression).join(' ') || '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setInput(
|
||||
queryData?.aggregations?.map((i: any) => i.expression).join(' ') || '',
|
||||
);
|
||||
}, [queryData?.aggregations]);
|
||||
|
||||
const [cursorPos, setCursorPos] = useState(0);
|
||||
const [functionArgPairs, setFunctionArgPairs] = useState<
|
||||
{ func: string; arg: string }[]
|
||||
>([]);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// Get valid function names (lowercase)
|
||||
const validFunctions = useMemo(
|
||||
() => tracesAggregateOperatorOptions.map((op) => op.value.toLowerCase()),
|
||||
[],
|
||||
);
|
||||
|
||||
// Helper function to safely start completion
|
||||
const safeStartCompletion = useCallback((): void => {
|
||||
requestAnimationFrame(() => {
|
||||
if (editorRef.current) {
|
||||
startCompletion(editorRef.current);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Update cursor position on every editor update
|
||||
const handleUpdate = (update: { view: EditorView }): void => {
|
||||
const pos = update.view.state.selection.main.from;
|
||||
setCursorPos(pos);
|
||||
};
|
||||
|
||||
// Effect to handle focus state and trigger suggestions
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
safeStartCompletion();
|
||||
}
|
||||
}, [isFocused, safeStartCompletion]);
|
||||
|
||||
// Extract all valid function-argument pairs from the input
|
||||
useEffect(() => {
|
||||
const pairs: { func: string; arg: string }[] = [];
|
||||
const regex = /([a-zA-Z_][\w]*)\s*\(([^)]*)\)/g;
|
||||
let match;
|
||||
while ((match = regex.exec(input)) !== null) {
|
||||
const func = match[1].toLowerCase();
|
||||
const args = match[2]
|
||||
.split(',')
|
||||
.map((arg) => arg.trim())
|
||||
.filter((arg) => arg.length > 0);
|
||||
|
||||
if (args.length === 0) {
|
||||
// For functions with no arguments, add a pair with empty string as arg
|
||||
pairs.push({ func, arg: '' });
|
||||
} else {
|
||||
args.forEach((arg) => {
|
||||
pairs.push({ func, arg });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validation logic
|
||||
const validateAggregations = (): string | null => {
|
||||
// Check maxAggregations limit
|
||||
if (maxAggregations !== undefined && pairs.length > maxAggregations) {
|
||||
return `Maximum ${maxAggregations} aggregation${
|
||||
maxAggregations === 1 ? '' : 's'
|
||||
} allowed`;
|
||||
}
|
||||
|
||||
// Check for invalid functions
|
||||
const invalidFuncs = pairs.filter(
|
||||
(pair) => !validFunctions.includes(pair.func),
|
||||
);
|
||||
if (invalidFuncs.length > 0) {
|
||||
const funcs = invalidFuncs.map((f) => f.func).join(', ');
|
||||
return `Invalid function${invalidFuncs.length === 1 ? '' : 's'}: ${funcs}`;
|
||||
}
|
||||
|
||||
// Check for incomplete function calls
|
||||
if (/([a-zA-Z_][\w]*)\s*\([^)]*$/g.test(input)) {
|
||||
return 'Incomplete function call - missing closing parenthesis';
|
||||
}
|
||||
|
||||
// Check for empty function calls that require arguments
|
||||
const emptyFuncs = (input.match(/([a-zA-Z_][\w]*)\s*\(\s*\)/g) || [])
|
||||
.map((call) => call.match(/([a-zA-Z_][\w]*)/)?.[1])
|
||||
.filter((func): func is string => Boolean(func))
|
||||
.filter((func) => operatorArgMeta[func.toLowerCase()]?.acceptsArgs);
|
||||
|
||||
if (emptyFuncs.length > 0) {
|
||||
const isPlural = emptyFuncs.length > 1;
|
||||
return `Function${isPlural ? 's' : ''} ${emptyFuncs.join(', ')} require${
|
||||
isPlural ? '' : 's'
|
||||
} arguments`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
setValidationError(validateAggregations());
|
||||
setFunctionArgPairs(pairs);
|
||||
setAggregationOptions(queryData.queryName, pairs);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [input, maxAggregations, validFunctions]);
|
||||
|
||||
// Transaction filter to limit aggregations
|
||||
const transactionFilterExtension = useMemo(() => {
|
||||
if (maxAggregations === undefined) return [];
|
||||
|
||||
return EditorState.transactionFilter.of((tr: Transaction) => {
|
||||
if (!tr.docChanged) return tr;
|
||||
|
||||
const regex = /([a-zA-Z_][\w]*)\s*\(([^)]*)\)/g;
|
||||
const oldMatches = [
|
||||
...tr.startState.doc.toString().matchAll(regex),
|
||||
].filter((match) => validFunctions.includes(match[1].toLowerCase()));
|
||||
const newMatches = [
|
||||
...tr.newDoc.toString().matchAll(regex),
|
||||
].filter((match) => validFunctions.includes(match[1].toLowerCase()));
|
||||
|
||||
if (
|
||||
newMatches.length > oldMatches.length &&
|
||||
newMatches.length > maxAggregations
|
||||
) {
|
||||
return []; // Cancel transaction
|
||||
}
|
||||
return tr;
|
||||
});
|
||||
}, [maxAggregations, validFunctions]);
|
||||
|
||||
// Find function context for fetching suggestions
|
||||
const functionContextForFetch = getFunctionContextAtCursor(input, cursorPos);
|
||||
|
||||
const { data: aggregateAttributeData, isLoading: isLoadingFields } = useQuery(
|
||||
[
|
||||
QueryBuilderKeys.GET_AGGREGATE_ATTRIBUTE,
|
||||
functionContextForFetch,
|
||||
queryData.dataSource,
|
||||
],
|
||||
() => {
|
||||
const operatorsWithoutDataType: (string | undefined)[] = [
|
||||
TracesAggregatorOperator.COUNT,
|
||||
TracesAggregatorOperator.COUNT_DISTINCT,
|
||||
TracesAggregatorOperator.RATE,
|
||||
];
|
||||
|
||||
const fieldDataType =
|
||||
functionContextForFetch &&
|
||||
operatorsWithoutDataType.includes(functionContextForFetch)
|
||||
? undefined
|
||||
: QUERY_BUILDER_KEY_TYPES.NUMBER;
|
||||
|
||||
return getKeySuggestions({
|
||||
signal: queryData.dataSource,
|
||||
searchText: '',
|
||||
fieldDataType: fieldDataType as QUERY_BUILDER_KEY_TYPES,
|
||||
});
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
!!functionContextForFetch &&
|
||||
!!operatorArgMeta[functionContextForFetch]?.acceptsArgs,
|
||||
},
|
||||
);
|
||||
|
||||
// Memoized chipPlugin that highlights valid function calls like count(), max(arg), min(arg)
|
||||
const chipPlugin = useMemo(
|
||||
() =>
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: import('@codemirror/view').DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = this.buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
buildDecorations(
|
||||
view: EditorView,
|
||||
): import('@codemirror/view').DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
const text = view.state.doc.sliceString(from, to);
|
||||
|
||||
const regex = /\b([a-zA-Z_][\w]*)\s*\(([^)]*)\)/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const func = match[1].toLowerCase();
|
||||
|
||||
if (validFunctions.includes(func)) {
|
||||
const start = from + match.index;
|
||||
const end = start + match[0].length;
|
||||
builder.add(start, end, chipDecoration);
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.finish();
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (v: any): import('@codemirror/view').DecorationSet =>
|
||||
v.decorations,
|
||||
},
|
||||
),
|
||||
[validFunctions],
|
||||
) as any;
|
||||
|
||||
const operatorCompletions: Completion[] = tracesAggregateOperatorOptions.map(
|
||||
(op) => ({
|
||||
label: op.value,
|
||||
type: 'function',
|
||||
info: op.label,
|
||||
apply: (
|
||||
view: EditorView,
|
||||
completion: Completion,
|
||||
from: number,
|
||||
to: number,
|
||||
): void => {
|
||||
const acceptsArgs = operatorArgMeta[op.value]?.acceptsArgs;
|
||||
|
||||
let insertText: string;
|
||||
let cursorPos: number;
|
||||
|
||||
if (!acceptsArgs) {
|
||||
insertText = `${op.value}() `;
|
||||
cursorPos = from + insertText.length; // Use insertText.length instead of hardcoded values
|
||||
} else {
|
||||
insertText = `${op.value}(`;
|
||||
cursorPos = from + insertText.length; // Use insertText.length instead of hardcoded values
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: insertText },
|
||||
selection: { anchor: cursorPos },
|
||||
});
|
||||
|
||||
// Trigger suggestions after a small delay
|
||||
setTimeout(() => {
|
||||
safeStartCompletion();
|
||||
}, 50);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Memoize field suggestions from API (no filtering here)
|
||||
const fieldSuggestions = useMemo(
|
||||
() =>
|
||||
Object.keys(aggregateAttributeData?.data.data.keys || {}).flatMap((key) => {
|
||||
const attributeKeys = aggregateAttributeData?.data.data.keys[key];
|
||||
if (!attributeKeys) return [];
|
||||
|
||||
return attributeKeys.map((attributeKey) => ({
|
||||
label: attributeKey.name,
|
||||
type: 'variable',
|
||||
info: attributeKey.fieldDataType,
|
||||
apply: (
|
||||
view: EditorView,
|
||||
completion: Completion,
|
||||
from: number,
|
||||
to: number,
|
||||
): void => {
|
||||
const text = view.state.sliceDoc(0, from);
|
||||
const funcName = getFunctionContextAtCursor(text, from);
|
||||
const multiple = funcName ? operatorArgMeta[funcName]?.multiple : false;
|
||||
|
||||
// Insert the selected key followed by either a comma or closing parenthesis
|
||||
const insertText = multiple
|
||||
? `${completion.label},`
|
||||
: `${completion.label}) `;
|
||||
const cursorPos = from + insertText.length; // Use insertText.length instead of hardcoded values
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: insertText },
|
||||
selection: { anchor: cursorPos },
|
||||
});
|
||||
|
||||
// Trigger next suggestions after a small delay
|
||||
setTimeout(() => {
|
||||
safeStartCompletion();
|
||||
}, 50);
|
||||
},
|
||||
}));
|
||||
}) || [],
|
||||
[aggregateAttributeData, safeStartCompletion],
|
||||
);
|
||||
|
||||
const aggregatorAutocomplete = useMemo(
|
||||
() =>
|
||||
autocompletion({
|
||||
override: [
|
||||
(context: CompletionContext): CompletionResult | null => {
|
||||
const text = context.state.sliceDoc(0, context.state.doc.length);
|
||||
const cursorPos = context.pos;
|
||||
const funcName = getFunctionContextAtCursor(text, cursorPos);
|
||||
|
||||
// Check if over limit and not editing existing
|
||||
if (maxAggregations !== undefined) {
|
||||
const regex = /([a-zA-Z_][\w]*)\s*\(([^)]*)\)/g;
|
||||
const matches = [...text.matchAll(regex)].filter((match) =>
|
||||
validFunctions.includes(match[1].toLowerCase()),
|
||||
);
|
||||
if (matches.length >= maxAggregations) {
|
||||
const isEditing = matches.some((match) => {
|
||||
const start = match.index ?? 0;
|
||||
return cursorPos >= start && cursorPos <= start + match[0].length;
|
||||
});
|
||||
if (!isEditing) return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Do not show suggestions if inside count()
|
||||
if (
|
||||
funcName === TracesAggregatorOperator.COUNT &&
|
||||
cursorPos > 0 &&
|
||||
text[cursorPos - 1] !== ')'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If inside a function that accepts args, show field suggestions
|
||||
if (funcName && operatorArgMeta[funcName]?.acceptsArgs) {
|
||||
if (isLoadingFields) {
|
||||
return {
|
||||
from: cursorPos,
|
||||
options: [
|
||||
{
|
||||
label: 'Loading suggestions...',
|
||||
type: 'text',
|
||||
apply: (): void => {},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const doc = context.state.sliceDoc(0, cursorPos);
|
||||
const lastOpenParen = doc.lastIndexOf('(');
|
||||
const lastComma = doc.lastIndexOf(',', cursorPos - 1);
|
||||
const startOfArg =
|
||||
lastComma > lastOpenParen ? lastComma + 1 : lastOpenParen + 1;
|
||||
const inputText = doc.slice(startOfArg, cursorPos).trim();
|
||||
|
||||
// Parse arguments already present in the function call (before the cursor)
|
||||
const usedArgs = new Set<string>();
|
||||
if (lastOpenParen !== -1) {
|
||||
const argsString = doc.slice(lastOpenParen + 1, cursorPos);
|
||||
argsString.split(',').forEach((arg) => {
|
||||
const trimmed = arg.trim();
|
||||
if (trimmed) usedArgs.add(trimmed);
|
||||
});
|
||||
}
|
||||
|
||||
// Exclude arguments already paired with this function elsewhere in the input
|
||||
const globalUsedArgs = new Set(
|
||||
functionArgPairs
|
||||
.filter((pair) => pair.func === funcName)
|
||||
.map((pair) => pair.arg),
|
||||
);
|
||||
|
||||
const availableSuggestions = fieldSuggestions.filter(
|
||||
(suggestion) =>
|
||||
!usedArgs.has(suggestion.label) &&
|
||||
!globalUsedArgs.has(suggestion.label),
|
||||
);
|
||||
|
||||
const filteredSuggestions =
|
||||
inputText === ''
|
||||
? availableSuggestions
|
||||
: availableSuggestions.filter((suggestion) =>
|
||||
suggestion.label.toLowerCase().includes(inputText.toLowerCase()),
|
||||
);
|
||||
|
||||
return {
|
||||
from: startOfArg,
|
||||
options: filteredSuggestions,
|
||||
};
|
||||
}
|
||||
|
||||
// Show operator suggestions if no function context or not accepting args
|
||||
if (!funcName || !operatorArgMeta[funcName]?.acceptsArgs) {
|
||||
// Check if 'count(' is present in the current input (case-insensitive)
|
||||
const hasCount = text.toLowerCase().includes('count(');
|
||||
const availableOperators = hasCount
|
||||
? operatorCompletions.filter((op) => op.label.toLowerCase() !== 'count')
|
||||
: operatorCompletions;
|
||||
|
||||
// Get the word before cursor if any
|
||||
const word = context.matchBefore(/[\w\d_]+/);
|
||||
|
||||
// Show suggestions if:
|
||||
// 1. There's a word match
|
||||
// 2. The input is empty (cursor at start)
|
||||
// 3. The user explicitly triggered completion
|
||||
if (word || cursorPos === 0 || context.explicit) {
|
||||
return {
|
||||
from: word ? word.from : cursorPos,
|
||||
options: availableOperators,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
],
|
||||
defaultKeymap: true,
|
||||
closeOnBlur: true,
|
||||
maxRenderedOptions: 50,
|
||||
activateOnTyping: true,
|
||||
}),
|
||||
[
|
||||
operatorCompletions,
|
||||
isLoadingFields,
|
||||
fieldSuggestions,
|
||||
functionArgPairs,
|
||||
maxAggregations,
|
||||
validFunctions,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="query-aggregation-select-container">
|
||||
<CodeMirror
|
||||
value={input}
|
||||
onChange={(value): void => {
|
||||
setInput(value);
|
||||
onChange?.(value);
|
||||
}}
|
||||
className={`query-aggregation-select-editor ${
|
||||
validationError ? 'error' : ''
|
||||
}`}
|
||||
theme={isDarkMode ? copilot : githubLight}
|
||||
extensions={[
|
||||
chipPlugin,
|
||||
aggregatorAutocomplete,
|
||||
transactionFilterExtension,
|
||||
javascript({ jsx: false, typescript: false }),
|
||||
EditorView.lineWrapping,
|
||||
stopEventsExtension,
|
||||
keymap.of([
|
||||
...completionKeymap,
|
||||
{
|
||||
key: 'Escape',
|
||||
run: closeCompletion,
|
||||
},
|
||||
]),
|
||||
]}
|
||||
placeholder={
|
||||
maxAggregations !== undefined
|
||||
? `Type aggregator functions (max ${maxAggregations}) like sum(), count_distinct(...), etc.`
|
||||
: 'Type aggregator functions like sum(), count_distinct(...), etc.'
|
||||
}
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
autocompletion: true,
|
||||
completionKeymap: true,
|
||||
}}
|
||||
onUpdate={handleUpdate}
|
||||
onCreateEditor={(view: EditorView): void => {
|
||||
editorRef.current = view;
|
||||
}}
|
||||
onFocus={(): void => {
|
||||
setIsFocused(true);
|
||||
safeStartCompletion();
|
||||
}}
|
||||
onBlur={(): void => {
|
||||
setIsFocused(false);
|
||||
|
||||
if (editorRef.current) {
|
||||
closeCompletion(editorRef.current);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
Aggregation functions:
|
||||
<br />
|
||||
<span style={{ fontSize: '12px', lineHeight: '1.4' }}>
|
||||
• <strong>count</strong> - number of occurrences
|
||||
<br />• <strong>sum/avg</strong> - sum/average of values
|
||||
<br />• <strong>min/max</strong> - minimum/maximum value
|
||||
<br />• <strong>p50/p90/p99</strong> - percentiles
|
||||
<br />• <strong>count_distinct</strong> - unique values
|
||||
<br />• <strong>rate</strong> - per-interval rate
|
||||
</span>
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#core-aggregation-functions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
>
|
||||
View documentation
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
placement="left"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8px', // Match the error icon's top position
|
||||
right: validationError ? '40px' : '8px', // Move left when error icon is shown
|
||||
cursor: 'help',
|
||||
zIndex: 10,
|
||||
transition: 'right 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<Info
|
||||
size={14}
|
||||
style={{ opacity: 0.9, color: isDarkMode ? '#ffffff' : '#000000' }}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{validationError && (
|
||||
<div className="query-aggregation-error-container">
|
||||
<Popover
|
||||
placement="bottomRight"
|
||||
showArrow={false}
|
||||
content={
|
||||
<div className="query-aggregation-error-content">
|
||||
<div className="query-aggregation-error-message">{validationError}</div>
|
||||
</div>
|
||||
}
|
||||
overlayClassName="query-aggregation-error-popover"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<TriangleAlert size={14} color={Color.BG_CHERRY_500} />}
|
||||
className="periscope-btn ghost query-aggregation-error-btn"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
QueryAggregationSelect.defaultProps = {
|
||||
onChange: undefined,
|
||||
maxAggregations: undefined,
|
||||
};
|
||||
|
||||
export default QueryAggregationSelect;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Button, Tooltip, Typography } from 'antd';
|
||||
import { Plus, Sigma } from 'lucide-react';
|
||||
|
||||
export default function QueryFooter({
|
||||
addNewBuilderQuery,
|
||||
addNewFormula,
|
||||
}: {
|
||||
addNewBuilderQuery: () => void;
|
||||
addNewFormula: () => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="qb-footer">
|
||||
<div className="qb-footer-container">
|
||||
<div className="qb-add-new-query">
|
||||
<Tooltip title={<div style={{ textAlign: 'center' }}>Add New Query</div>}>
|
||||
<Button
|
||||
className="add-new-query-button periscope-btn secondary"
|
||||
type="text"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={addNewBuilderQuery}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="qb-add-formula">
|
||||
<Tooltip
|
||||
title={
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
Add New Formula
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-advanced-comparisons"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{' '}
|
||||
<br />
|
||||
Learn more
|
||||
</Typography.Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="add-formula-button periscope-btn secondary"
|
||||
icon={<Sigma size={16} />}
|
||||
onClick={addNewFormula}
|
||||
>
|
||||
Add Formula
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
.code-mirror-where-clause {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', sans-serif;
|
||||
|
||||
.query-where-clause-editor-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.query-where-clause-editor {
|
||||
flex: 1;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.query-status-container {
|
||||
width: 32px;
|
||||
|
||||
background-color: #121317 !important;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px solid var(--bg-slate-200);
|
||||
border-radius: 2px;
|
||||
border-top-left-radius: 0px !important;
|
||||
border-bottom-left-radius: 0px !important;
|
||||
border-left: none !important;
|
||||
|
||||
&.hasErrors {
|
||||
border-color: var(--bg-cherry-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-where-clause-editor {
|
||||
&.hasErrors {
|
||||
.cm-editor {
|
||||
.cm-content {
|
||||
border-color: var(--bg-cherry-500);
|
||||
border-top-right-radius: 0px !important;
|
||||
border-bottom-right-radius: 0px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
border-radius: 2px;
|
||||
// overflow: hidden;
|
||||
background-color: transparent !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--Slate-400, #1d212d);
|
||||
padding: 0px !important;
|
||||
background-color: #121317 !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--bg-ink-200);
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-focused {
|
||||
outline: 1px solid var(--bg-slate-200);
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete {
|
||||
background: var(--bg-ink-300) !important;
|
||||
border-radius: 2px !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
min-width: 400px !important;
|
||||
position: absolute !important;
|
||||
top: calc(100% + 6px) !important;
|
||||
left: 0px !important;
|
||||
right: 0px !important;
|
||||
|
||||
border-radius: 4px;
|
||||
border: 0px;
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
rgba(18, 19, 23, 0.8) 0%,
|
||||
rgba(18, 19, 23, 0.9) 98.68%
|
||||
) !important;
|
||||
backdrop-filter: blur(20px);
|
||||
box-sizing: border-box;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
border: 1px solid var(--bg-slate-200);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.6);
|
||||
|
||||
ul {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
min-height: 200px !important;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
}
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgb(136, 136, 136);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
li {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
line-height: 36px !important;
|
||||
height: 36px !important;
|
||||
padding: 4px 8px !important;
|
||||
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 8px !important;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-ink-100) !important;
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&[aria-selected='true'] {
|
||||
// background-color: rgba(78, 116, 248, 0.7) !important;
|
||||
background: rgba(171, 189, 255, 0.04) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-gutters {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
line-height: 34px !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
background-color: #121317 !important;
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--bg-ink-100) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--bg-ink-100) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
background: var(--bg-ink-100) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cursor-position {
|
||||
font-size: 12px;
|
||||
color: var(--bg-ink-200);
|
||||
padding: 6px;
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.query-validation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
margin-top: 16px;
|
||||
|
||||
.valid,
|
||||
.invalid {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.valid {
|
||||
background-color: rgba(39, 174, 96, 0.1);
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
background-color: rgba(235, 87, 87, 0.1);
|
||||
color: #eb5757;
|
||||
}
|
||||
|
||||
.query-validation-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.query-validation-errors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.query-validation-error {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
|
||||
font-size: 12px;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
color: var(--bg-cherry-500);
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-context {
|
||||
padding: 12px;
|
||||
background-color: var(--bg-ink-400);
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid var(--bg-robin-500);
|
||||
color: var(--bg-ink-300) !important;
|
||||
|
||||
.ant-card-head {
|
||||
color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
|
||||
.context-details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
|
||||
strong {
|
||||
color: var(--bg-vanilla-300);
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-mirror-card {
|
||||
.ant-card-body {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.query-text-preview-title {
|
||||
font-size: 13px;
|
||||
color: var(--bg-vanilla-100);
|
||||
background-color: var(--bg-robin-500);
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.query-text-preview {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 13px;
|
||||
color: var(--bg-vanilla-200);
|
||||
padding: 2px 6px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.query-examples-card {
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-200);
|
||||
|
||||
.ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.query-examples {
|
||||
.ant-collapse-header {
|
||||
padding: 8px 16px !important;
|
||||
color: var(--bg-vanilla-300) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.query-examples-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.query-example-tag {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--bg-ink-400);
|
||||
border: 1px solid var(--bg-slate-200);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-ink-300);
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--bg-robin-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.query-example-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.query-example-label {
|
||||
font-weight: 500;
|
||||
color: var(--bg-vanilla-300);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.query-example-query {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-200);
|
||||
background-color: var(--bg-ink-300);
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.query-example-description {
|
||||
font-size: 12px;
|
||||
color: var(--bg-vanilla-200);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.query-example-content {
|
||||
display: inline-flex;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Context indicator styles
|
||||
.context-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
background-color: #f5f5f5;
|
||||
border-left: 4px solid #1890ff;
|
||||
|
||||
display: none;
|
||||
|
||||
.triplet-info {
|
||||
margin-left: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.query-pair-info {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
||||
padding-left: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
// Color variations based on context
|
||||
&.context-indicator-key {
|
||||
border-left-color: #1890ff; // blue
|
||||
background-color: rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
&.context-indicator-operator {
|
||||
border-left-color: #722ed1; // purple
|
||||
background-color: rgba(114, 46, 209, 0.1);
|
||||
}
|
||||
|
||||
&.context-indicator-value {
|
||||
border-left-color: #52c41a; // green
|
||||
background-color: rgba(82, 196, 26, 0.1);
|
||||
}
|
||||
|
||||
&.context-indicator-conjunction {
|
||||
border-left-color: #fa8c16; // orange
|
||||
background-color: rgba(250, 140, 22, 0.1);
|
||||
}
|
||||
|
||||
&.context-indicator-function {
|
||||
border-left-color: #13c2c2; // cyan
|
||||
background-color: rgba(19, 194, 194, 0.1);
|
||||
}
|
||||
|
||||
&.context-indicator-parenthesis {
|
||||
border-left-color: #eb2f96; // magenta
|
||||
background-color: rgba(235, 47, 150, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-status-popover {
|
||||
.ant-popover-arrow {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ant-popover-content {
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
rgba(18, 19, 23, 0.8) 0%,
|
||||
rgba(18, 19, 23, 0.9) 98.68%
|
||||
);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
margin-top: -6px !important;
|
||||
}
|
||||
}
|
||||
|
||||
// /* Dark mode support */
|
||||
// :global(.darkMode) {
|
||||
// .code-mirror-where-clause {
|
||||
// .cm-editor {
|
||||
// border-color: var(--bg-slate-500);
|
||||
// background-color: var(--bg-ink-400);
|
||||
// }
|
||||
|
||||
// .cursor-position {
|
||||
// background-color: var(--bg-ink-400);
|
||||
// color: var(--bg-vanilla-100);
|
||||
// }
|
||||
|
||||
// .query-context {
|
||||
// background-color: var(--bg-ink-400);
|
||||
// color: var(--bg-vanilla-100);
|
||||
|
||||
// h3 {
|
||||
// color: var(--bg-vanilla-100);
|
||||
// }
|
||||
|
||||
// .context-details {
|
||||
// p {
|
||||
// strong {
|
||||
// color: var(--bg-vanilla-200);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// .query-examples-card {
|
||||
// background-color: var(--bg-ink-400);
|
||||
// border-color: var(--bg-slate-500);
|
||||
|
||||
// .ant-collapse-header {
|
||||
// color: var(--bg-vanilla-100) !important;
|
||||
// }
|
||||
|
||||
// .query-example-tag {
|
||||
// background-color: var(--bg-ink-400);
|
||||
// border-color: var(--bg-slate-500);
|
||||
|
||||
// &:hover {
|
||||
// background-color: var(--bg-ink-300);
|
||||
// border-color: var(--bg-robin-500);
|
||||
// }
|
||||
|
||||
// .query-example-label {
|
||||
// color: var(--bg-vanilla-100);
|
||||
// }
|
||||
|
||||
// .query-example-query {
|
||||
// color: var(--bg-vanilla-100);
|
||||
// background-color: var(--bg-ink-300);
|
||||
// }
|
||||
|
||||
// .query-example-description {
|
||||
// color: var(--bg-vanilla-100);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// .context-indicator {
|
||||
// background-color: var(--bg-ink-300);
|
||||
// color: var(--bg-vanilla-100);
|
||||
|
||||
// .query-pair-info {
|
||||
// border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
// background-color: rgba(255, 255, 255, 0.05);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
.lightMode {
|
||||
.code-mirror-where-clause {
|
||||
.query-where-clause-editor-container {
|
||||
.query-status-container {
|
||||
background-color: var(--bg-vanilla-100) !important;
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
&.hasErrors {
|
||||
border-color: var(--bg-cherry-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-where-clause-editor {
|
||||
&.hasErrors {
|
||||
.cm-editor {
|
||||
.cm-content {
|
||||
border-color: var(--bg-cherry-500);
|
||||
border-top-right-radius: 0px !important;
|
||||
border-bottom-right-radius: 0px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
&:focus-within {
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
&.cm-focused {
|
||||
outline: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
padding: 0px !important;
|
||||
background-color: var(--bg-vanilla-100) !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--bg-vanilla-200);
|
||||
}
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete {
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
|
||||
ul {
|
||||
li {
|
||||
background-color: var(--bg-vanilla-100) !important;
|
||||
color: var(--bg-ink-300) !important;
|
||||
|
||||
&:hover,
|
||||
&[aria-selected='true'] {
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
background-color: var(--bg-vanilla-100) !important;
|
||||
|
||||
::-moz-selection {
|
||||
background: #b3d4fc !important;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #b3d4fc !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
background: #b3d4fc !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cursor-position {
|
||||
color: var(--bg-vanilla-200);
|
||||
background-color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.query-context {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border-left: 3px solid var(--bg-vanilla-300);
|
||||
color: var(--bg-vanilla-300) !important;
|
||||
|
||||
.ant-card-head {
|
||||
color: var(--bg-ink-300) !important;
|
||||
}
|
||||
|
||||
.context-details {
|
||||
p {
|
||||
strong {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-examples-card {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.query-examples {
|
||||
.ant-collapse-header {
|
||||
color: var(--bg-ink-300) !important;
|
||||
}
|
||||
|
||||
.query-example-tag {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.query-example-label {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.query-example-query {
|
||||
color: var(--bg-ink-300);
|
||||
background-color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.query-example-description {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-indicator {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border-left: 4px solid var(--bg-vanilla-300);
|
||||
|
||||
display: none;
|
||||
|
||||
.query-pair-info {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
// Color variations based on context
|
||||
&.context-indicator-key {
|
||||
border-left-color: #1890ff; // blue
|
||||
background-color: rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
&.context-indicator-operator {
|
||||
border-left-color: #722ed1; // purple
|
||||
background-color: rgba(114, 46, 209, 0.1);
|
||||
}
|
||||
|
||||
&.context-indicator-value {
|
||||
border-left-color: #52c41a; // green
|
||||
background-color: rgba(82, 196, 26, 0.1);
|
||||
}
|
||||
|
||||
&.context-indicator-conjunction {
|
||||
border-left-color: #fa8c16; // orange
|
||||
background-color: rgba(250, 140, 22, 0.1);
|
||||
}
|
||||
|
||||
&.context-indicator-function {
|
||||
border-left-color: #13c2c2; // cyan
|
||||
background-color: rgba(19, 194, 194, 0.1);
|
||||
}
|
||||
|
||||
&.context-indicator-parenthesis {
|
||||
border-left-color: #eb2f96; // magenta
|
||||
background-color: rgba(235, 47, 150, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.query-status-popover {
|
||||
.ant-popover-content {
|
||||
background: var(--bg-vanilla-100);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
export const queryExamples = [
|
||||
{
|
||||
label: 'Basic Query',
|
||||
query: "status = 'error'",
|
||||
description: 'Find all errors',
|
||||
},
|
||||
{
|
||||
label: 'Multiple Conditions',
|
||||
query: "status = 'error' AND service = 'frontend'",
|
||||
description: 'Find errors from frontend service',
|
||||
},
|
||||
{
|
||||
label: 'IN Operator',
|
||||
query: "status IN ['error', 'warning']",
|
||||
description: 'Find items with specific statuses',
|
||||
},
|
||||
{
|
||||
label: 'Function Usage',
|
||||
query: "HAS(service, 'frontend')",
|
||||
description: 'Use HAS function',
|
||||
},
|
||||
{
|
||||
label: 'Numeric Comparison',
|
||||
query: 'duration > 1000',
|
||||
description: 'Find items with duration greater than 1000ms',
|
||||
},
|
||||
{
|
||||
label: 'Range Query',
|
||||
query: 'duration BETWEEN 100 AND 1000',
|
||||
description: 'Find items with duration between 100ms and 1000ms',
|
||||
},
|
||||
{
|
||||
label: 'Pattern Matching',
|
||||
query: "service LIKE 'front%'",
|
||||
description: 'Find services starting with "front"',
|
||||
},
|
||||
{
|
||||
label: 'Complex Conditions',
|
||||
query: "(status = 'error' OR status = 'warning') AND service = 'frontend'",
|
||||
description: 'Find errors or warnings from frontend service',
|
||||
},
|
||||
{
|
||||
label: 'Multiple Functions',
|
||||
query: "HAS(service, 'frontend') AND HAS(status, 'error')",
|
||||
description: 'Use multiple HAS functions',
|
||||
},
|
||||
{
|
||||
label: 'NOT Operator',
|
||||
query: "NOT status = 'success'",
|
||||
description: 'Find items that are not successful',
|
||||
},
|
||||
{
|
||||
label: 'Array Contains',
|
||||
query: "tags CONTAINS 'production'",
|
||||
description: 'Find items with production tag',
|
||||
},
|
||||
{
|
||||
label: 'Regex Pattern',
|
||||
query: "service REGEXP '^prod-.*'",
|
||||
description: 'Find services matching regex pattern',
|
||||
},
|
||||
{
|
||||
label: 'Null Check',
|
||||
query: 'error IS NULL',
|
||||
description: 'Find items without errors',
|
||||
},
|
||||
{
|
||||
label: 'Multiple Attributes',
|
||||
query:
|
||||
"service = 'frontend' AND environment = 'production' AND status = 'error'",
|
||||
description: 'Find production frontend errors',
|
||||
},
|
||||
{
|
||||
label: 'Nested Conditions',
|
||||
query:
|
||||
"(service = 'frontend' OR service = 'backend') AND (status = 'error' OR status = 'warning')",
|
||||
description: 'Find errors or warnings from frontend or backend',
|
||||
},
|
||||
];
|
||||
243
frontend/src/components/QueryBuilderV2/QueryV2/QueryV2.tsx
Normal file
243
frontend/src/components/QueryBuilderV2/QueryV2/QueryV2.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { Dropdown } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import QBEntityOptions from 'container/QueryBuilder/components/QBEntityOptions/QBEntityOptions';
|
||||
import { QueryProps } from 'container/QueryBuilder/components/Query/Query.interfaces';
|
||||
import SpanScopeSelector from 'container/QueryBuilder/filters/QueryBuilderSearchV2/SpanScopeSelector';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { Copy, Ellipsis, Trash } from 'lucide-react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import MetricsAggregateSection from './MerticsAggregateSection/MetricsAggregateSection';
|
||||
import { MetricsSelect } from './MetricsSelect/MetricsSelect';
|
||||
import QueryAddOns from './QueryAddOns/QueryAddOns';
|
||||
import QueryAggregation from './QueryAggregation/QueryAggregation';
|
||||
import QuerySearch from './QuerySearch/QuerySearch';
|
||||
|
||||
export const QueryV2 = memo(function QueryV2({
|
||||
ref,
|
||||
index,
|
||||
queryVariant,
|
||||
query,
|
||||
filterConfigs,
|
||||
isListViewPanel = false,
|
||||
version,
|
||||
showOnlyWhereClause = false,
|
||||
signalSource = '',
|
||||
}: QueryProps & { ref: React.RefObject<HTMLDivElement> }): JSX.Element {
|
||||
const { cloneQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const showFunctions = query?.functions?.length > 0;
|
||||
const { dataSource } = query;
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
const {
|
||||
handleChangeQueryData,
|
||||
handleDeleteQuery,
|
||||
handleQueryFunctionsUpdates,
|
||||
handleChangeDataSource,
|
||||
} = useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
filterConfigs,
|
||||
isListViewPanel,
|
||||
entityVersion: version,
|
||||
});
|
||||
|
||||
const handleToggleDisableQuery = useCallback(() => {
|
||||
handleChangeQueryData('disabled', !query.disabled);
|
||||
}, [handleChangeQueryData, query]);
|
||||
|
||||
const handleToggleCollapsQuery = (): void => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
};
|
||||
|
||||
const handleCloneEntity = (): void => {
|
||||
cloneQuery('query', query);
|
||||
};
|
||||
|
||||
const showReduceTo = useMemo(
|
||||
() =>
|
||||
dataSource === DataSource.METRICS &&
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.PIE ||
|
||||
panelType === PANEL_TYPES.VALUE),
|
||||
[dataSource, panelType],
|
||||
);
|
||||
|
||||
const showSpanScopeSelector = useMemo(() => dataSource === DataSource.TRACES, [
|
||||
dataSource,
|
||||
]);
|
||||
|
||||
const handleChangeAggregateEvery = useCallback(
|
||||
(value: IBuilderQuery['stepInterval']) => {
|
||||
handleChangeQueryData('stepInterval', value);
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
(handleChangeQueryData as HandleChangeQueryDataV5)('filter', {
|
||||
expression: value,
|
||||
});
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
const handleChangeAggregation = useCallback(
|
||||
(value: string) => {
|
||||
(handleChangeQueryData as HandleChangeQueryDataV5)('aggregations', [
|
||||
{
|
||||
expression: value,
|
||||
},
|
||||
]);
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('query-v2', { 'where-clause-view': showOnlyWhereClause })}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="qb-content-section">
|
||||
{!showOnlyWhereClause && (
|
||||
<div className="qb-header-container">
|
||||
<div className="query-actions-container">
|
||||
<div className="query-actions-left-container">
|
||||
<QBEntityOptions
|
||||
isMetricsDataSource={dataSource === DataSource.METRICS}
|
||||
showFunctions={
|
||||
(version && version === ENTITY_VERSION_V4) ||
|
||||
query.dataSource === DataSource.LOGS ||
|
||||
query.dataSource === DataSource.METRICS ||
|
||||
showFunctions ||
|
||||
false
|
||||
}
|
||||
isCollapsed={isCollapsed}
|
||||
entityType="query"
|
||||
entityData={query}
|
||||
onToggleVisibility={handleToggleDisableQuery}
|
||||
onDelete={handleDeleteQuery}
|
||||
onCloneQuery={cloneQuery}
|
||||
onCollapseEntity={handleToggleCollapsQuery}
|
||||
query={query}
|
||||
onQueryFunctionsUpdates={handleQueryFunctionsUpdates}
|
||||
showDeleteButton={false}
|
||||
showCloneOption={false}
|
||||
isListViewPanel={isListViewPanel}
|
||||
index={index}
|
||||
queryVariant={queryVariant}
|
||||
onChangeDataSource={handleChangeDataSource}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isListViewPanel && (
|
||||
<Dropdown
|
||||
className="query-actions-dropdown"
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
label: 'Clone',
|
||||
key: 'clone-query',
|
||||
icon: <Copy size={14} />,
|
||||
onClick: handleCloneEntity,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
key: 'delete-query',
|
||||
icon: <Trash size={14} />,
|
||||
onClick: handleDeleteQuery,
|
||||
},
|
||||
],
|
||||
}}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Ellipsis size={16} />
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="qb-elements-container">
|
||||
<div className="qb-search-container">
|
||||
{dataSource === DataSource.METRICS && (
|
||||
<div className="metrics-select-container">
|
||||
<MetricsSelect
|
||||
query={query}
|
||||
index={index}
|
||||
version={ENTITY_VERSION_V5}
|
||||
signalSource={signalSource as 'meter' | ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="qb-search-filter-container">
|
||||
<div className="query-search-container">
|
||||
<QuerySearch
|
||||
key={`query-search-${query.queryName}-${query.dataSource}`}
|
||||
onChange={handleSearchChange}
|
||||
queryData={query}
|
||||
dataSource={dataSource}
|
||||
signalSource={signalSource}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showSpanScopeSelector && (
|
||||
<div className="traces-search-filter-container">
|
||||
<div className="traces-search-filter-in">in</div>
|
||||
<SpanScopeSelector query={query} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!showOnlyWhereClause &&
|
||||
!isListViewPanel &&
|
||||
dataSource !== DataSource.METRICS && (
|
||||
<QueryAggregation
|
||||
dataSource={dataSource}
|
||||
key={`query-search-${query.queryName}-${query.dataSource}`}
|
||||
panelType={panelType || undefined}
|
||||
onAggregationIntervalChange={handleChangeAggregateEvery}
|
||||
onChange={handleChangeAggregation}
|
||||
queryData={query}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showOnlyWhereClause && dataSource === DataSource.METRICS && (
|
||||
<MetricsAggregateSection
|
||||
panelType={panelType}
|
||||
query={query}
|
||||
index={index}
|
||||
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}`}
|
||||
version="v4"
|
||||
signalSource={signalSource as 'meter' | ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showOnlyWhereClause && (
|
||||
<QueryAddOns
|
||||
index={index}
|
||||
query={query}
|
||||
version="v3"
|
||||
isListViewPanel={isListViewPanel}
|
||||
showReduceTo={showReduceTo}
|
||||
panelType={panelType}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
536
frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts
Normal file
536
frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { convertFiltersToExpression } from '../utils';
|
||||
|
||||
describe('convertFiltersToExpression', () => {
|
||||
it('should handle empty, null, and undefined inputs', () => {
|
||||
// Test null and undefined
|
||||
expect(convertFiltersToExpression(null as any)).toEqual({ expression: '' });
|
||||
expect(convertFiltersToExpression(undefined as any)).toEqual({
|
||||
expression: '',
|
||||
});
|
||||
|
||||
// Test empty filters
|
||||
expect(convertFiltersToExpression({ items: [], op: 'AND' })).toEqual({
|
||||
expression: '',
|
||||
});
|
||||
expect(
|
||||
convertFiltersToExpression({ items: undefined, op: 'AND' } as any),
|
||||
).toEqual({ expression: '' });
|
||||
});
|
||||
|
||||
it('should convert basic comparison operators with proper value formatting', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'service', type: 'string' },
|
||||
op: '=',
|
||||
value: 'api-gateway',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'status', type: 'string' },
|
||||
op: '!=',
|
||||
value: 'error',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'duration', type: 'number' },
|
||||
op: '>',
|
||||
value: 100,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'count', type: 'number' },
|
||||
op: '<=',
|
||||
value: 50,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: { key: 'is_active', type: 'boolean' },
|
||||
op: '=',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
key: { key: 'enabled', type: 'boolean' },
|
||||
op: '=',
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
key: { key: 'count', type: 'number' },
|
||||
op: '=',
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
key: { key: 'regex', type: 'string' },
|
||||
op: 'regex',
|
||||
value: '.*',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"service = 'api-gateway' AND status != 'error' AND duration > 100 AND count <= 50 AND is_active = true AND enabled = false AND count = 0 AND regex REGEXP '.*'",
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle string value formatting and escaping', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'message', type: 'string' },
|
||||
op: '=',
|
||||
value: "user's data",
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'description', type: 'string' },
|
||||
op: '=',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'path', type: 'string' },
|
||||
op: '=',
|
||||
value: '/api/v1/users',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"message = 'user\\'s data' AND description = '' AND path = '/api/v1/users'",
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle IN operator with various value types and array formatting', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'service', type: 'string' },
|
||||
op: 'IN',
|
||||
value: ['api-gateway', 'user-service', 'auth-service'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'status', type: 'string' },
|
||||
op: 'IN',
|
||||
value: 'success', // Single value should be converted to array
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'tags', type: 'string' },
|
||||
op: 'IN',
|
||||
value: [], // Empty array
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'name', type: 'string' },
|
||||
op: 'IN',
|
||||
value: ["John's", "Mary's", 'Bob'], // Values with quotes
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"service in ['api-gateway', 'user-service', 'auth-service'] AND status in ['success'] AND tags in [] AND name in ['John\\'s', 'Mary\\'s', 'Bob']",
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert deprecated operators to their modern equivalents', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'service', type: 'string' },
|
||||
op: 'nin',
|
||||
value: ['api-gateway', 'user-service'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'message', type: 'string' },
|
||||
op: 'nlike',
|
||||
value: 'error',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'path', type: 'string' },
|
||||
op: 'nregex',
|
||||
value: '/api/.*',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'service', type: 'string' },
|
||||
op: 'NIN', // Test case insensitivity
|
||||
value: ['api-gateway'],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: { key: 'user_id', type: 'string' },
|
||||
op: 'nexists',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
key: { key: 'description', type: 'string' },
|
||||
op: 'ncontains',
|
||||
value: 'error',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
key: { key: 'tags', type: 'string' },
|
||||
op: 'nhas',
|
||||
value: 'production',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
key: { key: 'labels', type: 'string' },
|
||||
op: 'nhasany',
|
||||
value: ['env:prod', 'service:api'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"service NOT IN ['api-gateway', 'user-service'] AND message NOT LIKE 'error' AND path NOT REGEXP '/api/.*' AND service NOT IN ['api-gateway'] AND user_id NOT EXISTS AND description NOT CONTAINS 'error' AND NOT has(tags, 'production') AND NOT hasAny(labels, ['env:prod', 'service:api'])",
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-value operators and function operators', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'user_id', type: 'string' },
|
||||
op: 'EXISTS',
|
||||
value: '', // Value should be ignored for EXISTS
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'user_id', type: 'string' },
|
||||
op: 'EXISTS',
|
||||
value: 'some-value', // Value should be ignored for EXISTS
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'tags', type: 'string' },
|
||||
op: 'has',
|
||||
value: 'production',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'tags', type: 'string' },
|
||||
op: 'hasAny',
|
||||
value: ['production', 'staging'],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: { key: 'tags', type: 'string' },
|
||||
op: 'hasAll',
|
||||
value: ['production', 'monitoring'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"user_id exists AND user_id exists AND has(tags, 'production') AND hasAny(tags, ['production', 'staging']) AND hasAll(tags, ['production', 'monitoring'])",
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out invalid filters and handle edge cases', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'service', type: 'string' },
|
||||
op: '=',
|
||||
value: 'api-gateway',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: undefined, // Invalid filter - should be skipped
|
||||
op: '=',
|
||||
value: 'error',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: '', type: 'string' }, // Invalid filter with empty key - should be skipped
|
||||
op: '=',
|
||||
value: 'test',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'status', type: 'string' },
|
||||
op: ' = ', // Test whitespace handling
|
||||
value: 'success',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: { key: 'service', type: 'string' },
|
||||
op: 'In', // Test mixed case handling
|
||||
value: ['api-gateway'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"service = 'api-gateway' AND status = 'success' AND service in ['api-gateway']",
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex mixed operator scenarios with proper joining', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'service', type: 'string' },
|
||||
op: 'IN',
|
||||
value: ['api-gateway', 'user-service'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'user_id', type: 'string' },
|
||||
op: 'EXISTS',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'tags', type: 'string' },
|
||||
op: 'has',
|
||||
value: 'production',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'duration', type: 'number' },
|
||||
op: '>',
|
||||
value: 100,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: { key: 'status', type: 'string' },
|
||||
op: 'nin',
|
||||
value: ['error', 'timeout'],
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
key: { key: 'method', type: 'string' },
|
||||
op: '=',
|
||||
value: 'POST',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"service in ['api-gateway', 'user-service'] AND user_id exists AND has(tags, 'production') AND duration > 100 AND status NOT IN ['error', 'timeout'] AND method = 'POST'",
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle all numeric comparison operators and edge cases', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'count', type: 'number' },
|
||||
op: '=',
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'score', type: 'number' },
|
||||
op: '>',
|
||||
value: 100,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'limit', type: 'number' },
|
||||
op: '>=',
|
||||
value: 50,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'threshold', type: 'number' },
|
||||
op: '<',
|
||||
value: 1000,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: { key: 'max_value', type: 'number' },
|
||||
op: '<=',
|
||||
value: 999,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
key: { key: 'values', type: 'string' },
|
||||
op: 'IN',
|
||||
value: ['1', '2', '3', '4', '5'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"count = 0 AND score > 100 AND limit >= 50 AND threshold < 1000 AND max_value <= 999 AND values in ['1', '2', '3', '4', '5']",
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle boolean values and string comparisons with special characters', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'is_active', type: 'boolean' },
|
||||
op: '=',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'is_deleted', type: 'boolean' },
|
||||
op: '=',
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'email', type: 'string' },
|
||||
op: '=',
|
||||
value: 'user@example.com',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'description', type: 'string' },
|
||||
op: '=',
|
||||
value: 'Contains "quotes" and \'apostrophes\'',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: { key: 'path', type: 'string' },
|
||||
op: '=',
|
||||
value: '/api/v1/users/123?filter=true',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"is_active = true AND is_deleted = false AND email = 'user@example.com' AND description = 'Contains \"quotes\" and \\'apostrophes\\'' AND path = '/api/v1/users/123?filter=true'",
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle all function operators and complex array scenarios', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'tags', type: 'string' },
|
||||
op: 'has',
|
||||
value: 'production',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'labels', type: 'string' },
|
||||
op: 'hasAny',
|
||||
value: ['env:prod', 'service:api'],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'metadata', type: 'string' },
|
||||
op: 'hasAll',
|
||||
value: ['version:1.0', 'team:backend'],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'services', type: 'string' },
|
||||
op: 'IN',
|
||||
value: ['api-gateway', 'user-service', 'auth-service', 'payment-service'],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: { key: 'excluded_services', type: 'string' },
|
||||
op: 'nin',
|
||||
value: ['legacy-service', 'deprecated-service'],
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
key: { key: 'status_codes', type: 'string' },
|
||||
op: 'IN',
|
||||
value: ['200', '201', '400', '500'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"has(tags, 'production') AND hasAny(labels, ['env:prod', 'service:api']) AND hasAll(metadata, ['version:1.0', 'team:backend']) AND services in ['api-gateway', 'user-service', 'auth-service', 'payment-service'] AND excluded_services NOT IN ['legacy-service', 'deprecated-service'] AND status_codes in ['200', '201', '400', '500']",
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle specific deprecated operators: nhas, ncontains, nexists', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'user_id', type: 'string' },
|
||||
op: 'nexists',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'description', type: 'string' },
|
||||
op: 'ncontains',
|
||||
value: 'error',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'tags', type: 'string' },
|
||||
op: 'nhas',
|
||||
value: 'production',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'labels', type: 'string' },
|
||||
op: 'nhasany',
|
||||
value: ['env:prod', 'service:api'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"user_id NOT EXISTS AND description NOT CONTAINS 'error' AND NOT has(tags, 'production') AND NOT hasAny(labels, ['env:prod', 'service:api'])",
|
||||
});
|
||||
});
|
||||
});
|
||||
688
frontend/src/components/QueryBuilderV2/utils.ts
Normal file
688
frontend/src/components/QueryBuilderV2/utils.ts
Normal file
@@ -0,0 +1,688 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { createAggregation } from 'api/v5/queryRange/prepareQueryRangePayloadV5';
|
||||
import {
|
||||
DEPRECATED_OPERATORS_MAP,
|
||||
OPERATORS,
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { IQueryPair } from 'types/antlrQueryTypes';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
Having,
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
TagFilter,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
LogAggregation,
|
||||
MetricAggregation,
|
||||
TraceAggregation,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
import { unquote } from 'utils/stringUtils';
|
||||
import { isFunctionOperator, isNonValueOperator } from 'utils/tokenUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
/**
|
||||
* Check if an operator requires array values (like IN, NOT IN)
|
||||
* @param operator - The operator to check
|
||||
* @returns True if the operator requires array values
|
||||
*/
|
||||
const isArrayOperator = (operator: string): boolean => {
|
||||
const arrayOperators = ['in', 'not in', 'IN', 'NOT IN'];
|
||||
return arrayOperators.includes(operator);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a value for the expression string
|
||||
* @param value - The value to format
|
||||
* @param operator - The operator being used (to determine if array is needed)
|
||||
* @returns Formatted value string
|
||||
*/
|
||||
const formatValueForExpression = (
|
||||
value: string[] | string | number | boolean,
|
||||
operator?: string,
|
||||
): string => {
|
||||
// For IN operators, ensure value is always an array
|
||||
if (isArrayOperator(operator || '')) {
|
||||
const arrayValue = Array.isArray(value) ? value : [value];
|
||||
return `[${arrayValue
|
||||
.map((v) =>
|
||||
typeof v === 'string' ? `'${v.replace(/'/g, "\\'")}'` : String(v),
|
||||
)
|
||||
.join(', ')}]`;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// Handle array values (e.g., for IN operations)
|
||||
return `[${value
|
||||
.map((v) =>
|
||||
typeof v === 'string' ? `'${v.replace(/'/g, "\\'")}'` : String(v),
|
||||
)
|
||||
.join(', ')}]`;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// Add single quotes around all string values and escape internal single quotes
|
||||
return `'${value.replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
export const convertFiltersToExpression = (
|
||||
filters: TagFilter,
|
||||
): { expression: string } => {
|
||||
if (!filters?.items || filters.items.length === 0) {
|
||||
return { expression: '' };
|
||||
}
|
||||
|
||||
const expressions = filters.items
|
||||
.map((filter) => {
|
||||
const { key, op, value } = filter;
|
||||
|
||||
// Skip if key is not defined
|
||||
if (!key?.key) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let operator = op.trim().toLowerCase();
|
||||
if (Object.keys(DEPRECATED_OPERATORS_MAP).includes(operator)) {
|
||||
operator =
|
||||
DEPRECATED_OPERATORS_MAP[
|
||||
operator as keyof typeof DEPRECATED_OPERATORS_MAP
|
||||
];
|
||||
}
|
||||
|
||||
if (isNonValueOperator(operator)) {
|
||||
return `${key.key} ${operator}`;
|
||||
}
|
||||
|
||||
if (isFunctionOperator(operator)) {
|
||||
// Get the proper function name from QUERY_BUILDER_FUNCTIONS
|
||||
const functionOperators = Object.values(QUERY_BUILDER_FUNCTIONS);
|
||||
const properFunctionName =
|
||||
functionOperators.find(
|
||||
(func: string) => func.toLowerCase() === operator.toLowerCase(),
|
||||
) || operator;
|
||||
|
||||
const formattedValue = formatValueForExpression(value, operator);
|
||||
return `${properFunctionName}(${key.key}, ${formattedValue})`;
|
||||
}
|
||||
|
||||
const formattedValue = formatValueForExpression(value, operator);
|
||||
return `${key.key} ${operator} ${formattedValue}`;
|
||||
})
|
||||
.filter((expression) => expression !== ''); // Remove empty expressions
|
||||
|
||||
return {
|
||||
expression: expressions.join(' AND '),
|
||||
};
|
||||
};
|
||||
|
||||
const formatValuesForFilter = (value: string | string[]): string | string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => (typeof v === 'string' ? unquote(v) : String(v)));
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return unquote(value);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
export const convertExpressionToFilters = (
|
||||
expression: string,
|
||||
): TagFilterItem[] => {
|
||||
if (!expression) return [];
|
||||
|
||||
const queryPairs = extractQueryPairs(expression);
|
||||
const filters: TagFilterItem[] = [];
|
||||
|
||||
queryPairs.forEach((pair) => {
|
||||
const operator = pair.hasNegation
|
||||
? getOperatorValue(`NOT_${pair.operator}`.toUpperCase())
|
||||
: getOperatorValue(pair.operator.toUpperCase());
|
||||
filters.push({
|
||||
id: uuid(),
|
||||
op: operator,
|
||||
key: {
|
||||
id: pair.key,
|
||||
key: pair.key,
|
||||
type: '',
|
||||
},
|
||||
value: pair.isMultiValue
|
||||
? formatValuesForFilter(pair.valueList as string[]) ?? []
|
||||
: formatValuesForFilter(pair.value as string) ?? '',
|
||||
});
|
||||
});
|
||||
|
||||
return filters;
|
||||
};
|
||||
|
||||
export const convertFiltersToExpressionWithExistingQuery = (
|
||||
filters: TagFilter,
|
||||
existingQuery: string | undefined,
|
||||
): { filters: TagFilter; filter: { expression: string } } => {
|
||||
// Check for deprecated operators and replace them with new operators
|
||||
const updatedFilters = cloneDeep(filters);
|
||||
|
||||
// Replace deprecated operators in filter items
|
||||
if (updatedFilters?.items) {
|
||||
updatedFilters.items = updatedFilters.items.map((item) => {
|
||||
const opLower = item.op?.toLowerCase();
|
||||
if (Object.keys(DEPRECATED_OPERATORS_MAP).includes(opLower)) {
|
||||
return {
|
||||
...item,
|
||||
op: DEPRECATED_OPERATORS_MAP[
|
||||
opLower as keyof typeof DEPRECATED_OPERATORS_MAP
|
||||
].toLowerCase(),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
if (!existingQuery) {
|
||||
// If no existing query, return filters with a newly generated expression
|
||||
return {
|
||||
filters: updatedFilters,
|
||||
filter: convertFiltersToExpression(updatedFilters),
|
||||
};
|
||||
}
|
||||
|
||||
// Extract query pairs from the existing query
|
||||
const queryPairs = extractQueryPairs(existingQuery.trim());
|
||||
let queryPairsMap: Map<string, IQueryPair> = new Map();
|
||||
const nonExistingFilters: TagFilterItem[] = [];
|
||||
let modifiedQuery = existingQuery; // We'll modify this query as we proceed
|
||||
const visitedPairs: Set<string> = new Set(); // Set to track visited query pairs
|
||||
|
||||
// Map extracted query pairs to key-specific pair information for faster access
|
||||
if (queryPairs.length > 0) {
|
||||
queryPairsMap = new Map(
|
||||
queryPairs.map((pair) => {
|
||||
const key = pair.hasNegation
|
||||
? `${pair.key}-not ${pair.operator}`.trim().toLowerCase()
|
||||
: `${pair.key}-${pair.operator}`.trim().toLowerCase();
|
||||
return [key, pair];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
filters?.items?.forEach((filter) => {
|
||||
const { key, op, value } = filter;
|
||||
|
||||
// Skip invalid filters with no key
|
||||
if (!key) return;
|
||||
|
||||
let shouldAddToNonExisting = true; // Flag to decide if the filter should be added to non-existing filters
|
||||
const sanitizedOperator = op.trim().toUpperCase();
|
||||
|
||||
// Check if the operator is IN or NOT IN
|
||||
if (
|
||||
[OPERATORS.IN, `${OPERATORS.NOT} ${OPERATORS.IN}`].includes(
|
||||
sanitizedOperator,
|
||||
)
|
||||
) {
|
||||
const existingPair = queryPairsMap.get(
|
||||
`${key.key}-${op}`.trim().toLowerCase(),
|
||||
);
|
||||
const formattedValue = formatValueForExpression(value, op);
|
||||
|
||||
// If a matching query pair exists, modify the query
|
||||
if (
|
||||
existingPair &&
|
||||
existingPair.position?.valueStart &&
|
||||
existingPair.position?.valueEnd
|
||||
) {
|
||||
visitedPairs.add(`${key.key}-${op}`.trim().toLowerCase());
|
||||
modifiedQuery =
|
||||
modifiedQuery.slice(0, existingPair.position.valueStart) +
|
||||
formattedValue +
|
||||
modifiedQuery.slice(existingPair.position.valueEnd + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle the different cases for IN operator
|
||||
switch (sanitizedOperator) {
|
||||
case OPERATORS.IN:
|
||||
// If there's a NOT IN or equal operator, merge the filter
|
||||
if (
|
||||
queryPairsMap.has(
|
||||
`${key.key}-${OPERATORS.NOT} ${op}`.trim().toLowerCase(),
|
||||
)
|
||||
) {
|
||||
const notInPair = queryPairsMap.get(
|
||||
`${key.key}-${OPERATORS.NOT} ${op}`.trim().toLowerCase(),
|
||||
);
|
||||
visitedPairs.add(
|
||||
`${key.key}-${OPERATORS.NOT} ${op}`.trim().toLowerCase(),
|
||||
);
|
||||
if (notInPair?.position?.valueEnd) {
|
||||
modifiedQuery = `${modifiedQuery.slice(
|
||||
0,
|
||||
notInPair.position.negationStart,
|
||||
)}${OPERATORS.IN} ${formattedValue} ${modifiedQuery.slice(
|
||||
notInPair.position.valueEnd + 1,
|
||||
)}`;
|
||||
}
|
||||
shouldAddToNonExisting = false; // Don't add this to non-existing filters
|
||||
} else if (
|
||||
queryPairsMap.has(`${key.key}-${OPERATORS['=']}`.trim().toLowerCase())
|
||||
) {
|
||||
const equalsPair = queryPairsMap.get(
|
||||
`${key.key}-${OPERATORS['=']}`.trim().toLowerCase(),
|
||||
);
|
||||
visitedPairs.add(`${key.key}-${OPERATORS['=']}`.trim().toLowerCase());
|
||||
if (equalsPair?.position?.valueEnd) {
|
||||
modifiedQuery = `${modifiedQuery.slice(
|
||||
0,
|
||||
equalsPair.position.operatorStart,
|
||||
)}${OPERATORS.IN} ${formattedValue} ${modifiedQuery.slice(
|
||||
equalsPair.position.valueEnd + 1,
|
||||
)}`;
|
||||
}
|
||||
shouldAddToNonExisting = false; // Don't add this to non-existing filters
|
||||
} else if (
|
||||
queryPairsMap.has(`${key.key}-${OPERATORS['!=']}`.trim().toLowerCase())
|
||||
) {
|
||||
const notEqualsPair = queryPairsMap.get(
|
||||
`${key.key}-${OPERATORS['!=']}`.trim().toLowerCase(),
|
||||
);
|
||||
visitedPairs.add(`${key.key}-${OPERATORS['!=']}`.trim().toLowerCase());
|
||||
if (notEqualsPair?.position?.valueEnd) {
|
||||
modifiedQuery = `${modifiedQuery.slice(
|
||||
0,
|
||||
notEqualsPair.position.operatorStart,
|
||||
)}${OPERATORS.IN} ${formattedValue} ${modifiedQuery.slice(
|
||||
notEqualsPair.position.valueEnd + 1,
|
||||
)}`;
|
||||
}
|
||||
shouldAddToNonExisting = false; // Don't add this to non-existing filters
|
||||
}
|
||||
break;
|
||||
case `${OPERATORS.NOT} ${OPERATORS.IN}`:
|
||||
if (
|
||||
queryPairsMap.has(`${key.key}-${OPERATORS['!=']}`.trim().toLowerCase())
|
||||
) {
|
||||
const notEqualsPair = queryPairsMap.get(
|
||||
`${key.key}-${OPERATORS['!=']}`.trim().toLowerCase(),
|
||||
);
|
||||
visitedPairs.add(`${key.key}-${OPERATORS['!=']}`.trim().toLowerCase());
|
||||
if (notEqualsPair?.position?.valueEnd) {
|
||||
modifiedQuery = `${modifiedQuery.slice(
|
||||
0,
|
||||
notEqualsPair.position.operatorStart,
|
||||
)}${OPERATORS.NOT} ${
|
||||
OPERATORS.IN
|
||||
} ${formattedValue} ${modifiedQuery.slice(
|
||||
notEqualsPair.position.valueEnd + 1,
|
||||
)}`;
|
||||
}
|
||||
shouldAddToNonExisting = false; // Don't add this to non-existing filters
|
||||
}
|
||||
break; // No operation needed for NOT IN case
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
queryPairsMap.has(`${filter.key?.key}-${filter.op}`.trim().toLowerCase())
|
||||
) {
|
||||
visitedPairs.add(`${filter.key?.key}-${filter.op}`.trim().toLowerCase());
|
||||
}
|
||||
|
||||
// Add filters that don't have an existing pair to non-existing filters
|
||||
if (
|
||||
shouldAddToNonExisting &&
|
||||
!queryPairsMap.has(`${filter.key?.key}-${filter.op}`.trim().toLowerCase())
|
||||
) {
|
||||
nonExistingFilters.push(filter);
|
||||
}
|
||||
});
|
||||
|
||||
// Create new filters from non-visited query pairs
|
||||
const newFilterItems: TagFilterItem[] = [];
|
||||
queryPairsMap.forEach((pair, key) => {
|
||||
if (!visitedPairs.has(key)) {
|
||||
const operator = pair.hasNegation
|
||||
? getOperatorValue(`NOT_${pair.operator}`.toUpperCase())
|
||||
: getOperatorValue(pair.operator.toUpperCase());
|
||||
|
||||
newFilterItems.push({
|
||||
id: uuid(),
|
||||
op: operator,
|
||||
key: {
|
||||
id: pair.key,
|
||||
key: pair.key,
|
||||
type: '',
|
||||
},
|
||||
value: pair.isMultiValue
|
||||
? formatValuesForFilter(pair.valueList as string[]) ?? ''
|
||||
: formatValuesForFilter(pair.value as string) ?? '',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Merge new filter items with existing ones
|
||||
if (newFilterItems.length > 0 && updatedFilters?.items) {
|
||||
updatedFilters.items = [...updatedFilters.items, ...newFilterItems];
|
||||
}
|
||||
|
||||
// If no non-existing filters, return the modified query directly
|
||||
if (nonExistingFilters.length === 0) {
|
||||
return {
|
||||
filters: updatedFilters,
|
||||
filter: { expression: modifiedQuery },
|
||||
};
|
||||
}
|
||||
|
||||
// Convert non-existing filters to an expression and append to the modified query
|
||||
const nonExistingFilterExpression = convertFiltersToExpression({
|
||||
items: nonExistingFilters,
|
||||
op: filters.op || 'AND',
|
||||
});
|
||||
|
||||
if (nonExistingFilterExpression.expression) {
|
||||
return {
|
||||
filters: updatedFilters,
|
||||
filter: {
|
||||
expression: `${modifiedQuery.trim()} ${
|
||||
nonExistingFilterExpression.expression
|
||||
}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Return the final result with the modified query
|
||||
return {
|
||||
filters: updatedFilters,
|
||||
filter: { expression: modifiedQuery || '' },
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes specified key-value pairs from a logical query expression string.
|
||||
*
|
||||
* This function parses the given query expression and removes any query pairs
|
||||
* whose keys match those in the `keysToRemove` array. It also removes any trailing
|
||||
* logical conjunctions (e.g., `AND`, `OR`) and whitespace that follow the matched pairs,
|
||||
* ensuring that the resulting expression remains valid and clean.
|
||||
*
|
||||
* @param expression - The full query string.
|
||||
* @param keysToRemove - An array of keys (case-insensitive) that should be removed from the expression.
|
||||
* @returns A new expression string with the specified keys and their associated clauses removed.
|
||||
*/
|
||||
export const removeKeysFromExpression = (
|
||||
expression: string,
|
||||
keysToRemove: string[],
|
||||
): string => {
|
||||
if (!keysToRemove || keysToRemove.length === 0) {
|
||||
return expression;
|
||||
}
|
||||
|
||||
let updatedExpression = expression;
|
||||
|
||||
if (updatedExpression) {
|
||||
keysToRemove.forEach((key) => {
|
||||
// Extract key-value query pairs from the expression
|
||||
const existingQueryPairs = extractQueryPairs(updatedExpression);
|
||||
|
||||
let queryPairsMap: Map<string, IQueryPair>;
|
||||
|
||||
if (existingQueryPairs.length > 0) {
|
||||
// Build a map for quick lookup of query pairs by their lowercase trimmed keys
|
||||
queryPairsMap = new Map(
|
||||
existingQueryPairs.map((pair) => {
|
||||
const key = pair.key.trim().toLowerCase();
|
||||
return [key, pair];
|
||||
}),
|
||||
);
|
||||
|
||||
// Lookup the current query pair using the attribute key (case-insensitive)
|
||||
const currentQueryPair = queryPairsMap.get(`${key}`.trim().toLowerCase());
|
||||
if (currentQueryPair && currentQueryPair.isComplete) {
|
||||
// Determine the start index of the query pair (fallback order: key → operator → value)
|
||||
const queryPairStart =
|
||||
currentQueryPair.position.keyStart ??
|
||||
currentQueryPair.position.operatorStart ??
|
||||
currentQueryPair.position.valueStart;
|
||||
// Determine the end index of the query pair (fallback order: value → operator → key)
|
||||
let queryPairEnd =
|
||||
currentQueryPair.position.valueEnd ??
|
||||
currentQueryPair.position.operatorEnd ??
|
||||
currentQueryPair.position.keyEnd;
|
||||
// Get the part of the expression that comes after the current query pair
|
||||
const expressionAfterPair = `${updatedExpression.slice(queryPairEnd + 1)}`;
|
||||
// Match optional spaces and an optional conjunction (AND/OR), case-insensitive
|
||||
const conjunctionOrSpacesRegex = /^(\s*((AND|OR)\s+)?)/i;
|
||||
const match = expressionAfterPair.match(conjunctionOrSpacesRegex);
|
||||
if (match && match.length > 0) {
|
||||
// If match is found, extend the queryPairEnd to include the matched part
|
||||
queryPairEnd += match[0].length;
|
||||
}
|
||||
// Remove the full query pair (including any conjunction/whitespace) from the expression
|
||||
updatedExpression = `${updatedExpression.slice(
|
||||
0,
|
||||
queryPairStart,
|
||||
)}${updatedExpression.slice(queryPairEnd + 1)}`.trim();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return updatedExpression;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert old having format to new having format
|
||||
* @param having - Array of old having objects with columnName, op, and value
|
||||
* @returns New having format with expression string
|
||||
*/
|
||||
export const convertHavingToExpression = (
|
||||
having: Having[],
|
||||
): { expression: string } => {
|
||||
if (!having || having.length === 0) {
|
||||
return { expression: '' };
|
||||
}
|
||||
|
||||
const expressions = having
|
||||
.map((havingItem) => {
|
||||
const { columnName, op, value } = havingItem;
|
||||
|
||||
// Skip if columnName is not defined
|
||||
if (!columnName) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Format value based on its type
|
||||
let formattedValue: string;
|
||||
if (Array.isArray(value)) {
|
||||
// For array values, format as [val1, val2, ...]
|
||||
formattedValue = `[${value.join(', ')}]`;
|
||||
} else {
|
||||
// For single values, just convert to string
|
||||
formattedValue = String(value);
|
||||
}
|
||||
|
||||
return `${columnName} ${op} ${formattedValue}`;
|
||||
})
|
||||
.filter((expression) => expression !== ''); // Remove empty expressions
|
||||
|
||||
return {
|
||||
expression: expressions.join(' AND '),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert old aggregation format to new aggregation format
|
||||
* @param aggregateOperator - The aggregate operator (e.g., 'sum', 'count', 'avg')
|
||||
* @param aggregateAttribute - The attribute to aggregate
|
||||
* @param dataSource - The data source type
|
||||
* @param timeAggregation - Time aggregation for metrics (optional)
|
||||
* @param spaceAggregation - Space aggregation for metrics (optional)
|
||||
* @param alias - Optional alias for the aggregation
|
||||
* @returns New aggregation format based on data source
|
||||
*
|
||||
*/
|
||||
export const convertAggregationToExpression = (
|
||||
aggregateOperator: string,
|
||||
aggregateAttribute: BaseAutocompleteData,
|
||||
dataSource: DataSource,
|
||||
timeAggregation?: string,
|
||||
spaceAggregation?: string,
|
||||
alias?: string,
|
||||
): (TraceAggregation | LogAggregation | MetricAggregation)[] | undefined => {
|
||||
// Skip if no operator or attribute key
|
||||
if (!aggregateOperator) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Replace noop with count as default
|
||||
const normalizedOperator =
|
||||
aggregateOperator === 'noop' ? 'count' : aggregateOperator;
|
||||
const normalizedTimeAggregation =
|
||||
timeAggregation === 'noop' ? 'count' : timeAggregation;
|
||||
const normalizedSpaceAggregation =
|
||||
spaceAggregation === 'noop' ? 'count' : spaceAggregation;
|
||||
|
||||
// For metrics, use the MetricAggregation format
|
||||
if (dataSource === DataSource.METRICS) {
|
||||
return [
|
||||
{
|
||||
metricName: aggregateAttribute.key,
|
||||
timeAggregation: (normalizedTimeAggregation || normalizedOperator) as any,
|
||||
spaceAggregation: (normalizedSpaceAggregation || normalizedOperator) as any,
|
||||
} as MetricAggregation,
|
||||
];
|
||||
}
|
||||
|
||||
// For traces and logs, use expression format
|
||||
const expression = `${normalizedOperator}(${aggregateAttribute.key})`;
|
||||
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
return [
|
||||
{
|
||||
expression,
|
||||
...(alias && { alias }),
|
||||
} as TraceAggregation,
|
||||
];
|
||||
}
|
||||
|
||||
// For logs
|
||||
return [
|
||||
{
|
||||
expression,
|
||||
...(alias && { alias }),
|
||||
} as LogAggregation,
|
||||
];
|
||||
};
|
||||
|
||||
function getColId(
|
||||
queryName: string,
|
||||
aggregation: { alias?: string; expression?: string },
|
||||
isMultipleAggregations: boolean,
|
||||
): string {
|
||||
if (isMultipleAggregations && aggregation.expression) {
|
||||
return `${queryName}.${aggregation.expression}`;
|
||||
}
|
||||
|
||||
return queryName;
|
||||
}
|
||||
|
||||
// function to give you label value for query name taking multiaggregation into account
|
||||
export function getQueryLabelWithAggregation(
|
||||
queryData: IBuilderQuery[],
|
||||
): { label: string; value: string }[] {
|
||||
const labels: { label: string; value: string }[] = [];
|
||||
|
||||
const aggregationPerQuery =
|
||||
queryData.reduce((acc, query) => {
|
||||
if (query.queryName && query.aggregations?.length) {
|
||||
acc[query.queryName] = createAggregation(query).map((a: any) => ({
|
||||
alias: a.alias,
|
||||
expression: a.expression,
|
||||
}));
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, any>) || {};
|
||||
|
||||
Object.entries(aggregationPerQuery).forEach(([queryName, aggregations]) => {
|
||||
const isMultipleAggregations = aggregations.length > 1;
|
||||
|
||||
aggregations.forEach((agg: any, index: number) => {
|
||||
const columnId = getColId(queryName, agg, isMultipleAggregations);
|
||||
|
||||
// For display purposes, show the aggregation index for multiple aggregations
|
||||
const displayLabel = isMultipleAggregations
|
||||
? `${queryName}.${index}`
|
||||
: queryName;
|
||||
|
||||
labels.push({
|
||||
label: displayLabel,
|
||||
value: columnId, // This matches the ID format used in table columns
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
export const adjustQueryForV5 = (currentQuery: Query): Query => {
|
||||
if (currentQuery.queryType === EQueryType.QUERY_BUILDER) {
|
||||
const newQueryData = currentQuery.builder.queryData.map((query) => {
|
||||
const aggregations = query.aggregations?.map((aggregation) => {
|
||||
if (query.dataSource === DataSource.METRICS) {
|
||||
const metricAggregation = aggregation as MetricAggregation;
|
||||
return {
|
||||
...aggregation,
|
||||
metricName:
|
||||
metricAggregation.metricName || query.aggregateAttribute?.key || '',
|
||||
timeAggregation:
|
||||
metricAggregation.timeAggregation || query.timeAggregation || '',
|
||||
spaceAggregation:
|
||||
metricAggregation.spaceAggregation || query.spaceAggregation || '',
|
||||
reduceTo: metricAggregation.reduceTo || query.reduceTo || 'avg',
|
||||
};
|
||||
}
|
||||
return aggregation;
|
||||
});
|
||||
|
||||
const {
|
||||
aggregateAttribute,
|
||||
aggregateOperator,
|
||||
timeAggregation,
|
||||
spaceAggregation,
|
||||
reduceTo,
|
||||
filters,
|
||||
...retainedQuery
|
||||
} = query;
|
||||
|
||||
const newAggregations =
|
||||
query.dataSource === DataSource.METRICS
|
||||
? (aggregations as MetricAggregation[])
|
||||
: (aggregations as (TraceAggregation | LogAggregation)[]);
|
||||
|
||||
return {
|
||||
...retainedQuery,
|
||||
aggregations: newAggregations,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: newQueryData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return currentQuery;
|
||||
};
|
||||
@@ -6,18 +6,18 @@ import './Checkbox.styles.scss';
|
||||
|
||||
import { Button, Checkbox, Input, Skeleton, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import {
|
||||
DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY,
|
||||
OPERATORS,
|
||||
} from 'constants/queryBuilder';
|
||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { cloneDeep, isArray, isEqual, isFunction } from 'lodash-es';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
@@ -30,7 +30,7 @@ import { v4 as uuid } from 'uuid';
|
||||
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
|
||||
|
||||
const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
|
||||
const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'nin'];
|
||||
const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in'];
|
||||
|
||||
const SOURCES_WITH_EMPTY_STATE_ENABLED = [QuickFiltersSource.LOGS_EXPLORER];
|
||||
|
||||
@@ -74,18 +74,59 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
searchText: searchText ?? '',
|
||||
},
|
||||
{
|
||||
enabled: isOpen,
|
||||
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: keyValueSuggestions,
|
||||
isLoading: isLoadingKeyValueSuggestions,
|
||||
} = useGetQueryKeyValueSuggestions({
|
||||
key: filter.attributeKey.key,
|
||||
signal: filter.dataSource || DataSource.LOGS,
|
||||
signalSource: 'meter',
|
||||
options: {
|
||||
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
});
|
||||
|
||||
const attributeValues: string[] = useMemo(() => {
|
||||
const dataType = filter.attributeKey.dataType || DataTypes.String;
|
||||
|
||||
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
|
||||
// Process the response data
|
||||
const responseData = keyValueSuggestions?.data as any;
|
||||
const values = responseData.data?.values || {};
|
||||
const stringValues = values.stringValues || [];
|
||||
const numberValues = values.numberValues || [];
|
||||
|
||||
// Generate options from string values - explicitly handle empty strings
|
||||
const stringOptions = stringValues
|
||||
// Strict filtering for empty string - we'll handle it as a special case if needed
|
||||
.filter(
|
||||
(value: string | null | undefined): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
);
|
||||
|
||||
// Generate options from number values
|
||||
const numberOptions = numberValues
|
||||
.filter(
|
||||
(value: number | null | undefined): value is number =>
|
||||
value !== null && value !== undefined,
|
||||
)
|
||||
.map((value: number) => value.toString());
|
||||
|
||||
// Combine all options and make sure we don't have duplicate labels
|
||||
return [...stringOptions, ...numberOptions];
|
||||
}
|
||||
|
||||
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
|
||||
return (data?.payload?.[key] || []).filter(
|
||||
(val) => val !== undefined && val !== null,
|
||||
);
|
||||
}, [data?.payload, filter.attributeKey.dataType]);
|
||||
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
|
||||
|
||||
const currentAttributeKeys = attributeValues.slice(0, visibleItemsCount);
|
||||
|
||||
@@ -168,14 +209,20 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item, idx) => ({
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
|
||||
filter.attributeKey.key,
|
||||
]),
|
||||
},
|
||||
filters: {
|
||||
...item.filters,
|
||||
items:
|
||||
idx === lastUsedQuery
|
||||
? item.filters.items.filter(
|
||||
? item.filters?.items?.filter(
|
||||
(fil) => !isEqual(fil.key?.key, filter.attributeKey.key),
|
||||
)
|
||||
: [...item.filters.items],
|
||||
) || []
|
||||
: [...(item.filters?.items || [])],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
@@ -213,6 +260,14 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(q) => !isEqual(q.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
|
||||
if (isOnlyOrAll === 'Only') {
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
@@ -293,7 +348,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'nin':
|
||||
case 'not in':
|
||||
// if the current running operator is NIN then when unchecking the value it gets
|
||||
// added to the clause like key NIN [value1 , currentUnselectedValue]
|
||||
if (!checked) {
|
||||
@@ -372,7 +427,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
if (!checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getOperatorValue(OPERATORS.NIN),
|
||||
op: getOperatorValue('NOT_IN'),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
@@ -395,7 +450,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
// case - when there is no filter for the current key that means all are selected right now.
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS.NIN),
|
||||
op: getOperatorValue('NOT_IN'),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
@@ -465,12 +520,14 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
{isOpen && isLoading && !attributeValues.length && (
|
||||
<section className="loading">
|
||||
<Skeleton paragraph={{ rows: 4 }} />
|
||||
</section>
|
||||
)}
|
||||
{isOpen && !isLoading && (
|
||||
{isOpen &&
|
||||
(isLoading || isLoadingKeyValueSuggestions) &&
|
||||
!attributeValues.length && (
|
||||
<section className="loading">
|
||||
<Skeleton paragraph={{ rows: 4 }} />
|
||||
</section>
|
||||
)}
|
||||
{isOpen && !isLoading && !isLoadingKeyValueSuggestions && (
|
||||
<>
|
||||
{!isEmptyStateWithDocsEnabled && (
|
||||
<section className="search">
|
||||
|
||||
@@ -178,10 +178,12 @@ function Duration({
|
||||
...data,
|
||||
filters: {
|
||||
...data.filters,
|
||||
items: data.filters?.items?.map((item) => ({
|
||||
...item,
|
||||
id: '',
|
||||
})),
|
||||
items:
|
||||
data.filters?.items?.map((item) => ({
|
||||
...item,
|
||||
id: '',
|
||||
})) || [],
|
||||
op: data.filters?.op || 'AND',
|
||||
},
|
||||
}));
|
||||
return clonedQuery;
|
||||
@@ -199,11 +201,12 @@ function Duration({
|
||||
...item.filters,
|
||||
items: props?.resetAll
|
||||
? []
|
||||
: (unionTagFilterItems(item.filters?.items, preparePostData())
|
||||
: (unionTagFilterItems(item.filters?.items || [], preparePostData())
|
||||
.map((item) =>
|
||||
item.key?.key === props?.clearByType ? undefined : item,
|
||||
)
|
||||
.filter((i) => i) as TagFilterItem[]),
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.quick-filters-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
.quick-filters-settings-container {
|
||||
position: relative;
|
||||
}
|
||||
@@ -102,6 +104,37 @@
|
||||
margin: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-filters-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.perilin-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background: radial-gradient(circle, #fff 10%, transparent 0);
|
||||
background-size: 12px 12px;
|
||||
opacity: 1;
|
||||
|
||||
mask-image: radial-gradient(
|
||||
circle at 50% 0,
|
||||
rgba(11, 12, 14, 0.1) 0,
|
||||
rgba(11, 12, 14, 0) 100%
|
||||
);
|
||||
-webkit-mask-image: radial-gradient(
|
||||
circle at 50% 0,
|
||||
rgba(11, 12, 14, 0.1) 0,
|
||||
rgba(11, 12, 14, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { cloneDeep, isFunction, isNull } from 'lodash-es';
|
||||
import { Settings2 as SettingsIcon } from 'lucide-react';
|
||||
import { Frown, Settings2 as SettingsIcon } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -90,9 +90,14 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item, idx) => ({
|
||||
...item,
|
||||
filter: {
|
||||
...item.filter,
|
||||
expression: '',
|
||||
},
|
||||
filters: {
|
||||
...item.filters,
|
||||
items: idx === lastUsedQuery ? [] : [...item.filters.items],
|
||||
items: idx === lastUsedQuery ? [] : [...(item.filters?.items || [])],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
@@ -231,6 +236,13 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
{filterConfig.length === 0 && (
|
||||
<div className="no-filters-container">
|
||||
<Frown size={16} />
|
||||
<Typography.Text>No filters found</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
</OverlayScrollbar>
|
||||
|
||||
@@ -5,8 +5,11 @@ import { SignalType } from 'components/QuickFilters/types';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { useMemo } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -40,6 +43,10 @@ function OtherFilters({
|
||||
() => SIGNAL_DATA_SOURCE_MAP[signal as SignalType] === DataSource.LOGS,
|
||||
[signal],
|
||||
);
|
||||
const isMeterDataSource = useMemo(
|
||||
() => signal && signal === SignalType.METER_EXPLORER,
|
||||
[signal],
|
||||
);
|
||||
|
||||
const {
|
||||
data: suggestionsData,
|
||||
@@ -69,7 +76,22 @@ function OtherFilters({
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && !isLogDataSource,
|
||||
enabled: !!signal && !isLogDataSource && !isMeterDataSource,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: fieldKeysData,
|
||||
isLoading: isLoadingFieldKeys,
|
||||
} = useGetQueryKeySuggestions(
|
||||
{
|
||||
searchText: inputValue,
|
||||
signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
signalSource: 'meter',
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && isMeterDataSource,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -77,13 +99,33 @@ function OtherFilters({
|
||||
let filterAttributes;
|
||||
if (isLogDataSource) {
|
||||
filterAttributes = suggestionsData?.payload?.attributes || [];
|
||||
} else if (isMeterDataSource) {
|
||||
const fieldKeys: QueryKeyDataSuggestionsProps[] = Object.values(
|
||||
fieldKeysData?.data?.data?.keys || {},
|
||||
)?.flat();
|
||||
filterAttributes = fieldKeys.map(
|
||||
(attr) =>
|
||||
({
|
||||
key: attr.name,
|
||||
dataType: attr.fieldDataType,
|
||||
type: attr.fieldContext,
|
||||
signal: attr.signal,
|
||||
} as BaseAutocompleteData),
|
||||
);
|
||||
} else {
|
||||
filterAttributes = aggregateKeysData?.payload?.attributeKeys || [];
|
||||
}
|
||||
return filterAttributes?.filter(
|
||||
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
|
||||
);
|
||||
}, [suggestionsData, aggregateKeysData, addedFilters, isLogDataSource]);
|
||||
}, [
|
||||
suggestionsData,
|
||||
aggregateKeysData,
|
||||
addedFilters,
|
||||
isLogDataSource,
|
||||
fieldKeysData,
|
||||
isMeterDataSource,
|
||||
]);
|
||||
|
||||
const handleAddFilter = (filter: FilterType): void => {
|
||||
setAddedFilters((prev) => [
|
||||
@@ -99,7 +141,8 @@ function OtherFilters({
|
||||
};
|
||||
|
||||
const renderFilters = (): React.ReactNode => {
|
||||
const isLoading = isFetchingSuggestions || isFetchingAggregateKeys;
|
||||
const isLoading =
|
||||
isFetchingSuggestions || isFetchingAggregateKeys || isLoadingFieldKeys;
|
||||
if (isLoading) return <OtherFiltersSkeleton />;
|
||||
if (!otherFilters?.length)
|
||||
return <div className="no-values-found">No values found</div>;
|
||||
|
||||
@@ -6,4 +6,5 @@ export const SIGNAL_DATA_SOURCE_MAP = {
|
||||
[SignalType.TRACES]: DataSource.TRACES,
|
||||
[SignalType.EXCEPTIONS]: DataSource.TRACES,
|
||||
[SignalType.API_MONITORING]: DataSource.TRACES,
|
||||
[SignalType.METER_EXPLORER]: DataSource.METRICS,
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/${SIGNAL}`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v1/orgs/me/filters`;
|
||||
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v3/filter_suggestions`;
|
||||
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
|
||||
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
|
||||
|
||||
const FILTER_OS_DESCRIPTION = 'os.description';
|
||||
const FILTER_K8S_DEPLOYMENT_NAME = 'k8s.deployment.name';
|
||||
@@ -77,7 +78,11 @@ const setupServer = (): void => {
|
||||
putHandler(await req.json());
|
||||
return res(ctx.status(200), ctx.json({}));
|
||||
}),
|
||||
rest.get(quickFiltersAttributeValuesURL, (_, res, ctx) =>
|
||||
|
||||
rest.get(quickFiltersAttributeValuesURL, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||
),
|
||||
rest.get(fieldsValuesURL, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ export enum SignalType {
|
||||
LOGS = 'logs',
|
||||
API_MONITORING = 'api_monitoring',
|
||||
EXCEPTIONS = 'exceptions',
|
||||
METER_EXPLORER = 'meter',
|
||||
}
|
||||
|
||||
export interface IQuickFiltersConfig {
|
||||
@@ -53,4 +54,5 @@ export enum QuickFiltersSource {
|
||||
TRACES_EXPLORER = 'traces-explorer',
|
||||
API_MONITORING = 'api-monitoring',
|
||||
EXCEPTIONS = 'exceptions',
|
||||
METER_EXPLORER = 'meter',
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Table } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import cx from 'classnames';
|
||||
import { dragColumnParams } from 'hooks/useDragColumns/configs';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { getColumnWidth, RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { debounce, set } from 'lodash-es';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import {
|
||||
@@ -110,11 +110,14 @@ function ResizeTable({
|
||||
// Apply stored column widths from widget configuration
|
||||
const columnsWithStoredWidths = columns.map((col) => {
|
||||
const dataIndex = (col as RowData).dataIndex as string;
|
||||
if (dataIndex && columnWidths && columnWidths[dataIndex]) {
|
||||
return {
|
||||
...col,
|
||||
width: columnWidths[dataIndex], // Apply stored width
|
||||
};
|
||||
if (dataIndex && columnWidths) {
|
||||
const width = getColumnWidth(dataIndex, columnWidths);
|
||||
if (width) {
|
||||
return {
|
||||
...col,
|
||||
width, // Apply stored width
|
||||
};
|
||||
}
|
||||
}
|
||||
return col;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
.warning-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// === SECTION: Summary (Top)
|
||||
&__summary-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
|
||||
&__summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&__summary-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__summary-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__warning-code {
|
||||
color: var(--bg-vanilla-100);
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 24px; /* 150% */
|
||||
letter-spacing: -0.08px;
|
||||
}
|
||||
|
||||
&__warning-message {
|
||||
margin: 0;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__docs-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 12.5px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.12px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&__message-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0px 16px 16px;
|
||||
|
||||
.key-value-label {
|
||||
width: fit-content;
|
||||
border-color: var(--bg-slate-400);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
&__key {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
&__value {
|
||||
padding-right: 10px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.48px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
&-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
&-dot {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
background: var(--bg-sakura-500);
|
||||
border-radius: 50%;
|
||||
}
|
||||
&-text {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 180% */
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
&-line {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background-image: radial-gradient(circle, #444c63 1px, transparent 2px);
|
||||
background-size: 8px 11px;
|
||||
background-position: top left;
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
// === SECTION: Message List (Bottom)
|
||||
|
||||
&__message-list-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__message-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 275px;
|
||||
}
|
||||
|
||||
&__message-item {
|
||||
position: relative;
|
||||
margin-bottom: 4px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Geist Mono;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
color: var(--bg-vanilla-400);
|
||||
padding: 3px 12px;
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
&__message-item::before {
|
||||
font-family: unset;
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2px;
|
||||
height: 4px;
|
||||
border-radius: 50px;
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
&__scroll-hint {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
margin: auto;
|
||||
width: fit-content;
|
||||
display: inline-flex;
|
||||
padding: 4px 12px 4px 10px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
background: var(--bg-slate-200);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0px 103px 12px 0px rgba(0, 0, 0, 0.01),
|
||||
0px 66px 18px 0px rgba(0, 0, 0, 0.01), 0px 37px 22px 0px rgba(0, 0, 0, 0.03),
|
||||
0px 17px 17px 0px rgba(0, 0, 0, 0.04), 0px 4px 9px 0px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
&__scroll-hint-text {
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.warning-content {
|
||||
&__warning-code {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
&__warning-message {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
&__message-item {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
&__message-badge {
|
||||
&-label-text {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
.key-value-label__value {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
&__docs-button {
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
142
frontend/src/components/WarningPopover/WarningPopover.tsx
Normal file
142
frontend/src/components/WarningPopover/WarningPopover.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import './WarningPopover.styles.scss';
|
||||
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Popover, PopoverProps } from 'antd';
|
||||
import ErrorIcon from 'assets/Error';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { BookOpenText, ChevronsDown, TriangleAlert } from 'lucide-react';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { ReactNode } from 'react';
|
||||
import { Warning } from 'types/api';
|
||||
|
||||
interface WarningContentProps {
|
||||
warning: Warning;
|
||||
}
|
||||
|
||||
export function WarningContent({ warning }: WarningContentProps): JSX.Element {
|
||||
const {
|
||||
url: warningUrl,
|
||||
warnings: warningMessages,
|
||||
code: warningCode,
|
||||
message: warningMessage,
|
||||
} = warning || {};
|
||||
|
||||
if (!warning) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="warning-content">
|
||||
{/* Summary Header */}
|
||||
<section className="warning-content__summary-section">
|
||||
<header className="warning-content__summary">
|
||||
<div className="warning-content__summary-left">
|
||||
<div className="warning-content__icon-wrapper">
|
||||
<ErrorIcon />
|
||||
</div>
|
||||
|
||||
<div className="warning-content__summary-text">
|
||||
<h2 className="warning-content__warning-code">{warningCode}</h2>
|
||||
<p className="warning-content__warning-message">{warningMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{warningUrl && (
|
||||
<div className="warning-content__summary-right">
|
||||
<Button
|
||||
type="default"
|
||||
className="warning-content__docs-button"
|
||||
href={warningUrl}
|
||||
target="_blank"
|
||||
data-testid="warning-docs-button"
|
||||
>
|
||||
<BookOpenText size={14} />
|
||||
Open Docs
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{warningMessages?.length > 0 && (
|
||||
<div className="warning-content__message-badge">
|
||||
<KeyValueLabel
|
||||
badgeKey={
|
||||
<div className="warning-content__message-badge-label">
|
||||
<div className="warning-content__message-badge-label-dot" />
|
||||
<div className="warning-content__message-badge-label-text">
|
||||
MESSAGES
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
badgeValue={warningMessages.length.toString()}
|
||||
/>
|
||||
<div className="warning-content__message-badge-line" />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Detailed Messages */}
|
||||
<section className="warning-content__messages-section">
|
||||
<div className="warning-content__message-list-container">
|
||||
<OverlayScrollbar>
|
||||
<ul className="warning-content__message-list">
|
||||
{warningMessages?.map((warning) => (
|
||||
<li className="warning-content__message-item" key={warning.message}>
|
||||
{warning.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</OverlayScrollbar>
|
||||
{warningMessages?.length > 10 && (
|
||||
<div className="warning-content__scroll-hint">
|
||||
<ChevronsDown
|
||||
size={16}
|
||||
color={Color.BG_VANILLA_100}
|
||||
className="warning-content__scroll-hint-icon"
|
||||
/>
|
||||
<span className="warning-content__scroll-hint-text">
|
||||
Scroll for more
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface WarningPopoverProps extends PopoverProps {
|
||||
children?: ReactNode;
|
||||
warningData: Warning;
|
||||
}
|
||||
|
||||
function WarningPopover({
|
||||
children,
|
||||
warningData,
|
||||
...popoverProps
|
||||
}: WarningPopoverProps): JSX.Element {
|
||||
return (
|
||||
<Popover
|
||||
content={<WarningContent warning={warningData} />}
|
||||
overlayStyle={{ padding: 0, maxWidth: '600px' }}
|
||||
overlayInnerStyle={{ padding: 0 }}
|
||||
autoAdjustOverflow
|
||||
{...popoverProps}
|
||||
>
|
||||
{children || (
|
||||
<TriangleAlert
|
||||
size={16}
|
||||
style={{ cursor: 'pointer' }}
|
||||
color={Color.BG_AMBER_500}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
WarningPopover.defaultProps = {
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
export default WarningPopover;
|
||||
@@ -0,0 +1,63 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { Select } from 'antd';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
|
||||
import { UniversalYAxisUnitMappings, Y_AXIS_CATEGORIES } from './constants';
|
||||
import { UniversalYAxisUnit, YAxisUnitSelectorProps } from './types';
|
||||
import { mapMetricUnitToUniversalUnit } from './utils';
|
||||
|
||||
function YAxisUnitSelector({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Please select a unit',
|
||||
loading = false,
|
||||
}: YAxisUnitSelectorProps): JSX.Element {
|
||||
const universalUnit = mapMetricUnitToUniversalUnit(value);
|
||||
|
||||
const handleSearch = (
|
||||
searchTerm: string,
|
||||
currentOption: DefaultOptionType | undefined,
|
||||
): boolean => {
|
||||
if (!currentOption?.value) return false;
|
||||
|
||||
const search = searchTerm.toLowerCase();
|
||||
const unitId = currentOption.value.toString().toLowerCase();
|
||||
const unitLabel = currentOption.children?.toString().toLowerCase() || '';
|
||||
|
||||
// Check label and id
|
||||
if (unitId.includes(search) || unitLabel.includes(search)) return true;
|
||||
|
||||
// Check aliases (from the mapping) using array iteration
|
||||
const aliases = Array.from(
|
||||
UniversalYAxisUnitMappings[currentOption.value as UniversalYAxisUnit] ?? [],
|
||||
);
|
||||
|
||||
return aliases.some((alias) => alias.toLowerCase().includes(search));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="y-axis-unit-selector-component">
|
||||
<Select
|
||||
showSearch
|
||||
value={universalUnit}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
filterOption={(input, option): boolean => handleSearch(input, option)}
|
||||
loading={loading}
|
||||
>
|
||||
{Y_AXIS_CATEGORIES.map((category) => (
|
||||
<Select.OptGroup key={category.name} label={category.name}>
|
||||
{category.units.map((unit) => (
|
||||
<Select.Option key={unit.id} value={unit.id}>
|
||||
{unit.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select.OptGroup>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default YAxisUnitSelector;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import YAxisUnitSelector from '../YAxisUnitSelector';
|
||||
|
||||
describe('YAxisUnitSelector', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnChange.mockClear();
|
||||
});
|
||||
|
||||
it('renders with default placeholder', () => {
|
||||
render(<YAxisUnitSelector value="" onChange={mockOnChange} />);
|
||||
expect(screen.getByText('Please select a unit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with custom placeholder', () => {
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
value=""
|
||||
onChange={mockOnChange}
|
||||
placeholder="Custom placeholder"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Custom placeholder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange when a value is selected', () => {
|
||||
render(<YAxisUnitSelector value="" onChange={mockOnChange} />);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
const option = screen.getByText('Bytes (B)');
|
||||
fireEvent.click(option);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('By', {
|
||||
children: 'Bytes (B)',
|
||||
key: 'By',
|
||||
value: 'By',
|
||||
});
|
||||
});
|
||||
|
||||
it('filters options based on search input', () => {
|
||||
render(<YAxisUnitSelector value="" onChange={mockOnChange} />);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'byte' } });
|
||||
|
||||
expect(screen.getByText('Bytes/sec')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all categories and their units', () => {
|
||||
render(<YAxisUnitSelector value="" onChange={mockOnChange} />);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
|
||||
// Check for category headers
|
||||
expect(screen.getByText('Data')).toBeInTheDocument();
|
||||
expect(screen.getByText('Time')).toBeInTheDocument();
|
||||
|
||||
// Check for some common units
|
||||
expect(screen.getByText('Bytes (B)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Seconds (s)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user