Compare commits
44 Commits
feat/drill
...
fix/extrac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
603d6d6fce | ||
|
|
339d81a240 | ||
|
|
2acee366a0 | ||
|
|
76e8672ee5 | ||
|
|
64c0bc1b97 | ||
|
|
9d3733ed72 | ||
|
|
72ec4a1b1f | ||
|
|
f5bf8f9f70 | ||
|
|
365cbdd835 | ||
|
|
d26efd2833 | ||
|
|
0e3ac2a179 | ||
|
|
30bf3a53f5 | ||
|
|
249f8be845 | ||
|
|
9c952942ad | ||
|
|
dac46d82ff | ||
|
|
802ce6de01 | ||
|
|
6853f0c99d | ||
|
|
3f8a2870e4 | ||
|
|
5fa70ea802 | ||
|
|
3a952fa330 | ||
|
|
6d97db1d9d | ||
|
|
5412e7f70b | ||
|
|
8e5cb9046d | ||
|
|
760eabb2dc | ||
|
|
35ddaaa2fc | ||
|
|
a51ee66c02 | ||
|
|
75d189162b | ||
|
|
0dd085c48e | ||
|
|
932918e3a4 | ||
|
|
aa3bc16dcb | ||
|
|
b5098e00a3 | ||
|
|
20dc561bfe | ||
|
|
99bbb87738 | ||
|
|
f1ce93171c | ||
|
|
531a0a12dd | ||
|
|
92794389d6 | ||
|
|
bd02848623 | ||
|
|
b5016b061b | ||
|
|
c308e8668c | ||
|
|
41ee4176ad | ||
|
|
994663110d | ||
|
|
3a2eab2019 | ||
|
|
01202b5800 | ||
|
|
2901e052ae |
@@ -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,7 +88,7 @@ This command:
|
||||
|
||||
> 💡 **Tip**: The API server runs at `http://localhost:8080/` by default
|
||||
|
||||
### 3. Setting up the Frontend
|
||||
### 4. Setting up the Frontend
|
||||
|
||||
1. Navigate to the frontend directory:
|
||||
```bash
|
||||
@@ -98,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"}]}]}]}'
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
"METER_EXPLORER_BASE": "SigNoz | Meter Explorer"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
"METER_EXPLORER_BASE": "SigNoz | Meter Explorer"
|
||||
}
|
||||
|
||||
@@ -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_EXPLORER_BASE,
|
||||
exact: true,
|
||||
component: MeterExplorer,
|
||||
key: 'METER_EXPLORER_BASE',
|
||||
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,
|
||||
|
||||
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>);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
})}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export const getKeySuggestions = (
|
||||
metricName = '',
|
||||
fieldContext = '',
|
||||
fieldDataType = '',
|
||||
signalSource = '',
|
||||
} = props;
|
||||
|
||||
const encodedSignal = encodeURIComponent(signal);
|
||||
@@ -21,8 +22,9 @@ export const getKeySuggestions = (
|
||||
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}`,
|
||||
`/fields/keys?signal=${encodedSignal}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&fieldContext=${encodedFieldContext}&fieldDataType=${encodedFieldDataType}&source=${encodedSource}`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,13 +8,15 @@ import {
|
||||
export const getValueSuggestions = (
|
||||
props: QueryKeyValueRequestProps,
|
||||
): Promise<AxiosResponse<QueryKeyValueSuggestionsResponseProps>> => {
|
||||
const { signal, key, searchText } = props;
|
||||
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}`,
|
||||
`/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}`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cloneDeep, isEmpty } from 'lodash-es';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { SuccessResponse, Warning } from 'types/api';
|
||||
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
import {
|
||||
DistributionData,
|
||||
@@ -28,14 +28,18 @@ function getColName(
|
||||
const aggregationsCount = aggregationPerQuery[col.queryName]?.length || 0;
|
||||
const isSingleAggregation = aggregationsCount === 1;
|
||||
|
||||
// Single aggregation: Priority is alias > legend > expression
|
||||
if (isSingleAggregation) {
|
||||
return alias || legend || expression;
|
||||
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;
|
||||
}
|
||||
|
||||
// Multiple aggregations: Each follows single rules BUT never shows legend
|
||||
// Priority: alias > expression (legend is ignored for multiple aggregations)
|
||||
return alias || expression;
|
||||
return legend || col.queryName;
|
||||
}
|
||||
|
||||
function getColId(
|
||||
@@ -48,7 +52,14 @@ function getColId(
|
||||
const aggregation =
|
||||
aggregationPerQuery?.[col.queryName]?.[col.aggregationIndex];
|
||||
const expression = aggregation?.expression || '';
|
||||
return `${col.queryName}.${expression}`;
|
||||
const aggregationsCount = aggregationPerQuery[col.queryName]?.length || 0;
|
||||
const isMultipleAggregations = aggregationsCount > 1;
|
||||
|
||||
if (isMultipleAggregations && expression) {
|
||||
return `${col.queryName}.${expression}`;
|
||||
}
|
||||
|
||||
return col.queryName;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,7 +352,7 @@ export function convertV5ResponseToLegacy(
|
||||
v5Response: SuccessResponse<MetricRangePayloadV5>,
|
||||
legendMap: Record<string, string>,
|
||||
formatForWeb?: boolean,
|
||||
): SuccessResponse<MetricRangePayloadV3> {
|
||||
): SuccessResponse<MetricRangePayloadV3> & { warning?: Warning } {
|
||||
const { payload, params } = v5Response;
|
||||
const v5Data = payload?.data;
|
||||
|
||||
@@ -367,14 +378,18 @@ export function convertV5ResponseToLegacy(
|
||||
legendMap,
|
||||
aggregationPerQuery,
|
||||
);
|
||||
|
||||
return {
|
||||
...v5Response,
|
||||
payload: {
|
||||
data: {
|
||||
resultType: 'scalar',
|
||||
result: webTables,
|
||||
warnings: v5Data?.data?.warning || [],
|
||||
},
|
||||
warning: v5Data?.warning || undefined,
|
||||
},
|
||||
warning: v5Data?.warning || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -390,6 +405,7 @@ export function convertV5ResponseToLegacy(
|
||||
...v5Response,
|
||||
payload: {
|
||||
data: convertedData,
|
||||
warning: v5Response.payload?.data?.warning || undefined,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@ 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,
|
||||
QueryFunctionProps,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
BaseBuilderQuery,
|
||||
FieldContext,
|
||||
@@ -30,6 +27,7 @@ import {
|
||||
} 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;
|
||||
@@ -123,17 +121,21 @@ function createBaseSpec(
|
||||
functions: isEmpty(queryData.functions)
|
||||
? undefined
|
||||
: queryData.functions.map(
|
||||
(func: QueryFunctionProps): QueryFunction => ({
|
||||
name: func.name as FunctionName,
|
||||
args: isEmpty(func.namedArgs)
|
||||
? func.args.map((arg) => ({
|
||||
value: arg,
|
||||
}))
|
||||
: Object.entries(func.namedArgs).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
})),
|
||||
}),
|
||||
(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
|
||||
@@ -258,6 +260,7 @@ export function convertBuilderQueriesToV5(
|
||||
spec = {
|
||||
name: queryName,
|
||||
signal: 'metrics' as const,
|
||||
source: queryData.source || '',
|
||||
...baseSpec,
|
||||
aggregations: aggregations as MetricAggregation[],
|
||||
// reduceTo: queryData.reduceTo,
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface NavigateToExplorerProps {
|
||||
endTime?: number;
|
||||
sameTab?: boolean;
|
||||
shouldResolveQuery?: boolean;
|
||||
widgetQuery?: Query;
|
||||
}
|
||||
|
||||
export function useNavigateToExplorer(): (
|
||||
@@ -30,27 +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: [...(item.filters?.items || []), ...selectedFilters],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -67,6 +75,7 @@ export function useNavigateToExplorer(): (
|
||||
endTime,
|
||||
sameTab,
|
||||
shouldResolveQuery,
|
||||
widgetQuery,
|
||||
} = props;
|
||||
const urlParams = new URLSearchParams();
|
||||
if (startTime && endTime) {
|
||||
@@ -77,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({
|
||||
|
||||
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;
|
||||
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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -131,6 +131,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
queryVariant={config?.queryVariant || 'dropdown'}
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
isListViewPanel={isListViewPanel}
|
||||
signalSource={config?.signalSource || ''}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { createContext, ReactNode, useContext, useMemo, useState } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
// Types for the context state
|
||||
export type AggregationOption = { func: string; arg: string };
|
||||
@@ -6,8 +13,12 @@ export type AggregationOption = { func: string; arg: string };
|
||||
interface QueryBuilderV2ContextType {
|
||||
searchText: string;
|
||||
setSearchText: (text: string) => void;
|
||||
aggregationOptions: AggregationOption[];
|
||||
setAggregationOptions: (options: AggregationOption[]) => 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
|
||||
@@ -24,26 +35,50 @@ export function QueryBuilderV2Provider({
|
||||
children: ReactNode;
|
||||
}): JSX.Element {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [aggregationOptions, setAggregationOptions] = useState<
|
||||
AggregationOption[]
|
||||
>([]);
|
||||
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,
|
||||
aggregationOptions,
|
||||
aggregationOptionsMap,
|
||||
setAggregationOptions,
|
||||
getAggregationOptions,
|
||||
aggregationInterval,
|
||||
setAggregationInterval,
|
||||
queryAddValues,
|
||||
setQueryAddValues,
|
||||
}),
|
||||
[searchText, aggregationOptions, aggregationInterval, queryAddValues],
|
||||
[
|
||||
searchText,
|
||||
aggregationOptionsMap,
|
||||
aggregationInterval,
|
||||
queryAddValues,
|
||||
getAggregationOptions,
|
||||
setAggregationOptions,
|
||||
],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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 { Info } from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useMemo } from 'react';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
@@ -19,11 +18,13 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
index,
|
||||
version,
|
||||
panelType,
|
||||
signalSource = '',
|
||||
}: {
|
||||
query: IBuilderQuery;
|
||||
index: number;
|
||||
version: string;
|
||||
panelType: PANEL_TYPES | null;
|
||||
signalSource: string;
|
||||
}): JSX.Element {
|
||||
const { setAggregationOptions } = useQueryBuilderV2Context();
|
||||
const {
|
||||
@@ -50,17 +51,17 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setAggregationOptions([
|
||||
setAggregationOptions(query.queryName, [
|
||||
{
|
||||
func: queryAggregation.spaceAggregation || 'count',
|
||||
arg: queryAggregation.metricName || '',
|
||||
},
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
queryAggregation.spaceAggregation,
|
||||
queryAggregation.metricName,
|
||||
setAggregationOptions,
|
||||
query,
|
||||
query.queryName,
|
||||
]);
|
||||
|
||||
const handleChangeGroupByKeys = useCallback(
|
||||
@@ -100,12 +101,22 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
<div className="metrics-time-aggregation-section">
|
||||
<div className="metrics-aggregation-section-content">
|
||||
<div className="metrics-aggregation-section-content-item">
|
||||
<div className="metrics-aggregation-section-content-item-label main-label">
|
||||
AGGREGATE BY TIME{' '}
|
||||
<Tooltip title="AGGREGATE BY TIME">
|
||||
<Info size={12} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<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 || ''}
|
||||
@@ -118,9 +129,30 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
|
||||
{showAggregationInterval && (
|
||||
<div className="metrics-aggregation-section-content-item">
|
||||
<div className="metrics-aggregation-section-content-item-label">
|
||||
every
|
||||
</div>
|
||||
<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
|
||||
@@ -138,12 +170,22 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
<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-label main-label">
|
||||
AGGREGATE LABELS
|
||||
<Tooltip title="AGGREGATE LABELS">
|
||||
<Info size={12} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<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}
|
||||
@@ -168,6 +210,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
disabled={!queryAggregation.metricName}
|
||||
query={query}
|
||||
onChange={handleChangeGroupByKeys}
|
||||
signalSource={signalSource}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,13 +247,35 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
disabled={!queryAggregation.metricName}
|
||||
query={query}
|
||||
onChange={handleChangeGroupByKeys}
|
||||
signalSource={signalSource}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="metrics-aggregation-section-content-item">
|
||||
<div className="metrics-aggregation-section-content-item-label">
|
||||
every
|
||||
</div>
|
||||
<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
|
||||
|
||||
@@ -9,10 +9,12 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
query,
|
||||
index,
|
||||
version,
|
||||
signalSource,
|
||||
}: {
|
||||
query: IBuilderQuery;
|
||||
index: number;
|
||||
version: string;
|
||||
signalSource: 'meter' | '';
|
||||
}): JSX.Element {
|
||||
const { handleChangeAggregatorAttribute } = useQueryOperations({
|
||||
index,
|
||||
@@ -22,7 +24,12 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
|
||||
return (
|
||||
<div className="metrics-select-container">
|
||||
<AggregatorFilter onChange={handleChangeAggregatorAttribute} query={query} />
|
||||
<AggregatorFilter
|
||||
onChange={handleChangeAggregatorAttribute}
|
||||
query={query}
|
||||
index={index}
|
||||
signalSource={signalSource || ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -95,7 +95,8 @@ function HavingFilter({
|
||||
queryData: IBuilderQuery;
|
||||
}): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { aggregationOptions } = useQueryBuilderV2Context();
|
||||
const { getAggregationOptions } = useQueryBuilderV2Context();
|
||||
const aggregationOptions = getAggregationOptions(queryData.queryName);
|
||||
const having = queryData?.having as Having;
|
||||
const [input, setInput] = useState(having?.expression || '');
|
||||
|
||||
|
||||
@@ -111,17 +111,13 @@
|
||||
border-radius: 2px !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
margin-top: -2px !important;
|
||||
width: 100% !important;
|
||||
position: absolute !important;
|
||||
top: 38px !important;
|
||||
top: calc(100% + 6px) !important;
|
||||
left: 0px !important;
|
||||
right: 0px !important;
|
||||
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-200, #1d212d);
|
||||
border-top: none !important;
|
||||
border-top-left-radius: 0px !important;
|
||||
border-top-right-radius: 0px !important;
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
rgba(18, 19, 23, 0.8) 0%,
|
||||
@@ -129,7 +125,9 @@
|
||||
) !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;
|
||||
@@ -165,7 +163,6 @@
|
||||
overflow: hidden;
|
||||
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
color: var(--bg-vanilla-100) !important;
|
||||
|
||||
.cm-completionIcon {
|
||||
display: none !important;
|
||||
@@ -330,16 +327,18 @@
|
||||
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'] {
|
||||
color: var(--bg-ink-500) !important;
|
||||
background: var(--bg-vanilla-300) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable react/require-default-props */
|
||||
import './QueryAddOns.styles.scss';
|
||||
|
||||
import { Button, Radio, RadioChangeEvent } from 'antd';
|
||||
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';
|
||||
@@ -9,7 +10,7 @@ import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/Re
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { BarChart2, ChevronUp, ScrollText } from 'lucide-react';
|
||||
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';
|
||||
@@ -21,6 +22,8 @@ interface AddOn {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
key: string;
|
||||
description?: string;
|
||||
docLink?: string;
|
||||
}
|
||||
|
||||
const ADD_ONS_KEYS = {
|
||||
@@ -36,26 +39,45 @@ 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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -63,8 +85,58 @@ 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,
|
||||
@@ -212,7 +284,21 @@ function QueryAddOns({
|
||||
{selectedViews.find((view) => view.key === 'group_by') && (
|
||||
<div className="add-on-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<div className="label">Group By</div>
|
||||
<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={
|
||||
@@ -234,7 +320,21 @@ function QueryAddOns({
|
||||
{selectedViews.find((view) => view.key === 'having') && (
|
||||
<div className="add-on-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<div className="label">Having</div>
|
||||
<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 => {
|
||||
@@ -266,7 +366,21 @@ function QueryAddOns({
|
||||
{selectedViews.find((view) => view.key === 'order_by') && (
|
||||
<div className="add-on-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<div className="label">Order By</div>
|
||||
<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}
|
||||
@@ -290,7 +404,21 @@ function QueryAddOns({
|
||||
{selectedViews.find((view) => view.key === 'reduce_to') && showReduceTo && (
|
||||
<div className="add-on-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<div className="label">Reduce to</div>
|
||||
<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>
|
||||
@@ -330,20 +458,32 @@ function QueryAddOns({
|
||||
value={selectedViews}
|
||||
>
|
||||
{addOns.map((addOn) => (
|
||||
<Radio.Button
|
||||
key={addOn.label}
|
||||
className={
|
||||
selectedViews.find((view) => view.key === addOn.key)
|
||||
? 'selected-view tab'
|
||||
: 'tab'
|
||||
<Tooltip
|
||||
key={addOn.key}
|
||||
title={
|
||||
<TooltipContent
|
||||
label={addOn.label}
|
||||
description={addOn.description}
|
||||
docLink={addOn.docLink}
|
||||
/>
|
||||
}
|
||||
value={addOn}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<div className="add-on-tab-title">
|
||||
{addOn.icon}
|
||||
{addOn.label}
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<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>
|
||||
|
||||
@@ -63,17 +63,14 @@
|
||||
border-radius: 2px !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
margin-top: 8px !important;
|
||||
min-width: 400px !important;
|
||||
position: absolute !important;
|
||||
top: calc(100% + 6px) !important;
|
||||
left: 0px !important;
|
||||
width: 100% !important;
|
||||
right: 0px !important;
|
||||
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-200, #1d212d);
|
||||
border-top: none !important;
|
||||
border-top-left-radius: 0px !important;
|
||||
border-top-right-radius: 0px !important;
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
rgba(18, 19, 23, 0.8) 0%,
|
||||
@@ -81,6 +78,7 @@
|
||||
) !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 {
|
||||
@@ -269,19 +267,17 @@
|
||||
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 {
|
||||
&:hover {
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
color: var(--bg-ink-500) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
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;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './QueryAggregation.styles.scss';
|
||||
|
||||
import { Tooltip } from 'antd';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useMemo } from 'react';
|
||||
@@ -53,7 +54,31 @@ function QueryAggregationOptions({
|
||||
|
||||
{showAggregationInterval && (
|
||||
<div className="query-aggregation-interval">
|
||||
<div className="query-aggregation-interval-label">every</div>
|
||||
<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={
|
||||
|
||||
@@ -27,13 +27,13 @@ import CodeMirror, {
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
} from '@uiw/react-codemirror';
|
||||
import { Button, Popover } from 'antd';
|
||||
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 { TriangleAlert } from 'lucide-react';
|
||||
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';
|
||||
@@ -263,7 +263,7 @@ function QueryAggregationSelect({
|
||||
|
||||
setValidationError(validateAggregations());
|
||||
setFunctionArgPairs(pairs);
|
||||
setAggregationOptions(pairs);
|
||||
setAggregationOptions(queryData.queryName, pairs);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [input, maxAggregations, validFunctions]);
|
||||
|
||||
@@ -639,6 +639,50 @@ function QueryAggregationSelect({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<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
|
||||
|
||||
@@ -12,22 +12,7 @@ export default function QueryFooter({
|
||||
<div className="qb-footer">
|
||||
<div className="qb-footer-container">
|
||||
<div className="qb-add-new-query">
|
||||
<Tooltip
|
||||
title={
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
Add New Query
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=query-builder#multiple-queries-and-functions"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{' '}
|
||||
<br />
|
||||
Learn more
|
||||
</Typography.Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tooltip title={<div style={{ textAlign: 'center' }}>Add New Query</div>}>
|
||||
<Button
|
||||
className="add-new-query-button periscope-btn secondary"
|
||||
type="text"
|
||||
@@ -43,7 +28,7 @@ export default function QueryFooter({
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
Add New Formula
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=query-builder#multiple-queries-and-functions"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-advanced-comparisons"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
.cm-editor {
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
// overflow: hidden;
|
||||
background-color: transparent !important;
|
||||
|
||||
&:focus-within {
|
||||
@@ -75,11 +75,11 @@
|
||||
border-radius: 2px !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
margin-top: -2px !important;
|
||||
min-width: 400px !important;
|
||||
position: relative !important;
|
||||
top: 0px !important;
|
||||
position: absolute !important;
|
||||
top: calc(100% + 6px) !important;
|
||||
left: 0px !important;
|
||||
right: 0px !important;
|
||||
|
||||
border-radius: 4px;
|
||||
border: 0px;
|
||||
@@ -91,6 +91,8 @@
|
||||
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;
|
||||
@@ -571,9 +573,9 @@
|
||||
|
||||
.cm-tooltip-autocomplete {
|
||||
background: var(--bg-vanilla-100) !important;
|
||||
|
||||
border: 0px;
|
||||
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 {
|
||||
@@ -583,7 +585,7 @@
|
||||
&:hover,
|
||||
&[aria-selected='true'] {
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
font-weight: 600;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,13 @@ import { Color } from '@signozhq/design-tokens';
|
||||
import { copilot } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
|
||||
import { Button, Card, Collapse, Popover, Tag } from 'antd';
|
||||
import { Button, Card, Collapse, Popover, Tag, Tooltip } from 'antd';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
negationQueryOperatorSuggestions,
|
||||
OPERATORS,
|
||||
QUERY_BUILDER_KEY_TYPES,
|
||||
QUERY_BUILDER_OPERATORS_BY_KEY_TYPE,
|
||||
queryOperatorSuggestions,
|
||||
@@ -30,7 +31,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { debounce, isNull } from 'lodash-es';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { Info, TriangleAlert } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
IDetailedError,
|
||||
@@ -40,11 +41,11 @@ import {
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { validateQuery } from 'utils/antlrQueryUtils';
|
||||
import {
|
||||
getCurrentValueIndexAtCursor,
|
||||
getQueryContextAtCursor,
|
||||
} from 'utils/queryContextUtils';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
import { unquote } from 'utils/stringUtils';
|
||||
|
||||
import { queryExamples } from './constants';
|
||||
@@ -80,18 +81,17 @@ function QuerySearch({
|
||||
queryData,
|
||||
dataSource,
|
||||
onRun,
|
||||
signalSource,
|
||||
}: {
|
||||
onChange: (value: string) => void;
|
||||
queryData: IBuilderQuery;
|
||||
dataSource: DataSource;
|
||||
signalSource?: string;
|
||||
onRun?: (query: string) => void;
|
||||
}): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [query, setQuery] = useState<string>(queryData.filter?.expression || '');
|
||||
const [valueSuggestions, setValueSuggestions] = useState<any[]>([
|
||||
{ label: 'error', type: 'value' },
|
||||
{ label: 'frontend', type: 'value' },
|
||||
]);
|
||||
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
|
||||
const [activeKey, setActiveKey] = useState<string>('');
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
||||
const [queryContext, setQueryContext] = useState<IQueryContext | null>(null);
|
||||
@@ -114,9 +114,27 @@ function QuerySearch({
|
||||
}
|
||||
};
|
||||
|
||||
// Track if the query was changed externally (from queryData) vs internally (user input)
|
||||
const [isExternalQueryChange, setIsExternalQueryChange] = useState(false);
|
||||
const [lastExternalQuery, setLastExternalQuery] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(queryData.filter?.expression || '');
|
||||
}, [queryData.filter?.expression]);
|
||||
const newQuery = queryData.filter?.expression || '';
|
||||
// Only mark as external change if the query actually changed from external source
|
||||
if (newQuery !== lastExternalQuery) {
|
||||
setQuery(newQuery);
|
||||
setIsExternalQueryChange(true);
|
||||
setLastExternalQuery(newQuery);
|
||||
}
|
||||
}, [queryData.filter?.expression, lastExternalQuery]);
|
||||
|
||||
// Validate query when it changes externally (from queryData)
|
||||
useEffect(() => {
|
||||
if (isExternalQueryChange && query) {
|
||||
handleQueryValidation(query);
|
||||
setIsExternalQueryChange(false);
|
||||
}
|
||||
}, [isExternalQueryChange, query]);
|
||||
|
||||
const [keySuggestions, setKeySuggestions] = useState<
|
||||
QueryKeyDataSuggestionsProps[] | null
|
||||
@@ -127,7 +145,6 @@ function QuerySearch({
|
||||
const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 });
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const [isCompleteKeysList, setIsCompleteKeysList] = useState(false);
|
||||
const [
|
||||
isFetchingCompleteValuesList,
|
||||
setIsFetchingCompleteValuesList,
|
||||
@@ -138,6 +155,7 @@ function QuerySearch({
|
||||
// Reference to the editor view for programmatic autocompletion
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
const lastKeyRef = useRef<string>('');
|
||||
const lastFetchedKeyRef = useRef<string>('');
|
||||
const lastValueRef = useRef<string>('');
|
||||
const isMountedRef = useRef<boolean>(true);
|
||||
|
||||
@@ -170,36 +188,78 @@ function QuerySearch({
|
||||
500,
|
||||
);
|
||||
|
||||
const fetchKeySuggestions = async (searchText?: string): Promise<void> => {
|
||||
if (dataSource === DataSource.METRICS && !queryData.aggregateAttribute?.key) {
|
||||
setKeySuggestions([]);
|
||||
return;
|
||||
}
|
||||
const response = await getKeySuggestions({
|
||||
signal: dataSource,
|
||||
searchText: searchText || '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
});
|
||||
const toggleSuggestions = useCallback(
|
||||
(timeout?: number) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!editorRef.current) return;
|
||||
if (isFocused) {
|
||||
startCompletion(editorRef.current);
|
||||
} else {
|
||||
closeCompletion(editorRef.current);
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
if (response.data.data) {
|
||||
const { complete, keys } = response.data.data;
|
||||
const options = generateOptions(keys);
|
||||
// Use a Map to deduplicate by label and preserve order: new options take precedence
|
||||
const merged = new Map<string, QueryKeyDataSuggestionsProps>();
|
||||
options.forEach((opt) => merged.set(opt.label, opt));
|
||||
if (searchText && lastKeyRef.current !== searchText) {
|
||||
(keySuggestions || []).forEach((opt) => {
|
||||
if (!merged.has(opt.label)) merged.set(opt.label, opt);
|
||||
});
|
||||
return (): void => clearTimeout(timeoutId);
|
||||
},
|
||||
[isFocused],
|
||||
);
|
||||
|
||||
const fetchKeySuggestions = useCallback(
|
||||
async (searchText?: string): Promise<void> => {
|
||||
if (
|
||||
dataSource === DataSource.METRICS &&
|
||||
!queryData.aggregateAttribute?.key
|
||||
) {
|
||||
setKeySuggestions([]);
|
||||
return;
|
||||
}
|
||||
setKeySuggestions(Array.from(merged.values()));
|
||||
setIsCompleteKeysList(complete);
|
||||
}
|
||||
};
|
||||
|
||||
lastFetchedKeyRef.current = searchText || '';
|
||||
|
||||
const response = await getKeySuggestions({
|
||||
signal: dataSource,
|
||||
searchText: searchText || '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
});
|
||||
|
||||
if (response.data.data) {
|
||||
const { keys } = response.data.data;
|
||||
const options = generateOptions(keys);
|
||||
// Use a Map to deduplicate by label and preserve order: new options take precedence
|
||||
const merged = new Map<string, QueryKeyDataSuggestionsProps>();
|
||||
options.forEach((opt) => merged.set(opt.label, opt));
|
||||
if (searchText && lastKeyRef.current !== searchText) {
|
||||
(keySuggestions || []).forEach((opt) => {
|
||||
if (!merged.has(opt.label)) merged.set(opt.label, opt);
|
||||
});
|
||||
}
|
||||
setKeySuggestions(Array.from(merged.values()));
|
||||
|
||||
// Force reopen the completion if editor is available and focused
|
||||
if (editorRef.current) {
|
||||
toggleSuggestions(10);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
dataSource,
|
||||
debouncedMetricName,
|
||||
keySuggestions,
|
||||
toggleSuggestions,
|
||||
queryData.aggregateAttribute?.key,
|
||||
signalSource,
|
||||
],
|
||||
);
|
||||
|
||||
const debouncedFetchKeySuggestions = useMemo(
|
||||
() => debounce(fetchKeySuggestions, 300),
|
||||
[fetchKeySuggestions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setKeySuggestions([]);
|
||||
fetchKeySuggestions();
|
||||
debouncedFetchKeySuggestions();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSource, debouncedMetricName]);
|
||||
|
||||
@@ -310,6 +370,11 @@ function QuerySearch({
|
||||
},
|
||||
]);
|
||||
|
||||
// Force reopen the completion if editor is available and focused
|
||||
if (editorRef.current) {
|
||||
toggleSuggestions(10);
|
||||
}
|
||||
|
||||
const sanitizedSearchText = searchText ? searchText?.trim() : '';
|
||||
|
||||
try {
|
||||
@@ -317,6 +382,8 @@ function QuerySearch({
|
||||
key,
|
||||
searchText: sanitizedSearchText,
|
||||
signal: dataSource,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
});
|
||||
|
||||
// Skip updates if component unmounted or key changed
|
||||
@@ -382,13 +449,9 @@ function QuerySearch({
|
||||
]);
|
||||
}
|
||||
|
||||
// Force reopen the completion if editor is available
|
||||
// Force reopen the completion if editor is available and focused
|
||||
if (editorRef.current) {
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current && editorRef.current) {
|
||||
startCompletion(editorRef.current);
|
||||
}
|
||||
}, 10);
|
||||
toggleSuggestions(10);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -408,7 +471,14 @@ function QuerySearch({
|
||||
setIsFetchingCompleteValuesList(false);
|
||||
}
|
||||
},
|
||||
[activeKey, dataSource, isLoadingSuggestions],
|
||||
[
|
||||
activeKey,
|
||||
dataSource,
|
||||
isLoadingSuggestions,
|
||||
debouncedMetricName,
|
||||
signalSource,
|
||||
toggleSuggestions,
|
||||
],
|
||||
);
|
||||
|
||||
const debouncedFetchValueSuggestions = useMemo(
|
||||
@@ -468,14 +538,13 @@ function QuerySearch({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleQueryChange = useCallback(async (newQuery: string) => {
|
||||
setQuery(newQuery);
|
||||
}, []);
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
setQuery(value);
|
||||
handleQueryChange(value);
|
||||
onChange(value);
|
||||
// Mark as internal change to avoid triggering external validation
|
||||
setIsExternalQueryChange(false);
|
||||
// Update lastExternalQuery to prevent external validation trigger
|
||||
setLastExternalQuery(value);
|
||||
};
|
||||
|
||||
const handleBlur = (): void => {
|
||||
@@ -483,24 +552,27 @@ function QuerySearch({
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (query) {
|
||||
handleQueryValidation(query);
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
if (debouncedFetchValueSuggestions) {
|
||||
debouncedFetchValueSuggestions.cancel();
|
||||
}
|
||||
};
|
||||
if (debouncedFetchKeySuggestions) {
|
||||
debouncedFetchKeySuggestions.cancel();
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
[],
|
||||
);
|
||||
|
||||
const handleExampleClick = (exampleQuery: string): void => {
|
||||
// If there's an existing query, append the example with AND
|
||||
const newQuery = query ? `${query} AND ${exampleQuery}` : exampleQuery;
|
||||
setQuery(newQuery);
|
||||
handleQueryChange(newQuery);
|
||||
// Mark as internal change to avoid triggering external validation
|
||||
setIsExternalQueryChange(false);
|
||||
// Update lastExternalQuery to prevent external validation trigger
|
||||
setLastExternalQuery(newQuery);
|
||||
};
|
||||
|
||||
// Helper function to render a badge for the current context mode
|
||||
@@ -743,16 +815,14 @@ function QuerySearch({
|
||||
}
|
||||
|
||||
if (queryContext.isInKey) {
|
||||
const searchText = word?.text.toLowerCase() ?? '';
|
||||
const searchText = word?.text.toLowerCase().trim() ?? '';
|
||||
|
||||
options = (keySuggestions || []).filter((option) =>
|
||||
option.label.toLowerCase().includes(searchText),
|
||||
);
|
||||
|
||||
if (!isCompleteKeysList && options.length === 0) {
|
||||
setTimeout(() => {
|
||||
fetchKeySuggestions(searchText);
|
||||
}, 300);
|
||||
if (options.length === 0 && lastFetchedKeyRef.current !== searchText) {
|
||||
debouncedFetchKeySuggestions(searchText);
|
||||
}
|
||||
|
||||
// If we have previous pairs, we can prioritize keys that haven't been used yet
|
||||
@@ -827,12 +897,32 @@ function QuerySearch({
|
||||
QUERY_BUILDER_KEY_TYPES.STRING
|
||||
].includes(op.label),
|
||||
)
|
||||
.map((op) => ({
|
||||
...op,
|
||||
boost: ['=', '!=', 'LIKE', 'ILIKE', 'CONTAINS', 'IN'].includes(op.label)
|
||||
? 100
|
||||
: 0,
|
||||
}));
|
||||
.map((op) => {
|
||||
if (op.label === OPERATORS['=']) {
|
||||
return {
|
||||
...op,
|
||||
boost: 200,
|
||||
};
|
||||
}
|
||||
if (
|
||||
[
|
||||
OPERATORS['!='],
|
||||
OPERATORS.LIKE,
|
||||
OPERATORS.ILIKE,
|
||||
OPERATORS.CONTAINS,
|
||||
OPERATORS.IN,
|
||||
].includes(op.label)
|
||||
) {
|
||||
return {
|
||||
...op,
|
||||
boost: 100,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...op,
|
||||
boost: 0,
|
||||
};
|
||||
});
|
||||
} else if (keyType === QUERY_BUILDER_KEY_TYPES.BOOLEAN) {
|
||||
// Prioritize boolean operators
|
||||
options = options
|
||||
@@ -841,10 +931,24 @@ function QuerySearch({
|
||||
QUERY_BUILDER_KEY_TYPES.BOOLEAN
|
||||
].includes(op.label),
|
||||
)
|
||||
.map((op) => ({
|
||||
...op,
|
||||
boost: ['=', '!='].includes(op.label) ? 100 : 0,
|
||||
}));
|
||||
.map((op) => {
|
||||
if (op.label === OPERATORS['=']) {
|
||||
return {
|
||||
...op,
|
||||
boost: 200,
|
||||
};
|
||||
}
|
||||
if (op.label === OPERATORS['!=']) {
|
||||
return {
|
||||
...op,
|
||||
boost: 100,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...op,
|
||||
boost: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1034,26 +1138,15 @@ function QuerySearch({
|
||||
|
||||
// Effect to handle focus state and trigger suggestions
|
||||
useEffect(() => {
|
||||
if (editorRef.current) {
|
||||
if (!isFocused) {
|
||||
closeCompletion(editorRef.current);
|
||||
} else {
|
||||
startCompletion(editorRef.current);
|
||||
}
|
||||
}
|
||||
}, [isFocused]);
|
||||
const clearTimeout = toggleSuggestions(10);
|
||||
return (): void => clearTimeout();
|
||||
}, [isFocused, toggleSuggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryContext) return;
|
||||
|
||||
// Trigger suggestions based on context
|
||||
if (editorRef.current) {
|
||||
// Small delay to ensure the context is fully updated
|
||||
setTimeout(() => {
|
||||
if (editorRef.current) {
|
||||
startCompletion(editorRef.current);
|
||||
}
|
||||
}, 50);
|
||||
toggleSuggestions(10);
|
||||
}
|
||||
|
||||
// Handle value suggestions for value context
|
||||
@@ -1066,7 +1159,28 @@ function QuerySearch({
|
||||
fetchValueSuggestions({ key });
|
||||
}
|
||||
}
|
||||
}, [queryContext, activeKey, isLoadingSuggestions, fetchValueSuggestions]);
|
||||
}, [
|
||||
queryContext,
|
||||
toggleSuggestions,
|
||||
isLoadingSuggestions,
|
||||
activeKey,
|
||||
fetchValueSuggestions,
|
||||
]);
|
||||
|
||||
const getTooltipContent = (): JSX.Element => (
|
||||
<div>
|
||||
Need help with search syntax?
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/search-syntax/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
>
|
||||
View documentation
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="code-mirror-where-clause">
|
||||
@@ -1109,6 +1223,31 @@ function QuerySearch({
|
||||
)}
|
||||
|
||||
<div className="query-where-clause-editor-container">
|
||||
<Tooltip title={getTooltipContent()} placement="left">
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/search-syntax/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: validation.isValid === false && query ? 40 : 8, // Move left when error shown
|
||||
cursor: 'help',
|
||||
zIndex: 10,
|
||||
transition: 'right 0.2s ease',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
color: '#8c8c8c',
|
||||
}}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<Info
|
||||
size={14}
|
||||
style={{ opacity: 0.9, color: isDarkMode ? '#ffffff' : '#000000' }}
|
||||
/>
|
||||
</a>
|
||||
</Tooltip>
|
||||
|
||||
<CodeMirror
|
||||
value={query}
|
||||
theme={isDarkMode ? copilot : githubLight}
|
||||
@@ -1167,7 +1306,7 @@ function QuerySearch({
|
||||
]),
|
||||
),
|
||||
]}
|
||||
placeholder="Enter your filter query (e.g., status = 'error' AND service = 'frontend')"
|
||||
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
|
||||
basicSetup={{
|
||||
lineNumbers: false,
|
||||
}}
|
||||
@@ -1313,6 +1452,7 @@ function QuerySearch({
|
||||
|
||||
QuerySearch.defaultProps = {
|
||||
onRun: undefined,
|
||||
signalSource: '',
|
||||
};
|
||||
|
||||
export default QuerySearch;
|
||||
|
||||
@@ -28,6 +28,7 @@ export const QueryV2 = memo(function QueryV2({
|
||||
isListViewPanel = false,
|
||||
version,
|
||||
showOnlyWhereClause = false,
|
||||
signalSource = '',
|
||||
}: QueryProps & { ref: React.RefObject<HTMLDivElement> }): JSX.Element {
|
||||
const { cloneQuery, panelType } = useQueryBuilder();
|
||||
|
||||
@@ -175,6 +176,7 @@ export const QueryV2 = memo(function QueryV2({
|
||||
query={query}
|
||||
index={index}
|
||||
version={ENTITY_VERSION_V5}
|
||||
signalSource={signalSource as 'meter' | ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -186,6 +188,7 @@ export const QueryV2 = memo(function QueryV2({
|
||||
onChange={handleSearchChange}
|
||||
queryData={query}
|
||||
dataSource={dataSource}
|
||||
signalSource={signalSource}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -218,6 +221,7 @@ export const QueryV2 = memo(function QueryV2({
|
||||
index={index}
|
||||
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}`}
|
||||
version="v4"
|
||||
signalSource={signalSource as 'meter' | ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
386
frontend/src/components/QueryBuilderV2/utils.test.ts
Normal file
386
frontend/src/components/QueryBuilderV2/utils.test.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
|
||||
// Now import the function after all mocks are set up
|
||||
import { convertFiltersToExpressionWithExistingQuery } from './utils';
|
||||
|
||||
jest.mock('utils/queryContextUtils', () => ({
|
||||
extractQueryPairs: jest.fn(),
|
||||
}));
|
||||
|
||||
// Type the mocked functions
|
||||
const mockExtractQueryPairs = extractQueryPairs as jest.MockedFunction<
|
||||
typeof extractQueryPairs
|
||||
>;
|
||||
|
||||
describe('convertFiltersToExpressionWithExistingQuery', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should return filters with new expression when no existing query', () => {
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { id: 'service.name', key: 'service.name', type: 'string' },
|
||||
op: OPERATORS['='],
|
||||
value: 'test-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpressionWithExistingQuery(
|
||||
filters,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result.filters).toEqual(filters);
|
||||
expect(result.filter.expression).toBe("service.name = 'test-service'");
|
||||
});
|
||||
|
||||
test('should handle empty filters', () => {
|
||||
const filters = {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpressionWithExistingQuery(
|
||||
filters,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result.filters).toEqual(filters);
|
||||
expect(result.filter.expression).toBe('');
|
||||
});
|
||||
|
||||
test('should handle existing query with matching filters', () => {
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { id: 'service.name', key: 'service.name', type: 'string' },
|
||||
op: OPERATORS['='],
|
||||
value: 'updated-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const existingQuery = "service.name = 'old-service'";
|
||||
|
||||
// Mock extractQueryPairs to return query pairs with position information
|
||||
mockExtractQueryPairs.mockReturnValue([
|
||||
{
|
||||
key: 'service.name',
|
||||
operator: OPERATORS['='],
|
||||
value: "'old-service'",
|
||||
hasNegation: false,
|
||||
isMultiValue: false,
|
||||
isComplete: true,
|
||||
position: {
|
||||
keyStart: 0,
|
||||
keyEnd: 11,
|
||||
operatorStart: 13,
|
||||
operatorEnd: 13,
|
||||
valueStart: 15,
|
||||
valueEnd: 28,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = convertFiltersToExpressionWithExistingQuery(
|
||||
filters,
|
||||
existingQuery,
|
||||
);
|
||||
|
||||
expect(result.filters).toBeDefined();
|
||||
expect(result.filter).toBeDefined();
|
||||
expect(result.filter.expression).toBe("service.name = 'old-service'");
|
||||
expect(mockExtractQueryPairs).toHaveBeenCalledWith(
|
||||
"service.name = 'old-service'",
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle IN operator with existing query', () => {
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { id: 'service.name', key: 'service.name', type: 'string' },
|
||||
op: OPERATORS.IN,
|
||||
value: ['service1', 'service2'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const existingQuery = "service.name IN ['old-service']";
|
||||
|
||||
mockExtractQueryPairs.mockReturnValue([
|
||||
{
|
||||
key: 'service.name',
|
||||
operator: 'IN',
|
||||
value: "['old-service']",
|
||||
valueList: ["'old-service'"],
|
||||
valuesPosition: [
|
||||
{
|
||||
start: 17,
|
||||
end: 29,
|
||||
},
|
||||
],
|
||||
hasNegation: false,
|
||||
isMultiValue: true,
|
||||
position: {
|
||||
keyStart: 0,
|
||||
keyEnd: 11,
|
||||
operatorStart: 13,
|
||||
operatorEnd: 14,
|
||||
valueStart: 16,
|
||||
valueEnd: 30,
|
||||
negationStart: 0,
|
||||
negationEnd: 0,
|
||||
},
|
||||
isComplete: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = convertFiltersToExpressionWithExistingQuery(
|
||||
filters,
|
||||
existingQuery,
|
||||
);
|
||||
|
||||
expect(result.filters).toBeDefined();
|
||||
expect(result.filter).toBeDefined();
|
||||
// The function is currently returning the new value but with extra characters
|
||||
expect(result.filter.expression).toBe(
|
||||
"service.name IN ['service1', 'service2']",
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle IN operator conversion from equals', () => {
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { id: 'service.name', key: 'service.name', type: 'string' },
|
||||
op: OPERATORS.IN,
|
||||
value: ['service1', 'service2'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const existingQuery = "service.name = 'old-service'";
|
||||
|
||||
mockExtractQueryPairs.mockReturnValue([
|
||||
{
|
||||
key: 'service.name',
|
||||
operator: OPERATORS['='],
|
||||
value: "'old-service'",
|
||||
hasNegation: false,
|
||||
isMultiValue: false,
|
||||
isComplete: true,
|
||||
position: {
|
||||
keyStart: 0,
|
||||
keyEnd: 11,
|
||||
operatorStart: 13,
|
||||
operatorEnd: 13,
|
||||
valueStart: 15,
|
||||
valueEnd: 28,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = convertFiltersToExpressionWithExistingQuery(
|
||||
filters,
|
||||
existingQuery,
|
||||
);
|
||||
|
||||
expect(result.filters.items).toHaveLength(1);
|
||||
// The function is currently returning the new value but with extra characters
|
||||
expect(result.filter.expression).toBe(
|
||||
"service.name IN ['service1', 'service2'] ",
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle NOT IN operator conversion from not equals', () => {
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { id: 'service.name', key: 'service.name', type: 'string' },
|
||||
op: negateOperator(OPERATORS.IN),
|
||||
value: ['service1', 'service2'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const existingQuery = "service.name != 'old-service'";
|
||||
|
||||
mockExtractQueryPairs.mockReturnValue([
|
||||
{
|
||||
key: 'service.name',
|
||||
operator: OPERATORS['!='],
|
||||
value: "'old-service'",
|
||||
hasNegation: false,
|
||||
isMultiValue: false,
|
||||
isComplete: true,
|
||||
position: {
|
||||
keyStart: 0,
|
||||
keyEnd: 11,
|
||||
operatorStart: 13,
|
||||
operatorEnd: 14,
|
||||
valueStart: 16,
|
||||
valueEnd: 28,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = convertFiltersToExpressionWithExistingQuery(
|
||||
filters,
|
||||
existingQuery,
|
||||
);
|
||||
|
||||
expect(result.filters.items).toHaveLength(1);
|
||||
// The function is currently returning the new value but with extra characters
|
||||
expect(result.filter.expression).toBe(
|
||||
"service.name NOT IN ['service1', 'service2'] ",
|
||||
);
|
||||
});
|
||||
|
||||
test('should add new filters when they do not exist in existing query', () => {
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { id: 'new.key', key: 'new.key', type: 'string' },
|
||||
op: OPERATORS['='],
|
||||
value: 'new-value',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const existingQuery = "service.name = 'old-service'";
|
||||
|
||||
mockExtractQueryPairs.mockReturnValue([
|
||||
{
|
||||
key: 'service.name',
|
||||
operator: OPERATORS['='],
|
||||
value: "'old-service'",
|
||||
hasNegation: false,
|
||||
isMultiValue: false,
|
||||
isComplete: true,
|
||||
position: {
|
||||
keyStart: 0,
|
||||
keyEnd: 11,
|
||||
operatorStart: 13,
|
||||
operatorEnd: 13,
|
||||
valueStart: 15,
|
||||
valueEnd: 28,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = convertFiltersToExpressionWithExistingQuery(
|
||||
filters,
|
||||
existingQuery,
|
||||
);
|
||||
|
||||
expect(result.filters.items).toHaveLength(2); // Original + new filter
|
||||
expect(result.filter.expression).toBe(
|
||||
"service.name = 'old-service' new.key = 'new-value'",
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle simple value replacement', () => {
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { id: 'status', key: 'status', type: 'string' },
|
||||
op: OPERATORS['='],
|
||||
value: 'error',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const existingQuery = "status = 'success'";
|
||||
|
||||
mockExtractQueryPairs.mockReturnValue([
|
||||
{
|
||||
key: 'status',
|
||||
operator: OPERATORS['='],
|
||||
value: "'success'",
|
||||
hasNegation: false,
|
||||
isMultiValue: false,
|
||||
isComplete: true,
|
||||
position: {
|
||||
keyStart: 0,
|
||||
keyEnd: 6,
|
||||
operatorStart: 8,
|
||||
operatorEnd: 8,
|
||||
valueStart: 10,
|
||||
valueEnd: 19,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = convertFiltersToExpressionWithExistingQuery(
|
||||
filters,
|
||||
existingQuery,
|
||||
);
|
||||
|
||||
expect(result.filters.items).toHaveLength(1);
|
||||
// The function is currently returning the original expression (until we fix the replacement logic)
|
||||
expect(result.filter.expression).toBe("status = 'success'");
|
||||
});
|
||||
|
||||
test('should handle filters with no key gracefully', () => {
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: undefined,
|
||||
op: OPERATORS['='],
|
||||
value: 'test-value',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const existingQuery = "service.name = 'old-service'";
|
||||
|
||||
mockExtractQueryPairs.mockReturnValue([
|
||||
{
|
||||
key: 'service.name',
|
||||
operator: OPERATORS['='],
|
||||
value: "'old-service'",
|
||||
hasNegation: false,
|
||||
isMultiValue: false,
|
||||
isComplete: true,
|
||||
position: {
|
||||
keyStart: 0,
|
||||
keyEnd: 11,
|
||||
operatorStart: 13,
|
||||
operatorEnd: 13,
|
||||
valueStart: 15,
|
||||
valueEnd: 28,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = convertFiltersToExpressionWithExistingQuery(
|
||||
filters,
|
||||
existingQuery,
|
||||
);
|
||||
|
||||
expect(result.filters.items).toHaveLength(2); // Original + new filter (even though it has no key)
|
||||
expect(result.filter.expression).toBe("service.name = 'old-service'");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { createAggregation } from 'api/v5/queryRange/prepareQueryRangePayloadV5';
|
||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import { NON_VALUE_OPERATORS, OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { cloneDeep, isEqual, sortBy } from 'lodash-es';
|
||||
import { IQueryPair } from 'types/antlrQueryTypes';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
@@ -21,6 +21,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
import { unquote } from 'utils/stringUtils';
|
||||
import { isFunctionOperator } from 'utils/tokenUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
/**
|
||||
@@ -86,6 +87,15 @@ export const convertFiltersToExpression = (
|
||||
return '';
|
||||
}
|
||||
|
||||
const sanitizedOperator = op.trim().toUpperCase();
|
||||
if (isFunctionOperator(op)) {
|
||||
return `${op}(${key.key}, ${value})`;
|
||||
}
|
||||
|
||||
if (NON_VALUE_OPERATORS.includes(sanitizedOperator)) {
|
||||
return `${key.key} ${op}`;
|
||||
}
|
||||
|
||||
const formattedValue = formatValueForExpression(value, op);
|
||||
return `${key.key} ${op} ${formattedValue}`;
|
||||
})
|
||||
@@ -196,6 +206,31 @@ export const convertFiltersToExpressionWithExistingQuery = (
|
||||
existingPair.position?.valueEnd
|
||||
) {
|
||||
visitedPairs.add(`${key.key}-${op}`.trim().toLowerCase());
|
||||
|
||||
// Check if existing values match current filter values (for array-based operators)
|
||||
if (existingPair.valueList && filter.value && Array.isArray(filter.value)) {
|
||||
// Clean quotes from string values for comparison
|
||||
const cleanValues = (values: any[]): any[] =>
|
||||
values.map((val) => (typeof val === 'string' ? unquote(val) : val));
|
||||
|
||||
const cleanExistingValues = cleanValues(existingPair.valueList);
|
||||
const cleanFilterValues = cleanValues(filter.value);
|
||||
|
||||
// Compare arrays (order-independent) - if identical, keep existing value
|
||||
const isSameValues =
|
||||
cleanExistingValues.length === cleanFilterValues.length &&
|
||||
isEqual(sortBy(cleanExistingValues), sortBy(cleanFilterValues));
|
||||
|
||||
if (isSameValues) {
|
||||
// Values are identical, preserve existing formatting
|
||||
modifiedQuery =
|
||||
modifiedQuery.slice(0, existingPair.position.valueStart) +
|
||||
existingPair.value +
|
||||
modifiedQuery.slice(existingPair.position.valueEnd + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
modifiedQuery =
|
||||
modifiedQuery.slice(0, existingPair.position.valueStart) +
|
||||
formattedValue +
|
||||
@@ -539,43 +574,16 @@ export const convertAggregationToExpression = (
|
||||
];
|
||||
};
|
||||
|
||||
export const getQueryTitles = (currentQuery: Query): string[] => {
|
||||
if (currentQuery.queryType === EQueryType.QUERY_BUILDER) {
|
||||
const queryTitles: string[] = [];
|
||||
|
||||
// Handle builder queries with multiple aggregations
|
||||
currentQuery.builder.queryData.forEach((q) => {
|
||||
const aggregationCount = q.aggregations?.length || 1;
|
||||
|
||||
if (aggregationCount > 1) {
|
||||
// If multiple aggregations, create titles like A.0, A.1, A.2
|
||||
for (let i = 0; i < aggregationCount; i++) {
|
||||
queryTitles.push(`${q.queryName}.${i}`);
|
||||
}
|
||||
} else {
|
||||
// Single aggregation, just use query name
|
||||
queryTitles.push(q.queryName);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle formulas (they don't have aggregations, so just use query name)
|
||||
const formulas = currentQuery.builder.queryFormulas.map((q) => q.queryName);
|
||||
|
||||
return [...queryTitles, ...formulas];
|
||||
}
|
||||
|
||||
if (currentQuery.queryType === EQueryType.CLICKHOUSE) {
|
||||
return currentQuery.clickhouse_sql.map((q) => q.name);
|
||||
}
|
||||
|
||||
return currentQuery.promql.map((q) => q.name);
|
||||
};
|
||||
|
||||
function getColId(
|
||||
queryName: string,
|
||||
aggregation: { alias?: string; expression?: string },
|
||||
isMultipleAggregations: boolean,
|
||||
): string {
|
||||
return `${queryName}.${aggregation.expression}`;
|
||||
if (isMultipleAggregations && aggregation.expression) {
|
||||
return `${queryName}.${aggregation.expression}`;
|
||||
}
|
||||
|
||||
return queryName;
|
||||
}
|
||||
|
||||
// function to give you label value for query name taking multiaggregation into account
|
||||
@@ -599,7 +607,7 @@ export function getQueryLabelWithAggregation(
|
||||
const isMultipleAggregations = aggregations.length > 1;
|
||||
|
||||
aggregations.forEach((agg: any, index: number) => {
|
||||
const columnId = getColId(queryName, agg);
|
||||
const columnId = getColId(queryName, agg, isMultipleAggregations);
|
||||
|
||||
// For display purposes, show the aggregation index for multiple aggregations
|
||||
const displayLabel = isMultipleAggregations
|
||||
|
||||
@@ -17,6 +17,7 @@ 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';
|
||||
@@ -73,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);
|
||||
|
||||
@@ -478,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">
|
||||
|
||||
@@ -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';
|
||||
@@ -236,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>
|
||||
|
||||
@@ -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;
|
||||
@@ -15,6 +15,12 @@ export const OPERATORS = {
|
||||
'<': '<',
|
||||
};
|
||||
|
||||
export const QUERY_BUILDER_FUNCTIONS = {
|
||||
HAS: 'has',
|
||||
HASANY: 'hasAny',
|
||||
HASALL: 'hasAll',
|
||||
};
|
||||
|
||||
export const NON_VALUE_OPERATORS = [OPERATORS.EXISTS];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -76,3 +82,15 @@ export const queryOperatorSuggestions = [
|
||||
{ label: OPERATORS.NOT, type: 'operator', info: 'Not' },
|
||||
...negationQueryOperatorSuggestions,
|
||||
];
|
||||
|
||||
export function negateOperator(operatorOrFunction: string): string {
|
||||
// Special cases for equals/not equals
|
||||
if (operatorOrFunction === OPERATORS['=']) {
|
||||
return OPERATORS['!='];
|
||||
}
|
||||
if (operatorOrFunction === OPERATORS['!=']) {
|
||||
return OPERATORS['='];
|
||||
}
|
||||
// For all other operators and functions, add NOT in front
|
||||
return `${OPERATORS.NOT} ${operatorOrFunction}`;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
BoolOperators,
|
||||
DataSource,
|
||||
LogsAggregatorOperator,
|
||||
MeterAggregateOperator,
|
||||
MetricAggregateOperator,
|
||||
NumberOperators,
|
||||
QueryAdditionalFilter,
|
||||
@@ -36,6 +37,7 @@ import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
logsAggregateOperatorOptions,
|
||||
meterAggregateOperatorOptions,
|
||||
metricAggregateOperatorOptions,
|
||||
metricsGaugeAggregateOperatorOptions,
|
||||
metricsGaugeSpaceAggregateOperatorOptions,
|
||||
@@ -79,6 +81,7 @@ export const mapOfOperators = {
|
||||
metrics: metricAggregateOperatorOptions,
|
||||
logs: logsAggregateOperatorOptions,
|
||||
traces: tracesAggregateOperatorOptions,
|
||||
meter: meterAggregateOperatorOptions,
|
||||
};
|
||||
|
||||
export const metricsOperatorsByType = {
|
||||
@@ -193,6 +196,7 @@ export const initialQueryBuilderFormValues: IBuilderQuery = {
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: 'avg',
|
||||
source: '',
|
||||
};
|
||||
|
||||
const initialQueryBuilderFormLogsValues: IBuilderQuery = {
|
||||
@@ -209,6 +213,39 @@ const initialQueryBuilderFormTracesValues: IBuilderQuery = {
|
||||
dataSource: DataSource.TRACES,
|
||||
};
|
||||
|
||||
export const initialQueryBuilderFormMeterValues: IBuilderQuery = {
|
||||
dataSource: DataSource.METRICS,
|
||||
queryName: createNewBuilderItemName({ existNames: [], sourceNames: alphabet }),
|
||||
aggregateOperator: MeterAggregateOperator.COUNT,
|
||||
aggregateAttribute: initialAutocompleteData,
|
||||
timeAggregation: MeterAggregateOperator.RATE,
|
||||
spaceAggregation: MeterAggregateOperator.SUM,
|
||||
filter: { expression: '' },
|
||||
aggregations: [
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: MeterAggregateOperator.COUNT,
|
||||
spaceAggregation: MeterAggregateOperator.SUM,
|
||||
reduceTo: 'avg',
|
||||
},
|
||||
],
|
||||
functions: [],
|
||||
filters: { items: [], op: 'AND' },
|
||||
expression: createNewBuilderItemName({
|
||||
existNames: [],
|
||||
sourceNames: alphabet,
|
||||
}),
|
||||
disabled: false,
|
||||
stepInterval: undefined,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: 'avg',
|
||||
};
|
||||
|
||||
export const initialQueryBuilderFormValuesMap: Record<
|
||||
DataSource,
|
||||
IBuilderQuery
|
||||
@@ -285,6 +322,19 @@ export const initialQueriesMap: Record<DataSource, Query> = {
|
||||
traces: initialQueryTracesWithType,
|
||||
};
|
||||
|
||||
export const initialQueryMeterWithType: Query = {
|
||||
...initialQueryWithType,
|
||||
builder: {
|
||||
...initialQueryWithType.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.metrics,
|
||||
source: 'meter',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const operatorsByTypes: Record<LocalDataType, string[]> = {
|
||||
string: Object.values(StringOperators),
|
||||
number: Object.values(NumberOperators),
|
||||
|
||||
@@ -125,6 +125,126 @@ export const metricAggregateOperatorOptions: SelectOption<string, string>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const meterAggregateOperatorOptions: SelectOption<string, string>[] = [
|
||||
{
|
||||
value: MetricAggregateOperator.COUNT,
|
||||
label: 'Count',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.COUNT_DISTINCT,
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
label: 'Count Distinct',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.SUM,
|
||||
label: 'Sum',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.AVG,
|
||||
label: 'Avg',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.MAX,
|
||||
label: 'Max',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.MIN,
|
||||
label: 'Min',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.P05,
|
||||
label: 'P05',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.P10,
|
||||
label: 'P10',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.P20,
|
||||
label: 'P20',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.P25,
|
||||
label: 'P25',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.P50,
|
||||
label: 'P50',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.P75,
|
||||
label: 'P75',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.P90,
|
||||
label: 'P90',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.P95,
|
||||
label: 'P95',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.P99,
|
||||
label: 'P99',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.RATE,
|
||||
label: 'Rate',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.SUM_RATE,
|
||||
label: 'Sum_rate',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.AVG_RATE,
|
||||
label: 'Avg_rate',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.MAX_RATE,
|
||||
label: 'Max_rate',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.MIN_RATE,
|
||||
label: 'Min_rate',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.RATE_SUM,
|
||||
label: 'Rate_sum',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.RATE_AVG,
|
||||
label: 'Rate_avg',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.RATE_MIN,
|
||||
label: 'Rate_min',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.RATE_MAX,
|
||||
label: 'Rate_max',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.HIST_QUANTILE_50,
|
||||
label: 'Hist_quantile_50',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.HIST_QUANTILE_75,
|
||||
label: 'Hist_quantile_75',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.HIST_QUANTILE_90,
|
||||
label: 'Hist_quantile_90',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.HIST_QUANTILE_95,
|
||||
label: 'Hist_quantile_95',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.HIST_QUANTILE_99,
|
||||
label: 'Hist_quantile_99',
|
||||
},
|
||||
];
|
||||
|
||||
export const tracesAggregateOperatorOptions: SelectOption<string, string>[] = [
|
||||
{
|
||||
value: TracesAggregatorOperator.COUNT,
|
||||
|
||||
@@ -77,6 +77,9 @@ const ROUTES = {
|
||||
API_MONITORING: '/api-monitoring/explorer',
|
||||
METRICS_EXPLORER_BASE: '/metrics-explorer',
|
||||
WORKSPACE_ACCESS_RESTRICTED: '/workspace-access-restricted',
|
||||
METER_EXPLORER_BASE: '/meter-explorer',
|
||||
METER_EXPLORER: '/meter-explorer',
|
||||
METER_EXPLORER_VIEWS: '/meter-explorer/views',
|
||||
HOME_PAGE: '/',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export const GlobalShortcuts = {
|
||||
NavigateToAlerts: 'a+shift',
|
||||
NavigateToExceptions: 'e+shift',
|
||||
NavigateToMessagingQueues: 'm+shift',
|
||||
ToggleSidebar: 'b+shift',
|
||||
};
|
||||
|
||||
export const GlobalShortcutsName = {
|
||||
@@ -16,6 +17,7 @@ export const GlobalShortcutsName = {
|
||||
NavigateToAlerts: 'shift+a',
|
||||
NavigateToExceptions: 'shift+e',
|
||||
NavigateToMessagingQueues: 'shift+m',
|
||||
ToggleSidebar: 'shift+b',
|
||||
};
|
||||
|
||||
export const GlobalShortcutsDescription = {
|
||||
@@ -26,4 +28,5 @@ export const GlobalShortcutsDescription = {
|
||||
NavigateToAlerts: 'Navigate to alerts page',
|
||||
NavigateToExceptions: 'Navigate to Exceptions page',
|
||||
NavigateToMessagingQueues: 'Navigate to Messaging Queues page',
|
||||
ToggleSidebar: 'Toggle sidebar visibility',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import {
|
||||
KeyboardHotkeysProvider,
|
||||
useKeyboardHotkeys,
|
||||
} from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('api/common/logEvent', () => jest.fn());
|
||||
|
||||
// Mock the AppContext
|
||||
const mockUpdateUserPreferenceInContext = jest.fn();
|
||||
|
||||
const SHIFT_B_KEYBOARD_SHORTCUT = '{Shift>}b{/Shift}';
|
||||
|
||||
jest.mock('providers/App/App', () => ({
|
||||
useAppContext: jest.fn(() => ({
|
||||
userPreferences: [
|
||||
{
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
updateUserPreferenceInContext: mockUpdateUserPreferenceInContext,
|
||||
})),
|
||||
}));
|
||||
|
||||
function TestComponent({
|
||||
mockHandleShortcut,
|
||||
}: {
|
||||
mockHandleShortcut: () => void;
|
||||
}): JSX.Element {
|
||||
const { registerShortcut } = useKeyboardHotkeys();
|
||||
registerShortcut(GlobalShortcuts.ToggleSidebar, mockHandleShortcut);
|
||||
return <div data-testid="test">Test</div>;
|
||||
}
|
||||
|
||||
describe('Sidebar Toggle Shortcut', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Global Shortcuts Constants', () => {
|
||||
it('should have the correct shortcut key combination', () => {
|
||||
expect(GlobalShortcuts.ToggleSidebar).toBe('b+shift');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Shortcut Registration', () => {
|
||||
it('should register the sidebar toggle shortcut correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockHandleShortcut = jest.fn();
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<KeyboardHotkeysProvider>
|
||||
<TestComponent mockHandleShortcut={mockHandleShortcut} />
|
||||
</KeyboardHotkeysProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Trigger the shortcut
|
||||
await user.keyboard(SHIFT_B_KEYBOARD_SHORTCUT);
|
||||
|
||||
expect(mockHandleShortcut).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not trigger shortcut in input fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockHandleShortcut = jest.fn();
|
||||
|
||||
function TestComponent(): JSX.Element {
|
||||
const { registerShortcut } = useKeyboardHotkeys();
|
||||
registerShortcut(GlobalShortcuts.ToggleSidebar, mockHandleShortcut);
|
||||
return (
|
||||
<div>
|
||||
<input data-testid="input-field" />
|
||||
<div data-testid="test">Test</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<KeyboardHotkeysProvider>
|
||||
<TestComponent />
|
||||
</KeyboardHotkeysProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Focus on input field
|
||||
const inputField = screen.getByTestId('input-field');
|
||||
await user.click(inputField);
|
||||
|
||||
// Try to trigger shortcut while focused on input
|
||||
await user.keyboard('{Shift>}b{/Shift}');
|
||||
|
||||
// Should not trigger the shortcut
|
||||
expect(mockHandleShortcut).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sidebar Toggle Functionality', () => {
|
||||
it('should log the toggle event with correct parameters', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockHandleShortcut = jest.fn(() => {
|
||||
logEvent('Global Shortcut: Sidebar Toggle', {
|
||||
previousState: false,
|
||||
newState: true,
|
||||
});
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<KeyboardHotkeysProvider>
|
||||
<TestComponent mockHandleShortcut={mockHandleShortcut} />
|
||||
</KeyboardHotkeysProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
await user.keyboard(SHIFT_B_KEYBOARD_SHORTCUT);
|
||||
|
||||
expect(logEvent).toHaveBeenCalledWith('Global Shortcut: Sidebar Toggle', {
|
||||
previousState: false,
|
||||
newState: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should update user preference in context', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockHandleShortcut = jest.fn(() => {
|
||||
const save = {
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
value: true,
|
||||
};
|
||||
mockUpdateUserPreferenceInContext(save);
|
||||
});
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<KeyboardHotkeysProvider>
|
||||
<TestComponent mockHandleShortcut={mockHandleShortcut} />
|
||||
</KeyboardHotkeysProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
await user.keyboard(SHIFT_B_KEYBOARD_SHORTCUT);
|
||||
|
||||
expect(mockUpdateUserPreferenceInContext).toHaveBeenCalledWith({
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,10 @@ import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import getChangelogByVersion from 'api/changelog/getChangelogByVersion';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import manageCreditCardApi from 'api/v1/portal/create';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import getUserLatestVersion from 'api/v1/version/getLatestVersion';
|
||||
import getUserVersion from 'api/v1/version/getVersion';
|
||||
import { AxiosError } from 'axios';
|
||||
import cx from 'classnames';
|
||||
import ChangelogModal from 'components/ChangelogModal/ChangelogModal';
|
||||
import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway';
|
||||
@@ -22,10 +24,12 @@ import { Events } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import SideNav from 'container/SideNav';
|
||||
import TopNav from 'container/TopNav';
|
||||
import dayjs from 'dayjs';
|
||||
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
@@ -68,8 +72,10 @@ import {
|
||||
LicensePlatform,
|
||||
LicenseState,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
import AppReducer from 'types/reducer/app';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
import { eventEmitter } from 'utils/getEventEmitter';
|
||||
import {
|
||||
getFormattedDate,
|
||||
@@ -662,10 +668,85 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
</div>
|
||||
);
|
||||
|
||||
const sideNavPinned = userPreferences?.find(
|
||||
const sideNavPinnedPreference = userPreferences?.find(
|
||||
(preference) => preference.name === USER_PREFERENCES.SIDENAV_PINNED,
|
||||
)?.value as boolean;
|
||||
|
||||
// Add loading state to prevent layout shift during initial load
|
||||
const [isSidebarLoaded, setIsSidebarLoaded] = useState(false);
|
||||
|
||||
// Get sidebar state from localStorage as fallback until preferences are loaded
|
||||
const getSidebarStateFromLocalStorage = useCallback((): boolean => {
|
||||
try {
|
||||
const storedValue = getLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED);
|
||||
return storedValue === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Set sidebar as loaded after user preferences are fetched
|
||||
useEffect(() => {
|
||||
if (userPreferences !== null) {
|
||||
setIsSidebarLoaded(true);
|
||||
}
|
||||
}, [userPreferences]);
|
||||
|
||||
// Use localStorage value as fallback until preferences are loaded
|
||||
const isSideNavPinned = isSidebarLoaded
|
||||
? sideNavPinnedPreference
|
||||
: getSidebarStateFromLocalStorage();
|
||||
|
||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||
const { updateUserPreferenceInContext } = useAppContext();
|
||||
|
||||
const { mutate: updateUserPreferenceMutation } = useMutation(
|
||||
updateUserPreference,
|
||||
{
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleToggleSidebar = useCallback((): void => {
|
||||
const newState = !isSideNavPinned;
|
||||
|
||||
logEvent('Global Shortcut: Sidebar Toggle', {
|
||||
previousState: isSideNavPinned,
|
||||
newState,
|
||||
});
|
||||
|
||||
// Save to localStorage immediately for instant feedback
|
||||
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, newState.toString());
|
||||
|
||||
// Update the context immediately
|
||||
const save = {
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
value: newState,
|
||||
};
|
||||
updateUserPreferenceInContext(save as UserPreference);
|
||||
|
||||
// Make the API call in the background
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
value: newState,
|
||||
});
|
||||
}, [
|
||||
isSideNavPinned,
|
||||
updateUserPreferenceInContext,
|
||||
updateUserPreferenceMutation,
|
||||
]);
|
||||
|
||||
// Register the sidebar toggle shortcut
|
||||
useEffect(() => {
|
||||
registerShortcut(GlobalShortcuts.ToggleSidebar, handleToggleSidebar);
|
||||
|
||||
return (): void => {
|
||||
deregisterShortcut(GlobalShortcuts.ToggleSidebar);
|
||||
};
|
||||
}, [registerShortcut, deregisterShortcut, handleToggleSidebar]);
|
||||
|
||||
const SHOW_TRIAL_EXPIRY_BANNER =
|
||||
showTrialExpiryBanner && !showPaymentFailedWarning;
|
||||
const SHOW_WORKSPACE_RESTRICTED_BANNER = showWorkspaceRestricted;
|
||||
@@ -739,14 +820,14 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
className={cx(
|
||||
'app-layout',
|
||||
isDarkMode ? 'darkMode dark' : 'lightMode',
|
||||
sideNavPinned ? 'side-nav-pinned' : '',
|
||||
isSideNavPinned ? 'side-nav-pinned' : '',
|
||||
SHOW_WORKSPACE_RESTRICTED_BANNER ? 'isWorkspaceRestricted' : '',
|
||||
SHOW_TRIAL_EXPIRY_BANNER ? 'isTrialExpired' : '',
|
||||
SHOW_PAYMENT_FAILED_BANNER ? 'isPaymentFailed' : '',
|
||||
)}
|
||||
>
|
||||
{isToDisplayLayout && !renderFullScreen && (
|
||||
<SideNav isPinned={sideNavPinned} />
|
||||
<SideNav isPinned={isSideNavPinned} />
|
||||
)}
|
||||
<div
|
||||
className={cx('app-content', {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryPromQLData,
|
||||
@@ -28,7 +28,7 @@ const defaultAnnotations = {
|
||||
|
||||
export const alertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V4,
|
||||
version: ENTITY_VERSION_V5,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
@@ -62,7 +62,7 @@ export const alertDefaults: AlertDef = {
|
||||
|
||||
export const anamolyAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.METRICS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V4,
|
||||
version: ENTITY_VERSION_V5,
|
||||
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
@@ -107,7 +107,7 @@ export const anamolyAlertDefaults: AlertDef = {
|
||||
|
||||
export const logAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.LOGS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V4,
|
||||
version: ENTITY_VERSION_V5,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
@@ -139,7 +139,7 @@ export const logAlertDefaults: AlertDef = {
|
||||
|
||||
export const traceAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.TRACES_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V4,
|
||||
version: ENTITY_VERSION_V5,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
@@ -171,7 +171,7 @@ export const traceAlertDefaults: AlertDef = {
|
||||
|
||||
export const exceptionAlertDefaults: AlertDef = {
|
||||
alertType: AlertTypes.EXCEPTIONS_BASED_ALERT,
|
||||
version: ENTITY_VERSION_V4,
|
||||
version: ENTITY_VERSION_V5,
|
||||
condition: {
|
||||
compositeQuery: {
|
||||
builderQueries: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Form, Row } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import FormAlertRules, { AlertDetectionTypes } from 'container/FormAlertRules';
|
||||
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
||||
@@ -71,14 +71,14 @@ function CreateRules(): JSX.Element {
|
||||
case AlertTypes.ANOMALY_BASED_ALERT:
|
||||
setInitValues({
|
||||
...anamolyAlertDefaults,
|
||||
version: version || ENTITY_VERSION_V4,
|
||||
version: version || ENTITY_VERSION_V5,
|
||||
ruleType: AlertDetectionTypes.ANOMALY_DETECTION_ALERT,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
setInitValues({
|
||||
...alertDefaults,
|
||||
version: version || ENTITY_VERSION_V4,
|
||||
version: version || ENTITY_VERSION_V5,
|
||||
ruleType: AlertDetectionTypes.THRESHOLD_ALERT,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ function ExplorerOptionWrapper({
|
||||
sourcepage,
|
||||
isOneChartPerQuery,
|
||||
splitedQueries,
|
||||
signalSource,
|
||||
}: ExplorerOptionsWrapperProps): JSX.Element {
|
||||
const [isExplorerOptionHidden, setIsExplorerOptionHidden] = useState(false);
|
||||
|
||||
@@ -32,6 +33,7 @@ function ExplorerOptionWrapper({
|
||||
isLoading={isLoading}
|
||||
onExport={onExport}
|
||||
sourcepage={sourcepage}
|
||||
signalSource={signalSource}
|
||||
isExplorerOptionHidden={isExplorerOptionHidden}
|
||||
setIsExplorerOptionHidden={setIsExplorerOptionHidden}
|
||||
isOneChartPerQuery={isOneChartPerQuery}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
.explorer-options-container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
bottom: 8px;
|
||||
left: calc(50% + 240px);
|
||||
transform: translate(calc(-50% - 120px), 0);
|
||||
transition: left 0.2s linear;
|
||||
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: 8px;
|
||||
background-color: transparent;
|
||||
|
||||
.multi-alert-button,
|
||||
@@ -33,11 +33,12 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 10px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
padding: 10px 12px;
|
||||
background: rgba(22, 24, 29, 0.6);
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(20px);
|
||||
box-sizing: border-box;
|
||||
|
||||
.action-icon {
|
||||
display: flex;
|
||||
@@ -64,9 +65,9 @@
|
||||
|
||||
.explorer-options {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
border-radius: 50px;
|
||||
background: rgba(22, 24, 29, 0.6);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: var(--bg-ink-400);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
cursor: default;
|
||||
|
||||
@@ -93,6 +93,7 @@ function ExplorerOptions({
|
||||
onExport,
|
||||
query,
|
||||
sourcepage,
|
||||
signalSource,
|
||||
isExplorerOptionHidden = false,
|
||||
setIsExplorerOptionHidden,
|
||||
isOneChartPerQuery = false,
|
||||
@@ -110,6 +111,7 @@ function ExplorerOptions({
|
||||
|
||||
const isLogsExplorer = sourcepage === DataSource.LOGS;
|
||||
const isMetricsExplorer = sourcepage === DataSource.METRICS;
|
||||
const isMeterExplorer = signalSource === 'meter';
|
||||
|
||||
const PRESERVED_VIEW_LOCAL_STORAGE_KEY = LOCALSTORAGE.LAST_USED_SAVED_VIEWS;
|
||||
|
||||
@@ -120,8 +122,11 @@ function ExplorerOptions({
|
||||
if (isMetricsExplorer) {
|
||||
return PreservedViewsTypes.METRICS;
|
||||
}
|
||||
if (isMeterExplorer) {
|
||||
return PreservedViewsTypes.METER;
|
||||
}
|
||||
return PreservedViewsTypes.TRACES;
|
||||
}, [isLogsExplorer, isMetricsExplorer]);
|
||||
}, [isLogsExplorer, isMetricsExplorer, isMeterExplorer]);
|
||||
|
||||
const onModalToggle = useCallback((value: boolean) => {
|
||||
setIsExport(value);
|
||||
@@ -150,6 +155,10 @@ function ExplorerOptions({
|
||||
[MetricsExplorerEventKeys.OneChartPerQueryEnabled]: isOneChartPerQuery,
|
||||
panelType,
|
||||
});
|
||||
} else if (isMeterExplorer) {
|
||||
logEvent('Meter Explorer: Save view clicked', {
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
setIsSaveModalOpen(!isSaveModalOpen);
|
||||
};
|
||||
@@ -243,7 +252,7 @@ function ExplorerOptions({
|
||||
error,
|
||||
isRefetching,
|
||||
refetch: refetchAllView,
|
||||
} = useGetAllViews(sourcepage);
|
||||
} = useGetAllViews(isMeterExplorer ? 'meter' : sourcepage);
|
||||
|
||||
const compositeQuery = mapCompositeQueryFromQuery(currentQuery, panelType);
|
||||
|
||||
@@ -316,7 +325,7 @@ function ExplorerOptions({
|
||||
compositeQuery,
|
||||
viewKey,
|
||||
extraData: updatedExtraData,
|
||||
sourcePage: sourcepage,
|
||||
sourcePage: isMeterExplorer ? 'meter' : sourcepage,
|
||||
viewName,
|
||||
});
|
||||
|
||||
@@ -332,7 +341,7 @@ function ExplorerOptions({
|
||||
compositeQuery: mapCompositeQueryFromQuery(currentQuery, panelType),
|
||||
viewKey,
|
||||
extraData: updatedExtraData,
|
||||
sourcePage: sourcepage,
|
||||
sourcePage: isMeterExplorer ? 'meter' : sourcepage,
|
||||
viewName,
|
||||
},
|
||||
{
|
||||
@@ -459,6 +468,11 @@ function ExplorerOptions({
|
||||
panelType,
|
||||
viewName: option?.value,
|
||||
});
|
||||
} else if (isMeterExplorer) {
|
||||
logEvent('Meter Explorer: Select view', {
|
||||
panelType,
|
||||
viewName: option?.value,
|
||||
});
|
||||
}
|
||||
|
||||
updatePreservedViewInLocalStorage(option);
|
||||
@@ -505,6 +519,11 @@ function ExplorerOptions({
|
||||
: defaultLogsSelectedColumns,
|
||||
});
|
||||
|
||||
if (signalSource === 'meter') {
|
||||
history.replace(ROUTES.METER_EXPLORER);
|
||||
return;
|
||||
}
|
||||
|
||||
history.replace(DATASOURCE_VS_ROUTES[sourcepage]);
|
||||
};
|
||||
|
||||
@@ -549,7 +568,7 @@ function ExplorerOptions({
|
||||
redirectWithQueryBuilderData,
|
||||
refetchAllView,
|
||||
saveViewAsync,
|
||||
sourcePage: sourcepage,
|
||||
sourcePage: isMeterExplorer ? 'meter' : sourcepage,
|
||||
viewName: newViewName,
|
||||
setNewViewName,
|
||||
});
|
||||
@@ -668,7 +687,7 @@ function ExplorerOptions({
|
||||
return `Query ${query.builder.queryData[0].queryName}`;
|
||||
};
|
||||
|
||||
const alertButton = useMemo(() => {
|
||||
const CreateAlertButton = useMemo(() => {
|
||||
if (isOneChartPerQuery) {
|
||||
const selectLabel = (
|
||||
<Button
|
||||
@@ -721,7 +740,7 @@ function ExplorerOptions({
|
||||
splitedQueries,
|
||||
]);
|
||||
|
||||
const dashboardButton = useMemo(() => {
|
||||
const AddToDashboardButton = useMemo(() => {
|
||||
if (isOneChartPerQuery) {
|
||||
const selectLabel = (
|
||||
<Button
|
||||
@@ -829,7 +848,7 @@ function ExplorerOptions({
|
||||
style={{
|
||||
background: extraData
|
||||
? `linear-gradient(90deg, rgba(0,0,0,0) -5%, ${rgbaColor} 9%, rgba(0,0,0,0) 30%)`
|
||||
: 'transparent',
|
||||
: 'initial',
|
||||
}}
|
||||
>
|
||||
<div className="view-options">
|
||||
@@ -884,10 +903,13 @@ function ExplorerOptions({
|
||||
|
||||
<hr className={isEditDeleteSupported ? '' : 'hidden'} />
|
||||
|
||||
<div className={cx('actions', isEditDeleteSupported ? '' : 'hidden')}>
|
||||
{alertButton}
|
||||
{dashboardButton}
|
||||
</div>
|
||||
{signalSource !== 'meter' && (
|
||||
<div className={cx('actions', isEditDeleteSupported ? '' : 'hidden')}>
|
||||
{CreateAlertButton}
|
||||
{AddToDashboardButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="actions">
|
||||
{/* Hide the info icon for metrics explorer until we get the docs link */}
|
||||
{!isMetricsExplorer && (
|
||||
@@ -993,6 +1015,7 @@ export interface ExplorerOptionsProps {
|
||||
query: Query | null;
|
||||
disabled: boolean;
|
||||
sourcepage: DataSource;
|
||||
signalSource?: string;
|
||||
isExplorerOptionHidden?: boolean;
|
||||
setIsExplorerOptionHidden?: Dispatch<SetStateAction<boolean>>;
|
||||
isOneChartPerQuery?: boolean;
|
||||
@@ -1005,6 +1028,7 @@ ExplorerOptions.defaultProps = {
|
||||
setIsExplorerOptionHidden: undefined,
|
||||
isOneChartPerQuery: false,
|
||||
splitedQueries: [],
|
||||
signalSource: '',
|
||||
};
|
||||
|
||||
export default ExplorerOptions;
|
||||
|
||||
@@ -2,4 +2,5 @@ export enum PreservedViewsTypes {
|
||||
LOGS = 'logs',
|
||||
TRACES = 'traces',
|
||||
METRICS = 'metrics',
|
||||
METER = 'meter',
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { PreservedViewsTypes } from './constants';
|
||||
export interface SaveNewViewHandlerProps {
|
||||
viewName: string;
|
||||
compositeQuery: ICompositeMetricQuery;
|
||||
sourcePage: DataSource;
|
||||
sourcePage: DataSource | 'meter';
|
||||
extraData: SaveViewProps['extraData'];
|
||||
panelType: PANEL_TYPES | null;
|
||||
notifications: NotificationInstance;
|
||||
@@ -32,7 +32,8 @@ export interface SaveNewViewHandlerProps {
|
||||
export type PreservedViewType =
|
||||
| PreservedViewsTypes.LOGS
|
||||
| PreservedViewsTypes.TRACES
|
||||
| PreservedViewsTypes.METRICS;
|
||||
| PreservedViewsTypes.METRICS
|
||||
| PreservedViewsTypes.METER;
|
||||
|
||||
export type PreservedViewsInLocalStorage = Partial<
|
||||
Record<PreservedViewType, { key: string; value: string }>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const saveNewViewHandler = ({
|
||||
{
|
||||
viewName,
|
||||
compositeQuery,
|
||||
sourcePage,
|
||||
sourcePage: sourcePage as DataSource,
|
||||
extraData,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
height: 57vh;
|
||||
width: 100%;
|
||||
|
||||
.chart-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.threshold-alert-uplot-chart-container {
|
||||
height: calc(100% - 24px);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import './ChartPreview.styles.scss';
|
||||
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import Spinner from 'components/Spinner';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import AnomalyAlertEvaluationView from 'container/AnomalyAlertEvaluationView';
|
||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||
import GridPanelSwitch from 'container/GridPanelSwitch';
|
||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||
import { getFormatNameByOptionId } from 'container/NewWidget/RightContainer/alertFomatCategories';
|
||||
@@ -26,6 +28,7 @@ import getTimeString from 'lib/getTimeString';
|
||||
import history from 'lib/history';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
@@ -34,7 +37,10 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
import { LegendPosition } from 'types/api/dashboard/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
@@ -44,7 +50,7 @@ import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import { AlertDetectionTypes } from '..';
|
||||
import { ChartContainer, FailedMessageContainer } from './styles';
|
||||
import { ChartContainer } from './styles';
|
||||
import { getThresholdLabel } from './utils';
|
||||
|
||||
export interface ChartPreviewProps {
|
||||
@@ -80,6 +86,7 @@ function ChartPreview({
|
||||
const threshold = alertDef?.condition.target || 0;
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
const [graphVisibility, setGraphVisibility] = useState<boolean[]>([]);
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
|
||||
@@ -171,6 +178,19 @@ function ChartPreview({
|
||||
setMaxTimeScale(endTime);
|
||||
}, [maxTime, minTime, globalSelectedInterval, queryResponse, setQueryStatus]);
|
||||
|
||||
// Initialize graph visibility from localStorage
|
||||
useEffect(() => {
|
||||
if (queryResponse?.data?.payload?.data?.result) {
|
||||
const {
|
||||
graphVisibilityStates: localStoredVisibilityState,
|
||||
} = getLocalStorageGraphVisibilityState({
|
||||
apiResponse: queryResponse.data.payload.data.result,
|
||||
name: 'alert-chart-preview',
|
||||
});
|
||||
setGraphVisibility(localStoredVisibilityState);
|
||||
}
|
||||
}, [queryResponse?.data?.payload?.data?.result]);
|
||||
|
||||
if (queryResponse.data && graphType === PANEL_TYPES.BAR) {
|
||||
const sortedSeriesData = getSortedSeriesData(
|
||||
queryResponse.data?.payload.data.result,
|
||||
@@ -257,6 +277,10 @@ function ChartPreview({
|
||||
timezone: timezone.value,
|
||||
currentQuery,
|
||||
query: query || currentQuery,
|
||||
graphsVisibilityStates: graphVisibility,
|
||||
setGraphsVisibilityStates: setGraphVisibility,
|
||||
enhancedLegend: true,
|
||||
legendPosition: LegendPosition.BOTTOM,
|
||||
}),
|
||||
[
|
||||
yAxisUnit,
|
||||
@@ -274,6 +298,7 @@ function ChartPreview({
|
||||
timezone.value,
|
||||
currentQuery,
|
||||
query,
|
||||
graphVisibility,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -289,20 +314,23 @@ function ChartPreview({
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.ANOMALY_DETECTION)
|
||||
?.active || false;
|
||||
|
||||
const isWarning = !isEmpty(queryResponse.data?.warning);
|
||||
return (
|
||||
<div className="alert-chart-container" ref={graphRef}>
|
||||
<ChartContainer>
|
||||
{headline}
|
||||
<div className="chart-preview-header">
|
||||
{headline}
|
||||
{isWarning && (
|
||||
<WarningPopover warningData={queryResponse.data?.warning as Warning} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="threshold-alert-uplot-chart-container">
|
||||
{queryResponse.isLoading && (
|
||||
<Spinner size="large" tip="Loading..." height="100%" />
|
||||
)}
|
||||
{(queryResponse?.isError || queryResponse?.error) && (
|
||||
<FailedMessageContainer color="red" title="Failed to refresh the chart">
|
||||
<InfoCircleOutlined />
|
||||
{queryResponse.error.message || t('preview_chart_unexpected_error')}
|
||||
</FailedMessageContainer>
|
||||
<ErrorInPlace error={queryResponse.error as APIError} />
|
||||
)}
|
||||
|
||||
{chartDataAvailable && !isAnomalyDetectionAlert && (
|
||||
|
||||
@@ -37,11 +37,8 @@ import {
|
||||
defaultEvalWindow,
|
||||
defaultMatchType,
|
||||
} from 'types/api/alerts/def';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
QueryFunctionProps,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryFunction } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
@@ -182,12 +179,17 @@ function FormAlertRules({
|
||||
setDetectionMethod(value);
|
||||
};
|
||||
|
||||
const updateFunctions = (data: IBuilderQuery): QueryFunctionProps[] => {
|
||||
const anomalyFunction = {
|
||||
name: 'anomaly',
|
||||
args: [],
|
||||
namedArgs: { z_score_threshold: alertDef.condition.target || 3 },
|
||||
const updateFunctions = (data: IBuilderQuery): QueryFunction[] => {
|
||||
const anomalyFunction: QueryFunction = {
|
||||
name: 'anomaly' as any,
|
||||
args: [
|
||||
{
|
||||
name: 'z_score_threshold',
|
||||
value: alertDef.condition.target || 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const functions = data.functions || [];
|
||||
|
||||
if (alertDef.ruleType === AlertDetectionTypes.ANOMALY_DETECTION_ALERT) {
|
||||
@@ -238,8 +240,18 @@ function FormAlertRules({
|
||||
const queryData = currentQuery.builder.queryData[index];
|
||||
|
||||
const updatedFunctions = updateFunctions(queryData);
|
||||
queryData.functions = updatedFunctions;
|
||||
handleSetQueryData(index, queryData);
|
||||
|
||||
// Only update if functions actually changed to avoid resetting aggregateAttribute
|
||||
const currentFunctions = queryData.functions || [];
|
||||
const functionsChanged = !isEqual(currentFunctions, updatedFunctions);
|
||||
|
||||
if (functionsChanged) {
|
||||
const updatedQueryData = {
|
||||
...queryData,
|
||||
functions: updatedFunctions,
|
||||
};
|
||||
handleSetQueryData(index, updatedQueryData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { Props } from 'types/api/dashboard/update';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
@@ -45,7 +46,6 @@ import { getLocalStorageGraphVisibilityState, handleGraphClick } from './utils';
|
||||
function WidgetGraphComponent({
|
||||
widget,
|
||||
queryResponse,
|
||||
errorMessage,
|
||||
version,
|
||||
threshold,
|
||||
headerMenuList,
|
||||
@@ -184,9 +184,19 @@ function WidgetGraphComponent({
|
||||
notifications.success({
|
||||
message: 'Panel cloned successfully, redirecting to new copy.',
|
||||
});
|
||||
|
||||
const clonedWidget = updatedDashboard.data?.data?.widgets?.find(
|
||||
(w) => w.id === uuid,
|
||||
) as Widgets;
|
||||
|
||||
const queryParams = {
|
||||
graphType: widget?.panelTypes,
|
||||
widgetId: uuid,
|
||||
[QueryParams.graphType]: clonedWidget?.panelTypes,
|
||||
[QueryParams.widgetId]: uuid,
|
||||
...(clonedWidget?.query && {
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(
|
||||
JSON.stringify(clonedWidget.query),
|
||||
),
|
||||
}),
|
||||
};
|
||||
safeNavigate(`${pathname}/new?${createQueryParams(queryParams)}`);
|
||||
},
|
||||
@@ -366,7 +376,6 @@ function WidgetGraphComponent({
|
||||
onDelete={handleOnDelete}
|
||||
onClone={onCloneHandler}
|
||||
queryResponse={queryResponse}
|
||||
errorMessage={errorMessage}
|
||||
threshold={threshold}
|
||||
headerMenuList={headerMenuList}
|
||||
isWarning={isWarning}
|
||||
|
||||
@@ -267,7 +267,6 @@ function GridCardGraph({
|
||||
getGraphData?.(data?.payload?.data);
|
||||
setDashboardQueryRangeCalled(true);
|
||||
},
|
||||
showErrorModal: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -302,7 +301,7 @@ function GridCardGraph({
|
||||
widget={widget}
|
||||
queryResponse={queryResponse}
|
||||
errorMessage={errorMessage}
|
||||
isWarning={false}
|
||||
isWarning={!isEmpty(queryResponse.data?.warning)}
|
||||
version={version}
|
||||
threshold={threshold}
|
||||
headerMenuList={menuList}
|
||||
|
||||
@@ -235,6 +235,7 @@ export const handleGraphClick = async ({
|
||||
? customTracesTimeRange?.end
|
||||
: xValue + (stepInterval ?? 60),
|
||||
shouldResolveQuery: true,
|
||||
widgetQuery: widget?.query,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
InfoCircleOutlined,
|
||||
MoreOutlined,
|
||||
SearchOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Dropdown, Input, MenuProps, Tooltip, Typography } from 'antd';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import ErrorPopover from 'components/ErrorPopover/ErrorPopover';
|
||||
import Spinner from 'components/Spinner';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import useGetResolvedText from 'hooks/dashboard/useGetResolvedText';
|
||||
@@ -28,11 +31,12 @@ import { unparse } from 'papaparse';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { SuccessResponse, Warning } from 'types/api';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import { errorTooltipPosition, WARNING_MESSAGE } from './config';
|
||||
import { errorTooltipPosition } from './config';
|
||||
import { MENUITEM_KEYS_VS_LABELS, MenuItemKeys } from './contants';
|
||||
import { MenuItem } from './types';
|
||||
import { generateMenuList, isTWidgetOptions } from './utils';
|
||||
@@ -45,9 +49,11 @@ interface IWidgetHeaderProps {
|
||||
onClone?: VoidFunction;
|
||||
parentHover: boolean;
|
||||
queryResponse: UseQueryResult<
|
||||
SuccessResponse<MetricRangePayloadProps> | ErrorResponse
|
||||
SuccessResponse<MetricRangePayloadProps, unknown> & {
|
||||
warning?: Warning;
|
||||
},
|
||||
Error
|
||||
>;
|
||||
errorMessage: string | undefined;
|
||||
threshold?: ReactNode;
|
||||
headerMenuList?: MenuItemKeys[];
|
||||
isWarning: boolean;
|
||||
@@ -64,7 +70,6 @@ function WidgetHeader({
|
||||
onClone,
|
||||
parentHover,
|
||||
queryResponse,
|
||||
errorMessage,
|
||||
threshold,
|
||||
headerMenuList,
|
||||
isWarning,
|
||||
@@ -212,12 +217,8 @@ function WidgetHeader({
|
||||
});
|
||||
|
||||
const renderErrorMessage = useMemo(
|
||||
() =>
|
||||
errorMessage
|
||||
?.split('\n')
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
.map((item, i) => <p key={i}>{item}</p>),
|
||||
[errorMessage],
|
||||
() => <ErrorContent error={queryResponse.error as APIError} />,
|
||||
[queryResponse.error],
|
||||
);
|
||||
|
||||
if (widget.id === PANEL_TYPES.EMPTY_WIDGET) {
|
||||
@@ -278,23 +279,23 @@ function WidgetHeader({
|
||||
<Spinner style={{ paddingRight: '0.25rem' }} />
|
||||
)}
|
||||
{queryResponse.isError && (
|
||||
<Tooltip
|
||||
title={renderErrorMessage}
|
||||
<ErrorPopover
|
||||
content={renderErrorMessage}
|
||||
placement={errorTooltipPosition}
|
||||
className="widget-api-actions"
|
||||
overlayStyle={{ padding: 0, maxWidth: '600px' }}
|
||||
overlayInnerStyle={{ padding: 0 }}
|
||||
autoAdjustOverflow
|
||||
>
|
||||
<CircleX size={20} />
|
||||
</Tooltip>
|
||||
<CircleX
|
||||
size={16}
|
||||
style={{ cursor: 'pointer' }}
|
||||
color={Color.BG_CHERRY_500}
|
||||
/>
|
||||
</ErrorPopover>
|
||||
)}
|
||||
|
||||
{isWarning && (
|
||||
<Tooltip
|
||||
title={WARNING_MESSAGE}
|
||||
placement={errorTooltipPosition}
|
||||
className="widget-api-actions"
|
||||
>
|
||||
<WarningOutlined />
|
||||
</Tooltip>
|
||||
{isWarning && queryResponse.data?.warning && (
|
||||
<WarningPopover warningData={queryResponse.data?.warning as Warning} />
|
||||
)}
|
||||
{globalSearchAvailable && (
|
||||
<SearchOutlined
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getQueryRangeFormat } from 'api/dashboard/queryRangeFormat';
|
||||
import { getSubstituteVars } from 'api/dashboard/substitute_vars';
|
||||
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
|
||||
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
||||
import { prepareQueryRangePayload } from 'lib/dashboard/prepareQueryRangePayload';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
@@ -32,7 +32,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
const queryRangeMutation = useMutation(getQueryRangeFormat);
|
||||
const queryRangeMutation = useMutation(getSubstituteVars);
|
||||
|
||||
const getUpdatedQuery = useCallback(
|
||||
async ({
|
||||
@@ -40,7 +40,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
|
||||
selectedDashboard,
|
||||
}: UseUpdatedQueryOptions): Promise<Query> => {
|
||||
// Prepare query payload with resolved variables
|
||||
const { queryPayload } = prepareQueryRangePayload({
|
||||
const { queryPayload } = prepareQueryRangePayloadV5({
|
||||
query: widgetConfig.query,
|
||||
graphType: getGraphType(widgetConfig.panelTypes),
|
||||
selectedTime: widgetConfig.timePreferance,
|
||||
@@ -53,7 +53,10 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
|
||||
const queryResult = await queryRangeMutation.mutateAsync(queryPayload);
|
||||
|
||||
// Map query data from API response
|
||||
return mapQueryDataFromApi(queryResult.compositeQuery, widgetConfig?.query);
|
||||
return mapQueryDataFromApi(
|
||||
queryResult.data.compositeQuery,
|
||||
widgetConfig?.query,
|
||||
);
|
||||
},
|
||||
[globalSelectedInterval, queryRangeMutation],
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ColumnType } from 'antd/es/table';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { Events } from 'constants/events';
|
||||
import { QueryTable } from 'container/QueryTable';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { getColumnUnit, RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { cloneDeep, get, isEmpty } from 'lodash-es';
|
||||
import { Compass } from 'lucide-react';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
@@ -84,10 +84,11 @@ function GridTableComponent({
|
||||
(val): RowData => {
|
||||
const newValue = { ...val };
|
||||
Object.keys(val).forEach((k) => {
|
||||
if (columnUnits[k]) {
|
||||
const unit = getColumnUnit(k, columnUnits);
|
||||
if (unit) {
|
||||
// the check below takes care of not adding units for rows that have n/a or null values
|
||||
if (val[k] !== 'n/a' && val[k] !== null) {
|
||||
newValue[k] = getYAxisFormattedValue(String(val[k]), columnUnits[k]);
|
||||
newValue[k] = getYAxisFormattedValue(String(val[k]), unit);
|
||||
} else if (val[k] === null) {
|
||||
newValue[k] = 'n/a';
|
||||
}
|
||||
@@ -121,7 +122,8 @@ function GridTableComponent({
|
||||
render: (text: string, ...rest: any): ReactNode => {
|
||||
let textForThreshold = text;
|
||||
const dataIndex = (e as ColumnType<RowData>)?.dataIndex || e.title;
|
||||
if (columnUnits && columnUnits?.[dataIndex as string]) {
|
||||
const unit = getColumnUnit(dataIndex as string, columnUnits || {});
|
||||
if (unit) {
|
||||
textForThreshold = rest[0][`${dataIndex}_without_unit`];
|
||||
}
|
||||
const isNumber = !Number.isNaN(Number(textForThreshold));
|
||||
@@ -131,7 +133,7 @@ function GridTableComponent({
|
||||
thresholds,
|
||||
dataIndex as string,
|
||||
Number(textForThreshold),
|
||||
columnUnits?.[dataIndex as string],
|
||||
unit,
|
||||
);
|
||||
|
||||
const idx = thresholds.findIndex(
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { orange } from '@ant-design/colors';
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import { Dropdown, MenuProps } from 'antd';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import {
|
||||
negateOperator,
|
||||
OPERATORS,
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { useActiveLog } from 'hooks/logs/useActiveLog';
|
||||
|
||||
import { TitleWrapper } from './BodyTitleRenderer.styles';
|
||||
@@ -29,7 +33,9 @@ function BodyTitleRenderer({
|
||||
getDataTypes(value),
|
||||
),
|
||||
`${value}`,
|
||||
isFilterIn ? OPERATORS.HAS : OPERATORS.NHAS,
|
||||
isFilterIn
|
||||
? QUERY_BUILDER_FUNCTIONS.HAS
|
||||
: negateOperator(QUERY_BUILDER_FUNCTIONS.HAS),
|
||||
true,
|
||||
parentIsArray ? getDataTypes([value]) : getDataTypes(value),
|
||||
);
|
||||
|
||||
@@ -73,6 +73,8 @@ export default function TableRow({
|
||||
{tableColumns.map((column) => {
|
||||
if (!column.render) return <td>Empty</td>;
|
||||
|
||||
if (!column.key) return null;
|
||||
|
||||
const element: ColumnTypeRender<Record<string, unknown>> = column.render(
|
||||
log[column.key as keyof Record<string, unknown>],
|
||||
log,
|
||||
@@ -97,6 +99,7 @@ export default function TableRow({
|
||||
fontSize={fontSize}
|
||||
columnKey={column.key as string}
|
||||
onClick={handleShowLogDetails}
|
||||
className={column.key as string}
|
||||
>
|
||||
{cloneElement(children, props)}
|
||||
</TableCellStyled>
|
||||
|
||||
@@ -136,7 +136,7 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
|
||||
key={column.key}
|
||||
fontSize={tableViewProps?.fontSize}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...(isDragColumn && { className: 'dragHandler' })}
|
||||
{...(isDragColumn && { className: `dragHandler ${column.key}` })}
|
||||
columnKey={column.key as string}
|
||||
>
|
||||
{(column.title as string).replace(/^\w/, (c) => c.toUpperCase())}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import APIError from 'types/api/error';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -8,5 +9,6 @@ export type LogsExplorerListProps = {
|
||||
logs: ILog[];
|
||||
onEndReached: (index: number) => void;
|
||||
isError: boolean;
|
||||
error?: Error | APIError;
|
||||
isFilterApplied: boolean;
|
||||
};
|
||||
|
||||
@@ -141,6 +141,7 @@ describe('LogsExplorerList - empty states', () => {
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
setWarning={(): void => {}}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</QueryBuilderContext.Provider>,
|
||||
@@ -205,6 +206,7 @@ describe('LogsExplorerList - empty states', () => {
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
setWarning={(): void => {}}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</QueryBuilderContext.Provider>,
|
||||
|
||||
@@ -2,6 +2,7 @@ import './LogsExplorerList.style.scss';
|
||||
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES } from 'components/LogDetail/constants';
|
||||
// components
|
||||
@@ -12,7 +13,6 @@ import Spinner from 'components/Spinner';
|
||||
import { CARD_BODY_STYLE } from 'constants/card';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import LogsError from 'container/LogsError/LogsError';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
@@ -21,6 +21,7 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import APIError from 'types/api/error';
|
||||
// interfaces
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
@@ -45,6 +46,7 @@ function LogsExplorerList({
|
||||
logs,
|
||||
onEndReached,
|
||||
isError,
|
||||
error,
|
||||
isFilterApplied,
|
||||
}: LogsExplorerListProps): JSX.Element {
|
||||
const ref = useRef<VirtuosoHandle>(null);
|
||||
@@ -256,7 +258,9 @@ function LogsExplorerList({
|
||||
/>
|
||||
)}
|
||||
|
||||
{isError && !isLoading && !isFetching && <LogsError />}
|
||||
{isError && !isLoading && !isFetching && error && (
|
||||
<ErrorInPlace error={error as APIError} />
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && logs.length > 0 && (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
@@ -8,11 +9,13 @@ import {
|
||||
export const convertKeysToColumnFields = (
|
||||
keys: TelemetryFieldKey[],
|
||||
): IField[] =>
|
||||
keys.map((item) => ({
|
||||
dataType: item.fieldDataType ?? '',
|
||||
name: item.name,
|
||||
type: item.fieldContext ?? '',
|
||||
}));
|
||||
keys
|
||||
.filter((item) => !isEmpty(item.name))
|
||||
.map((item) => ({
|
||||
dataType: item.fieldDataType ?? '',
|
||||
name: item.name,
|
||||
type: item.fieldContext ?? '',
|
||||
}));
|
||||
/**
|
||||
* Determines if a query represents a trace-to-logs navigation
|
||||
* by checking for the presence of a trace_id filter.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import APIError from 'types/api/error';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
export type LogsExplorerTableProps = {
|
||||
data: QueryDataV3[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error?: Error | APIError;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import './LogsExplorerTable.styles.scss';
|
||||
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import LogsError from 'container/LogsError/LogsError';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { QueryTable } from 'container/QueryTable';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { memo } from 'react';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { LogsExplorerTableProps } from './LogsExplorerTable.interfaces';
|
||||
|
||||
@@ -13,6 +14,7 @@ function LogsExplorerTable({
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
}: LogsExplorerTableProps): JSX.Element {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
|
||||
@@ -20,8 +22,8 @@ function LogsExplorerTable({
|
||||
return <LogsLoading />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <LogsError />;
|
||||
if (isError && error) {
|
||||
return <ErrorInPlace error={error as APIError} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -51,8 +51,10 @@ import { ArrowUp10, Minus, Sliders } from 'lucide-react';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -62,7 +64,9 @@ import {
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import APIError from 'types/api/error';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
@@ -87,6 +91,7 @@ function LogsExplorerViewsContainer({
|
||||
setIsLoadingQueries,
|
||||
listQueryKeyRef,
|
||||
chartQueryKeyRef,
|
||||
setWarning,
|
||||
}: {
|
||||
selectedView: ExplorerViews;
|
||||
setIsLoadingQueries: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -94,6 +99,7 @@ function LogsExplorerViewsContainer({
|
||||
listQueryKeyRef: MutableRefObject<any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
chartQueryKeyRef: MutableRefObject<any>;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
}): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const dispatch = useDispatch();
|
||||
@@ -269,6 +275,7 @@ function LogsExplorerViewsContainer({
|
||||
isFetching,
|
||||
isError,
|
||||
isSuccess,
|
||||
error,
|
||||
} = useGetExplorerQueryRange(
|
||||
requestData,
|
||||
panelType,
|
||||
@@ -372,6 +379,13 @@ function LogsExplorerViewsContainer({
|
||||
[activeLogId, orderBy, listQuery, selectedView],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (!listQuery) return;
|
||||
|
||||
@@ -771,6 +785,7 @@ function LogsExplorerViewsContainer({
|
||||
logs={logs}
|
||||
onEndReached={handleEndReached}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
|
||||
/>
|
||||
)}
|
||||
@@ -780,8 +795,10 @@ function LogsExplorerViewsContainer({
|
||||
isLoading={isLoading || isFetching}
|
||||
data={data}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
|
||||
dataSource={DataSource.LOGS}
|
||||
setWarning={setWarning}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -794,6 +811,7 @@ function LogsExplorerViewsContainer({
|
||||
}
|
||||
isLoading={isLoading || isFetching}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -82,6 +82,48 @@ jest.mock('hooks/queryBuilder/useGetExplorerQueryRange', () => ({
|
||||
useGetExplorerQueryRange: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock ErrorStateComponent to handle APIError properly
|
||||
jest.mock(
|
||||
'components/Common/ErrorStateComponent',
|
||||
() =>
|
||||
function MockErrorStateComponent({ error, message }: any): JSX.Element {
|
||||
if (error) {
|
||||
// Mock the getErrorMessage and getErrorDetails methods
|
||||
const getErrorMessage = jest
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
error.error?.message ||
|
||||
'Something went wrong. Please try again or contact support.',
|
||||
);
|
||||
const getErrorDetails = jest.fn().mockReturnValue(error);
|
||||
|
||||
// Add the methods to the error object
|
||||
const errorWithMethods = {
|
||||
...error,
|
||||
getErrorMessage,
|
||||
getErrorDetails,
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="error-state-component">
|
||||
<div>{errorWithMethods.getErrorMessage()}</div>
|
||||
{errorWithMethods.getErrorDetails().error?.errors?.map((err: any) => (
|
||||
<div key={`error-${err.message}`}>• {err.message}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="error-state-component">
|
||||
<div>
|
||||
{message || 'Something went wrong. Please try again or contact support.'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: jest.fn(),
|
||||
@@ -131,6 +173,7 @@ const renderer = (): RenderResult =>
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
setWarning={(): void => {}}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
@@ -172,21 +215,6 @@ describe('LogsExplorerViews -', () => {
|
||||
expect(queryByText('pending_data_placeholder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check error state', async () => {
|
||||
lodsQueryServerRequest();
|
||||
(useGetExplorerQueryRange as jest.Mock).mockReturnValue({
|
||||
data: { payload: logsQueryRangeSuccessNewFormatResponse },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: true,
|
||||
});
|
||||
const { queryByText } = renderer();
|
||||
|
||||
expect(
|
||||
queryByText('Something went wrong. Please try again or contact support.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should add activeLogId filter when present in URL', async () => {
|
||||
// Mock useCopyLogLink to return an activeLogId
|
||||
(useCopyLogLink as jest.Mock).mockReturnValue({
|
||||
@@ -206,6 +234,7 @@ describe('LogsExplorerViews -', () => {
|
||||
setIsLoadingQueries={(): void => {}}
|
||||
listQueryKeyRef={{ current: {} }}
|
||||
chartQueryKeyRef={{ current: {} }}
|
||||
setWarning={(): void => {}}
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
</QueryBuilderContext.Provider>,
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
.meter-explorer-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.meter-explorer-quick-filters-section {
|
||||
width: 280px;
|
||||
border-right: 1px solid var(--bg-slate-500);
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-explorer-content-section {
|
||||
width: 100%;
|
||||
|
||||
.explore-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 4px 0;
|
||||
padding: 0 8px;
|
||||
|
||||
.explore-header-left-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.explore-header-right-actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.query-section {
|
||||
max-height: 450px;
|
||||
overflow-y: auto;
|
||||
|
||||
.rc-virtual-list-holder {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.explore-tabs {
|
||||
margin: 15px 0;
|
||||
.tab {
|
||||
background-color: var(--bg-slate-500);
|
||||
border-color: var(--bg-ink-200);
|
||||
width: 180px;
|
||||
padding: 16px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab:first-of-type {
|
||||
border-top-left-radius: 2px;
|
||||
}
|
||||
|
||||
.tab:last-of-type {
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
|
||||
.selected-view {
|
||||
background: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
.explore-content {
|
||||
.ant-space {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-meter-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.time-series-view-panel {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: var(--bg-ink-400);
|
||||
padding: 8px !important;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.time-series-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(min(100%, calc(50% - 8px)), 1fr)
|
||||
);
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.quick-filters-open {
|
||||
.meter-explorer-content-section {
|
||||
width: calc(100% - 280px);
|
||||
}
|
||||
}
|
||||
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.meter-time-series-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.builder-units-filter {
|
||||
padding: 0 8px;
|
||||
margin-bottom: 0px !important;
|
||||
|
||||
.builder-units-filter-label {
|
||||
margin-bottom: 0px !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.meter-explorer-container {
|
||||
.explore-tabs {
|
||||
.tab {
|
||||
background-color: var(--bg-vanilla-100);
|
||||
border-color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
.selected-view {
|
||||
background: var(--bg-vanilla-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboards-and-alerts-popover-container {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
.dashboards-and-alerts-popover {
|
||||
border-radius: 20px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboards-popover {
|
||||
border: 1px solid var(--bg-sienna-500);
|
||||
.ant-typography {
|
||||
color: var(--bg-sienna-500);
|
||||
}
|
||||
}
|
||||
|
||||
.alerts-popover {
|
||||
border: 1px solid var(--bg-sakura-500);
|
||||
.ant-typography {
|
||||
color: var(--bg-sakura-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-data-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
|
||||
.no-data-text {
|
||||
color: var(--text-vanilla-500);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
182
frontend/src/container/MeterExplorer/Explorer/Explorer.tsx
Normal file
182
frontend/src/container/MeterExplorer/Explorer/Explorer.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Filter } from 'lucide-react';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { MeterExplorerEventKeys, MeterExplorerEvents } from '../events';
|
||||
import TimeSeries from './TimeSeries';
|
||||
import { splitQueryIntoOneChartPerQuery } from './utils';
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
const {
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
updateAllQueriesOperators,
|
||||
currentQuery,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const [showQuickFilters, setShowQuickFilters] = useState(true);
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueryMeterWithType,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
DataSource.METRICS,
|
||||
'meter' as 'meter' | '',
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
currentQuery || initialQueryMeterWithType,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
DataSource.METRICS,
|
||||
'meter' as 'meter' | '',
|
||||
),
|
||||
[currentQuery, updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
|
||||
const handleExport = useCallback(
|
||||
(
|
||||
dashboard: Dashboard | null,
|
||||
_isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
): void => {
|
||||
if (!dashboard) return;
|
||||
|
||||
const widgetId = uuid();
|
||||
|
||||
const dashboardEditView = generateExportToDashboardLink({
|
||||
query: queryToExport || exportDefaultQuery,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId,
|
||||
});
|
||||
|
||||
safeNavigate(dashboardEditView);
|
||||
},
|
||||
[exportDefaultQuery, safeNavigate],
|
||||
);
|
||||
|
||||
const splitedQueries = useMemo(
|
||||
() =>
|
||||
splitQueryIntoOneChartPerQuery(stagedQuery || initialQueryMeterWithType),
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(MeterExplorerEvents.TabChanged, {
|
||||
[MeterExplorerEventKeys.Tab]: 'explorer',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const queryComponents = useMemo(
|
||||
(): QueryBuilderProps['queryComponents'] => ({}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
className={cx('meter-explorer-container', {
|
||||
'quick-filters-open': showQuickFilters,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cx('meter-explorer-quick-filters-section', {
|
||||
hidden: !showQuickFilters,
|
||||
})}
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-meter-explorer"
|
||||
source={QuickFiltersSource.METER_EXPLORER}
|
||||
signal={SignalType.METER_EXPLORER}
|
||||
showFilterCollapse
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="meter-explorer-content-section">
|
||||
<div className="meter-explorer-explore-content">
|
||||
<div className="explore-header">
|
||||
<div className="explore-header-left-actions">
|
||||
{!showQuickFilters && (
|
||||
<Tooltip title="Show Quick Filters" placement="right" arrow={false}>
|
||||
<Button
|
||||
className="periscope-btn outline"
|
||||
icon={<Filter size={16} />}
|
||||
onClick={(): void => setShowQuickFilters(!showQuickFilters)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="explore-header-right-actions">
|
||||
<DateTimeSelector showAutoRefresh />
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => handleRunQuery(true, true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<QueryBuilderV2
|
||||
config={{
|
||||
initialDataSource: DataSource.METRICS,
|
||||
queryVariant: 'static',
|
||||
signalSource: 'meter',
|
||||
}}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
queryComponents={queryComponents}
|
||||
showFunctions={false}
|
||||
version="v3"
|
||||
/>
|
||||
|
||||
<div className="explore-content">
|
||||
<TimeSeries />
|
||||
</div>
|
||||
</div>
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.METRICS}
|
||||
signalSource="meter"
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={false}
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default Explorer;
|
||||
13
frontend/src/container/MeterExplorer/Explorer/NoData.tsx
Normal file
13
frontend/src/container/MeterExplorer/Explorer/NoData.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Typography } from 'antd';
|
||||
import { ChartLine } from 'lucide-react';
|
||||
|
||||
export default function NoData(): JSX.Element {
|
||||
return (
|
||||
<div className="no-data-container">
|
||||
<ChartLine size={48} />
|
||||
<Typography.Text className="no-data-text">
|
||||
No data found for the selected query
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Button } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QueryBuilder } from 'container/QueryBuilder';
|
||||
import { ButtonWrapper } from 'container/TracesExplorer/QuerySection/styles';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { MeterExplorerEventKeys, MeterExplorerEvents } from '../events';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
return (
|
||||
<div className="query-section">
|
||||
<QueryBuilder
|
||||
panelType={panelTypes}
|
||||
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
|
||||
version="v4"
|
||||
actions={
|
||||
<ButtonWrapper>
|
||||
<Button
|
||||
onClick={(): void => {
|
||||
handleRunQuery();
|
||||
logEvent(MeterExplorerEvents.QueryBuilderQueryChanged, {
|
||||
[MeterExplorerEventKeys.Tab]: 'explorer',
|
||||
});
|
||||
}}
|
||||
type="primary"
|
||||
>
|
||||
Run Query
|
||||
</Button>
|
||||
</ButtonWrapper>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuerySection;
|
||||
142
frontend/src/container/MeterExplorer/Explorer/TimeSeries.tsx
Normal file
142
frontend/src/container/MeterExplorer/Explorer/TimeSeries.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { isAxiosError } from 'axios';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
function TimeSeries(): JSX.Element {
|
||||
const { stagedQuery, currentQuery } = useQueryBuilder();
|
||||
|
||||
const { selectedTime: globalSelectedTime, maxTime, minTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
const [yAxisUnit, setYAxisUnit] = useState<string>('');
|
||||
|
||||
const isValidToConvertToMs = useMemo(() => {
|
||||
const isValid: boolean[] = [];
|
||||
|
||||
currentQuery.builder.queryData.forEach(
|
||||
({ aggregateAttribute, aggregateOperator }) => {
|
||||
const isExistDurationNanoAttribute =
|
||||
aggregateAttribute?.key === 'durationNano' ||
|
||||
aggregateAttribute?.key === 'duration_nano';
|
||||
|
||||
const isCountOperator =
|
||||
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
|
||||
|
||||
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
|
||||
},
|
||||
);
|
||||
|
||||
return isValid.every(Boolean);
|
||||
}, [currentQuery]);
|
||||
|
||||
const queryPayloads = useMemo(
|
||||
() => [stagedQuery || initialQueryMeterWithType],
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const queries = useQueries(
|
||||
queryPayloads.map((payload, index) => ({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
payload,
|
||||
ENTITY_VERSION_V5,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
index,
|
||||
],
|
||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(
|
||||
{
|
||||
query: payload,
|
||||
graphType: PANEL_TYPES.TIME_SERIES,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: DataSource.METRICS,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
),
|
||||
enabled: !!payload,
|
||||
retry: (failureCount: number, error: Error): boolean => {
|
||||
let status: number | undefined;
|
||||
|
||||
if (error instanceof APIError) {
|
||||
status = error.getHttpStatusCode();
|
||||
} else if (isAxiosError(error)) {
|
||||
status = error.response?.status;
|
||||
}
|
||||
|
||||
if (status && status >= 400 && status < 500) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return failureCount < 3;
|
||||
},
|
||||
onError: (error: APIError): void => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
const data = useMemo(() => queries.map(({ data }) => data) ?? [], [queries]);
|
||||
|
||||
const responseData = useMemo(
|
||||
() =>
|
||||
data.map((datapoint) =>
|
||||
isValidToConvertToMs ? convertDataValueToMs(datapoint) : datapoint,
|
||||
),
|
||||
[data, isValidToConvertToMs],
|
||||
);
|
||||
|
||||
const onUnitChangeHandler = (value: string): void => {
|
||||
setYAxisUnit(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="meter-time-series-container">
|
||||
<BuilderUnitsFilter onChange={onUnitChangeHandler} yAxisUnit={yAxisUnit} />
|
||||
<div className="time-series-container">
|
||||
{responseData.map((datapoint, index) => (
|
||||
<div
|
||||
className="time-series-view-panel"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
>
|
||||
<TimeSeriesView
|
||||
isFilterApplied={false}
|
||||
isError={queries[index].isError}
|
||||
isLoading={queries[index].isLoading}
|
||||
data={datapoint}
|
||||
dataSource={DataSource.METRICS}
|
||||
yAxisUnit={yAxisUnit}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimeSeries;
|
||||
3
frontend/src/container/MeterExplorer/Explorer/index.ts
Normal file
3
frontend/src/container/MeterExplorer/Explorer/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Explorer from './Explorer';
|
||||
|
||||
export default Explorer;
|
||||
37
frontend/src/container/MeterExplorer/Explorer/types.ts
Normal file
37
frontend/src/container/MeterExplorer/Explorer/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { RelatedMetric } from 'api/metricsExplorer/getRelatedMetrics';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
export enum ExplorerTabs {
|
||||
TIME_SERIES = 'time-series',
|
||||
RELATED_METRICS = 'related-metrics',
|
||||
}
|
||||
|
||||
export interface TimeSeriesProps {
|
||||
showOneChartPerQuery: boolean;
|
||||
}
|
||||
|
||||
export interface RelatedMetricsProps {
|
||||
metricNames: string[];
|
||||
}
|
||||
|
||||
export interface RelatedMetricsCardProps {
|
||||
metric: RelatedMetricWithQueryResult;
|
||||
}
|
||||
|
||||
export interface UseGetRelatedMetricsGraphsProps {
|
||||
selectedMetricName: string | null;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
export interface UseGetRelatedMetricsGraphsReturn {
|
||||
relatedMetrics: RelatedMetricWithQueryResult[];
|
||||
isRelatedMetricsLoading: boolean;
|
||||
isRelatedMetricsError: boolean;
|
||||
}
|
||||
|
||||
export interface RelatedMetricWithQueryResult extends RelatedMetric {
|
||||
queryResult: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>;
|
||||
}
|
||||
37
frontend/src/container/MeterExplorer/Explorer/utils.tsx
Normal file
37
frontend/src/container/MeterExplorer/Explorer/utils.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export const splitQueryIntoOneChartPerQuery = (query: Query): Query[] => {
|
||||
const queries: Query[] = [];
|
||||
|
||||
query.builder.queryData.forEach((currentQuery) => {
|
||||
const newQuery = {
|
||||
...query,
|
||||
id: uuid(),
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: [currentQuery],
|
||||
queryFormulas: [],
|
||||
},
|
||||
};
|
||||
queries.push(newQuery);
|
||||
});
|
||||
|
||||
query.builder.queryFormulas.forEach((currentFormula) => {
|
||||
const newQuery = {
|
||||
...query,
|
||||
id: uuid(),
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryFormulas: [currentFormula],
|
||||
queryData: query.builder.queryData.map((currentQuery) => ({
|
||||
...currentQuery,
|
||||
disabled: true,
|
||||
})),
|
||||
},
|
||||
};
|
||||
queries.push(newQuery);
|
||||
});
|
||||
|
||||
return queries;
|
||||
};
|
||||
51
frontend/src/container/MeterExplorer/events.ts
Normal file
51
frontend/src/container/MeterExplorer/events.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* This file contains all analytics events for the Meter Explorer.
|
||||
*/
|
||||
export enum MeterExplorerEvents {
|
||||
TabChanged = 'Meter Explorer: Tab visited',
|
||||
ModalOpened = 'Meter Explorer: Modal opened',
|
||||
MeterClicked = 'Meter Explorer: Meter clicked',
|
||||
FilterApplied = 'Meter Explorer: Filter applied',
|
||||
TreemapViewChanged = 'Meter Explorer: Treemap view changed',
|
||||
PageNumberChanged = 'Meter Explorer: Page number changed',
|
||||
PageSizeChanged = 'Meter Explorer: Page size changed',
|
||||
OrderByApplied = 'Meter Explorer: Order by applied',
|
||||
MetricMetadataUpdated = 'Meter Explorer: Metric metadata updated',
|
||||
OpenInExplorerClicked = 'Meter Explorer: Open in explorer clicked',
|
||||
InspectViewChanged = 'Meter Explorer: Inspect view changed',
|
||||
InspectQueryChanged = 'Meter Explorer: Inspect query changed',
|
||||
InspectPointClicked = 'Meter Explorer: Inspect point clicked',
|
||||
QueryBuilderQueryChanged = 'Meter Explorer: QueryBuilder query changed',
|
||||
YAxisUnitApplied = 'Meter Explorer: Y axis unit applied',
|
||||
AddToAlertClicked = 'Meter Explorer: Add to alert clicked',
|
||||
AddToDashboardClicked = 'Meter Explorer: Add to dashboard clicked',
|
||||
SaveViewClicked = 'Meter Explorer: Save view clicked',
|
||||
SearchApplied = 'Meter Explorer: Search applied',
|
||||
ViewEdited = 'Meter Explorer: View edited',
|
||||
ViewDeleted = 'Meter Explorer: View deleted',
|
||||
}
|
||||
|
||||
export enum MeterExplorerEventKeys {
|
||||
Tab = 'tab',
|
||||
Modal = 'modal',
|
||||
View = 'view',
|
||||
Interval = 'interval',
|
||||
ViewType = 'viewType',
|
||||
PageNumber = 'pageNumber',
|
||||
PageSize = 'pageSize',
|
||||
ColumnName = 'columnName',
|
||||
Order = 'order',
|
||||
AttributeKey = 'attributeKey',
|
||||
AttributeValue = 'attributeValue',
|
||||
MetricName = 'metricName',
|
||||
InspectView = 'inspectView',
|
||||
TimeAggregationOption = 'timeAggregationOption',
|
||||
TimeAggregationInterval = 'timeAggregationInterval',
|
||||
SpaceAggregationOption = 'spaceAggregationOption',
|
||||
SpaceAggregationLabels = 'spaceAggregationLabels',
|
||||
OneChartPerQueryEnabled = 'oneChartPerQueryEnabled',
|
||||
YAxisUnit = 'yAxisUnit',
|
||||
ViewName = 'viewName',
|
||||
Filters = 'filters',
|
||||
TimeRange = 'timeRange',
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import * as Sentry from '@sentry/react';
|
||||
import { Switch } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
@@ -12,9 +13,11 @@ import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { Warning } from 'types/api';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -118,6 +121,8 @@ function Explorer(): JSX.Element {
|
||||
[],
|
||||
);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="metrics-explorer-explore-container">
|
||||
@@ -131,6 +136,7 @@ function Explorer(): JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
<div className="explore-header-right-actions">
|
||||
{!isEmpty(warning) && <WarningPopover warningData={warning} />}
|
||||
<DateTimeSelector showAutoRefresh />
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => handleRunQuery(true, true)}
|
||||
@@ -167,7 +173,10 @@ function Explorer(): JSX.Element {
|
||||
</Button.Group> */}
|
||||
<div className="explore-content">
|
||||
{selectedTab === ExplorerTabs.TIME_SERIES && (
|
||||
<TimeSeries showOneChartPerQuery={showOneChartPerQuery} />
|
||||
<TimeSeries
|
||||
showOneChartPerQuery={showOneChartPerQuery}
|
||||
setWarning={setWarning}
|
||||
/>
|
||||
)}
|
||||
{/* TODO: Enable once we have resolved all related metrics issues */}
|
||||
{/* {selectedTab === ExplorerTabs.RELATED_METRICS && (
|
||||
@@ -180,7 +189,7 @@ function Explorer(): JSX.Element {
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.METRICS}
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={showOneChartPerQuery}
|
||||
isOneChartPerQuery={false}
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
@@ -8,7 +8,6 @@ import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -22,7 +21,10 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { TimeSeriesProps } from './types';
|
||||
import { splitQueryIntoOneChartPerQuery } from './utils';
|
||||
|
||||
function TimeSeries({ showOneChartPerQuery }: TimeSeriesProps): JSX.Element {
|
||||
function TimeSeries({
|
||||
showOneChartPerQuery,
|
||||
setWarning,
|
||||
}: TimeSeriesProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery } = useQueryBuilder();
|
||||
|
||||
const { selectedTime: globalSelectedTime, maxTime, minTime } = useSelector<
|
||||
@@ -61,8 +63,6 @@ function TimeSeries({ showOneChartPerQuery }: TimeSeriesProps): JSX.Element {
|
||||
|
||||
const [yAxisUnit, setYAxisUnit] = useState<string>('');
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const queries = useQueries(
|
||||
queryPayloads.map((payload, index) => ({
|
||||
queryKey: [
|
||||
@@ -104,9 +104,6 @@ function TimeSeries({ showOneChartPerQuery }: TimeSeriesProps): JSX.Element {
|
||||
|
||||
return failureCount < 3;
|
||||
},
|
||||
onError: (error: APIError): void => {
|
||||
showErrorModal(error);
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -150,6 +147,8 @@ function TimeSeries({ showOneChartPerQuery }: TimeSeriesProps): JSX.Element {
|
||||
data={datapoint}
|
||||
yAxisUnit={yAxisUnit}
|
||||
dataSource={DataSource.METRICS}
|
||||
error={queries[index].error as APIError}
|
||||
setWarning={setWarning}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RelatedMetric } from 'api/metricsExplorer/getRelatedMetrics';
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { SuccessResponse, Warning } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
export enum ExplorerTabs {
|
||||
@@ -10,6 +11,7 @@ export enum ExplorerTabs {
|
||||
|
||||
export interface TimeSeriesProps {
|
||||
showOneChartPerQuery: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
}
|
||||
|
||||
export interface RelatedMetricsProps {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user