Compare commits
37 Commits
limit-for-
...
variables-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fe1d1b3f7 | ||
|
|
11e4971db4 | ||
|
|
a0ab69ac4c | ||
|
|
972fadf79a | ||
|
|
9c2f127282 | ||
|
|
e30de5f13e | ||
|
|
019083983a | ||
|
|
fdcad997f5 | ||
|
|
03359a40a2 | ||
|
|
4f45801729 | ||
|
|
674556d672 | ||
|
|
af987e53ce | ||
|
|
59d5accd33 | ||
|
|
5a7ad670d8 | ||
|
|
9d04b397ac | ||
|
|
a4f3be5e46 | ||
|
|
8f833fa62c | ||
|
|
7029233596 | ||
|
|
d26efd2833 | ||
|
|
0e3ac2a179 | ||
|
|
249f8be845 | ||
|
|
9c952942ad | ||
|
|
dac46d82ff | ||
|
|
802ce6de01 | ||
|
|
6853f0c99d | ||
|
|
3f8a2870e4 | ||
|
|
5fa70ea802 | ||
|
|
3a952fa330 | ||
|
|
6d97db1d9d | ||
|
|
5412e7f70b | ||
|
|
8e5cb9046d | ||
|
|
760eabb2dc | ||
|
|
35ddaaa2fc | ||
|
|
a51ee66c02 | ||
|
|
75d189162b | ||
|
|
932918e3a4 | ||
|
|
aa3bc16dcb |
@@ -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
|
||||
##############################################################
|
||||
|
||||
@@ -122,6 +122,7 @@ telemetrystore:
|
||||
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 Views",
|
||||
"METER": "SigNoz | Meter"
|
||||
}
|
||||
|
||||
@@ -69,5 +69,8 @@
|
||||
"METRICS_EXPLORER": "SigNoz | Metrics Explorer",
|
||||
"METRICS_EXPLORER_EXPLORER": "SigNoz | Metrics Explorer",
|
||||
"METRICS_EXPLORER_VIEWS": "SigNoz | Metrics Explorer",
|
||||
"API_MONITORING": "SigNoz | External APIs"
|
||||
"API_MONITORING": "SigNoz | External APIs",
|
||||
"METER_EXPLORER": "SigNoz | Meter Explorer",
|
||||
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
|
||||
"METER": "SigNoz | Meter"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import MessagingQueues from 'pages/MessagingQueues';
|
||||
import MeterExplorer from 'pages/MeterExplorer';
|
||||
import { RouteProps } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
@@ -434,6 +435,28 @@ const routes: AppRoutes[] = [
|
||||
key: 'METRICS_EXPLORER_VIEWS',
|
||||
isPrivate: true,
|
||||
},
|
||||
|
||||
{
|
||||
path: ROUTES.METER,
|
||||
exact: true,
|
||||
component: MeterExplorer,
|
||||
key: 'METER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METER_EXPLORER,
|
||||
exact: true,
|
||||
component: MeterExplorer,
|
||||
key: 'METER_EXPLORER',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.METER_EXPLORER_VIEWS,
|
||||
exact: true,
|
||||
component: MeterExplorer,
|
||||
key: 'METER_EXPLORER_VIEWS',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.API_MONITORING,
|
||||
exact: true,
|
||||
|
||||
34
frontend/src/api/dynamicVariables/getFieldKeys.ts
Normal file
34
frontend/src/api/dynamicVariables/getFieldKeys.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { FieldKeyResponse } from 'types/api/dynamicVariables/getFieldKeys';
|
||||
|
||||
/**
|
||||
* Get field keys for a given signal type
|
||||
* @param signal Type of signal (traces, logs, metrics)
|
||||
* @param name Optional search text
|
||||
*/
|
||||
export const getFieldKeys = async (
|
||||
signal?: 'traces' | 'logs' | 'metrics',
|
||||
name?: string,
|
||||
): Promise<SuccessResponse<FieldKeyResponse> | ErrorResponse> => {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (signal) {
|
||||
params.signal = signal;
|
||||
}
|
||||
|
||||
if (name) {
|
||||
params.name = name;
|
||||
}
|
||||
|
||||
const response = await ApiBaseInstance.get('/fields/keys', { params });
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default getFieldKeys;
|
||||
63
frontend/src/api/dynamicVariables/getFieldValues.ts
Normal file
63
frontend/src/api/dynamicVariables/getFieldValues.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
|
||||
|
||||
/**
|
||||
* Get field values for a given signal type and field name
|
||||
* @param signal Type of signal (traces, logs, metrics)
|
||||
* @param name Name of the attribute for which values are being fetched
|
||||
* @param value Optional search text
|
||||
*/
|
||||
export const getFieldValues = async (
|
||||
signal?: 'traces' | 'logs' | 'metrics',
|
||||
name?: string,
|
||||
value?: string,
|
||||
startUnixMilli?: number,
|
||||
endUnixMilli?: number,
|
||||
): Promise<SuccessResponse<FieldValueResponse> | ErrorResponse> => {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (signal) {
|
||||
params.signal = signal;
|
||||
}
|
||||
|
||||
if (name) {
|
||||
params.name = name;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
params.value = value;
|
||||
}
|
||||
|
||||
if (startUnixMilli) {
|
||||
params.startUnixMilli = Math.floor(startUnixMilli / 1000000).toString();
|
||||
}
|
||||
|
||||
if (endUnixMilli) {
|
||||
params.endUnixMilli = Math.floor(endUnixMilli / 1000000).toString();
|
||||
}
|
||||
|
||||
const response = await ApiBaseInstance.get('/fields/values', { params });
|
||||
|
||||
// Normalize values from different types (stringValues, boolValues, etc.)
|
||||
if (response.data?.data?.values) {
|
||||
const allValues: string[] = [];
|
||||
Object.values(response.data.data.values).forEach((valueArray: any) => {
|
||||
if (Array.isArray(valueArray)) {
|
||||
allValues.push(...valueArray.map(String));
|
||||
}
|
||||
});
|
||||
|
||||
// Add a normalized values array to the response
|
||||
response.data.data.normalizedValues = allValues;
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default getFieldValues;
|
||||
@@ -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}`);
|
||||
|
||||
@@ -260,6 +260,7 @@ export function convertBuilderQueriesToV5(
|
||||
spec = {
|
||||
name: queryName,
|
||||
signal: 'metrics' as const,
|
||||
source: queryData.source || '',
|
||||
...baseSpec,
|
||||
aggregations: aggregations as MetricAggregation[],
|
||||
// reduceTo: queryData.reduceTo,
|
||||
|
||||
@@ -137,5 +137,11 @@
|
||||
h6 {
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
color: var(--text-ink-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
|
||||
import {
|
||||
filterOptionsBySearch,
|
||||
handleScrollToBottom,
|
||||
prioritizeOrAddOptionForMultiSelect,
|
||||
SPACEKEY,
|
||||
} from './utils';
|
||||
@@ -37,7 +38,7 @@ enum ToggleTagValue {
|
||||
All = 'All',
|
||||
}
|
||||
|
||||
const ALL_SELECTED_VALUE = '__all__'; // Constant for the special value
|
||||
const ALL_SELECTED_VALUE = '__ALL__'; // Constant for the special value
|
||||
|
||||
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
placeholder = 'Search...',
|
||||
@@ -62,6 +63,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
allowClear = false,
|
||||
onRetry,
|
||||
maxTagTextLength,
|
||||
onDropdownVisibleChange,
|
||||
showIncompleteDataMessage = false,
|
||||
...rest
|
||||
}) => {
|
||||
// ===== State & Refs =====
|
||||
@@ -78,6 +81,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
const [visibleOptions, setVisibleOptions] = useState<OptionData[]>([]);
|
||||
const isClickInsideDropdownRef = useRef(false);
|
||||
const justOpenedRef = useRef<boolean>(false);
|
||||
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
|
||||
|
||||
// Convert single string value to array for consistency
|
||||
const selectedValues = useMemo(
|
||||
@@ -124,6 +129,12 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
return allAvailableValues.every((val) => selectedValues.includes(val));
|
||||
}, [selectedValues, allAvailableValues, enableAllSelection]);
|
||||
|
||||
// Define allOptionShown earlier in the code
|
||||
const allOptionShown = useMemo(
|
||||
() => value === ALL_SELECTED_VALUE || value === 'ALL',
|
||||
[value],
|
||||
);
|
||||
|
||||
// Value passed to the underlying Ant Select component
|
||||
const displayValue = useMemo(
|
||||
() => (isAllSelected ? [ALL_SELECTED_VALUE] : selectedValues),
|
||||
@@ -132,10 +143,18 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
|
||||
// ===== Internal onChange Handler =====
|
||||
const handleInternalChange = useCallback(
|
||||
(newValue: string | string[]): void => {
|
||||
(newValue: string | string[], directCaller?: boolean): void => {
|
||||
// Ensure newValue is an array
|
||||
const currentNewValue = Array.isArray(newValue) ? newValue : [];
|
||||
|
||||
if (
|
||||
(allOptionShown || isAllSelected) &&
|
||||
!directCaller &&
|
||||
currentNewValue.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!onChange) return;
|
||||
|
||||
// Case 1: Cleared (empty array or undefined)
|
||||
@@ -144,7 +163,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 2: "__all__" is selected (means select all actual values)
|
||||
// Case 2: "__ALL__" is selected (means select all actual values)
|
||||
if (currentNewValue.includes(ALL_SELECTED_VALUE)) {
|
||||
const allActualOptions = allAvailableValues.map(
|
||||
(v) => options.flat().find((o) => o.value === v) || { label: v, value: v },
|
||||
@@ -175,7 +194,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[onChange, allAvailableValues, options, enableAllSelection],
|
||||
[
|
||||
allOptionShown,
|
||||
isAllSelected,
|
||||
onChange,
|
||||
allAvailableValues,
|
||||
options,
|
||||
enableAllSelection,
|
||||
],
|
||||
);
|
||||
|
||||
// ===== Existing Callbacks (potentially needing adjustment later) =====
|
||||
@@ -510,11 +536,19 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}
|
||||
|
||||
// Normal single value handling
|
||||
setSearchText(value.trim());
|
||||
const trimmedValue = value.trim();
|
||||
setSearchText(trimmedValue);
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
justOpenedRef.current = true;
|
||||
}
|
||||
if (onSearch) onSearch(value.trim());
|
||||
|
||||
// Reset active index when search changes if dropdown is open
|
||||
if (isOpen && trimmedValue) {
|
||||
setActiveIndex(0);
|
||||
}
|
||||
|
||||
if (onSearch) onSearch(trimmedValue);
|
||||
},
|
||||
[onSearch, isOpen, selectedValues, onChange],
|
||||
);
|
||||
@@ -528,28 +562,34 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
(text: string, searchQuery: string): React.ReactNode => {
|
||||
if (!searchQuery || !highlightSearch) return text;
|
||||
|
||||
const parts = text.split(
|
||||
new RegExp(
|
||||
`(${searchQuery.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')})`,
|
||||
'gi',
|
||||
),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
// Create a unique key that doesn't rely on array index
|
||||
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
||||
try {
|
||||
const parts = text.split(
|
||||
new RegExp(
|
||||
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
|
||||
'gi',
|
||||
),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
// Create a unique key that doesn't rely on array index
|
||||
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
||||
|
||||
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||
<span key={uniqueKey} className="highlight-text">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||
<span key={uniqueKey} className="highlight-text">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
} catch (error) {
|
||||
// If regex fails, return the original text without highlighting
|
||||
console.error('Error in text highlighting:', error);
|
||||
return text;
|
||||
}
|
||||
},
|
||||
[highlightSearch],
|
||||
);
|
||||
@@ -560,10 +600,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
|
||||
if (isAllSelected) {
|
||||
// If all are selected, deselect all
|
||||
handleInternalChange([]);
|
||||
handleInternalChange([], true);
|
||||
} else {
|
||||
// Otherwise, select all
|
||||
handleInternalChange([ALL_SELECTED_VALUE]);
|
||||
handleInternalChange([ALL_SELECTED_VALUE], true);
|
||||
}
|
||||
}, [options, isAllSelected, handleInternalChange]);
|
||||
|
||||
@@ -738,6 +778,26 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
// Enhanced keyboard navigation with support for maxTagCount
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLElement>): void => {
|
||||
// Simple early return if ALL is selected - block all possible keyboard interactions
|
||||
// that could remove the ALL tag, but still allow dropdown navigation and search
|
||||
if (
|
||||
(allOptionShown || isAllSelected) &&
|
||||
(e.key === 'Backspace' || e.key === 'Delete')
|
||||
) {
|
||||
// Only prevent default if the input is empty or cursor is at start position
|
||||
const activeElement = document.activeElement as HTMLInputElement;
|
||||
const isInputActive = activeElement?.tagName === 'INPUT';
|
||||
const isInputEmpty = isInputActive && !activeElement?.value;
|
||||
const isCursorAtStart =
|
||||
isInputActive && activeElement?.selectionStart === 0;
|
||||
|
||||
if (isInputEmpty || isCursorAtStart) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get flattened list of all selectable options
|
||||
const getFlatOptions = (): OptionData[] => {
|
||||
if (!visibleOptions) return [];
|
||||
@@ -752,7 +812,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
if (hasAll) {
|
||||
flatList.push({
|
||||
label: 'ALL',
|
||||
value: '__all__', // Special value for the ALL option
|
||||
value: ALL_SELECTED_VALUE, // Special value for the ALL option
|
||||
type: 'defined',
|
||||
});
|
||||
}
|
||||
@@ -784,6 +844,17 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
|
||||
const flatOptions = getFlatOptions();
|
||||
|
||||
// If we just opened the dropdown and have options, set first option as active
|
||||
if (justOpenedRef.current && flatOptions.length > 0) {
|
||||
setActiveIndex(0);
|
||||
justOpenedRef.current = false;
|
||||
}
|
||||
|
||||
// If no option is active but we have options and dropdown is open, activate the first one
|
||||
if (isOpen && activeIndex === -1 && flatOptions.length > 0) {
|
||||
setActiveIndex(0);
|
||||
}
|
||||
|
||||
// Get the active input element to check cursor position
|
||||
const activeElement = document.activeElement as HTMLInputElement;
|
||||
const isInputActive = activeElement?.tagName === 'INPUT';
|
||||
@@ -1129,7 +1200,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
// If there's an active option in the dropdown, prioritize selecting it
|
||||
if (activeIndex >= 0 && activeIndex < flatOptions.length) {
|
||||
const selectedOption = flatOptions[activeIndex];
|
||||
if (selectedOption.value === '__all__') {
|
||||
if (selectedOption.value === ALL_SELECTED_VALUE) {
|
||||
handleSelectAll();
|
||||
} else if (selectedOption.value && onChange) {
|
||||
const newValues = selectedValues.includes(selectedOption.value)
|
||||
@@ -1159,6 +1230,10 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setActiveIndex(-1);
|
||||
// Call onDropdownVisibleChange when Escape is pressed to close dropdown
|
||||
if (onDropdownVisibleChange) {
|
||||
onDropdownVisibleChange(false);
|
||||
}
|
||||
break;
|
||||
|
||||
case SPACEKEY:
|
||||
@@ -1168,7 +1243,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
const selectedOption = flatOptions[activeIndex];
|
||||
|
||||
// Check if it's the ALL option
|
||||
if (selectedOption.value === '__all__') {
|
||||
if (selectedOption.value === ALL_SELECTED_VALUE) {
|
||||
handleSelectAll();
|
||||
} else if (selectedOption.value && onChange) {
|
||||
const newValues = selectedValues.includes(selectedOption.value)
|
||||
@@ -1214,7 +1289,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
setActiveIndex(0);
|
||||
justOpenedRef.current = true; // Set flag to initialize active option on next render
|
||||
setActiveChipIndex(-1);
|
||||
break;
|
||||
|
||||
@@ -1260,9 +1335,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}
|
||||
},
|
||||
[
|
||||
allOptionShown,
|
||||
isAllSelected,
|
||||
isOpen,
|
||||
activeIndex,
|
||||
getVisibleChipIndices,
|
||||
getLastVisibleChipIndex,
|
||||
selectedChips,
|
||||
isSelectionMode,
|
||||
isOpen,
|
||||
activeChipIndex,
|
||||
selectedValues,
|
||||
visibleOptions,
|
||||
@@ -1278,10 +1358,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
startSelection,
|
||||
selectionEnd,
|
||||
extendSelection,
|
||||
activeIndex,
|
||||
onDropdownVisibleChange,
|
||||
handleSelectAll,
|
||||
getVisibleChipIndices,
|
||||
getLastVisibleChipIndex,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1306,6 +1384,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
// Add a scroll handler for the dropdown
|
||||
const handleDropdownScroll = useCallback(
|
||||
(e: React.UIEvent<HTMLDivElement>): void => {
|
||||
setIsScrolledToBottom(handleScrollToBottom(e));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Custom dropdown render with sections support
|
||||
const customDropdownRender = useCallback((): React.ReactElement => {
|
||||
// Process options based on current search
|
||||
@@ -1382,6 +1468,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
onMouseDown={handleDropdownMouseDown}
|
||||
onClick={handleDropdownClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={handleDropdownScroll}
|
||||
onBlur={handleBlur}
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
@@ -1460,15 +1547,18 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
|
||||
{/* Navigation help footer */}
|
||||
<div className="navigation-footer" role="note">
|
||||
{!loading && !errorMessage && !noDataMessage && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
<ArrowUp size={8} className="icons" />
|
||||
<ArrowLeft size={8} className="icons" />
|
||||
<ArrowRight size={8} className="icons" />
|
||||
<span className="keyboard-text">to navigate</span>
|
||||
</section>
|
||||
)}
|
||||
{!loading &&
|
||||
!errorMessage &&
|
||||
!noDataMessage &&
|
||||
!(showIncompleteDataMessage && isScrolledToBottom) && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
<ArrowUp size={8} className="icons" />
|
||||
<ArrowLeft size={8} className="icons" />
|
||||
<ArrowRight size={8} className="icons" />
|
||||
<span className="keyboard-text">to navigate</span>
|
||||
</section>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="navigation-loading">
|
||||
<div className="navigation-icons">
|
||||
@@ -1494,9 +1584,19 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noDataMessage && !loading && (
|
||||
<div className="navigation-text">{noDataMessage}</div>
|
||||
)}
|
||||
{showIncompleteDataMessage &&
|
||||
isScrolledToBottom &&
|
||||
!loading &&
|
||||
!errorMessage && (
|
||||
<div className="navigation-text-incomplete">
|
||||
Use search for more options
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noDataMessage &&
|
||||
!loading &&
|
||||
!(showIncompleteDataMessage && isScrolledToBottom) &&
|
||||
!errorMessage && <div className="navigation-text">{noDataMessage}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1513,6 +1613,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
handleDropdownMouseDown,
|
||||
handleDropdownClick,
|
||||
handleKeyDown,
|
||||
handleDropdownScroll,
|
||||
handleBlur,
|
||||
activeIndex,
|
||||
loading,
|
||||
@@ -1522,8 +1623,31 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
renderOptionWithIndex,
|
||||
handleSelectAll,
|
||||
onRetry,
|
||||
showIncompleteDataMessage,
|
||||
isScrolledToBottom,
|
||||
]);
|
||||
|
||||
// Custom handler for dropdown visibility changes
|
||||
const handleDropdownVisibleChange = useCallback(
|
||||
(visible: boolean): void => {
|
||||
setIsOpen(visible);
|
||||
if (visible) {
|
||||
justOpenedRef.current = true;
|
||||
setActiveIndex(0);
|
||||
setActiveChipIndex(-1);
|
||||
} else {
|
||||
setSearchText('');
|
||||
setActiveIndex(-1);
|
||||
// Don't clear activeChipIndex when dropdown closes to maintain tag focus
|
||||
}
|
||||
// Pass through to the parent component's handler if provided
|
||||
if (onDropdownVisibleChange) {
|
||||
onDropdownVisibleChange(visible);
|
||||
}
|
||||
},
|
||||
[onDropdownVisibleChange],
|
||||
);
|
||||
|
||||
// ===== Side Effects =====
|
||||
|
||||
// Clear search when dropdown closes
|
||||
@@ -1588,52 +1712,9 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
const { label, value, closable, onClose } = props;
|
||||
|
||||
// If the display value is the special ALL value, render the ALL tag
|
||||
if (value === ALL_SELECTED_VALUE && isAllSelected) {
|
||||
const handleAllTagClose = (
|
||||
e: React.MouseEvent | React.KeyboardEvent,
|
||||
): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleInternalChange([]); // Clear selection when ALL tag is closed
|
||||
};
|
||||
|
||||
const handleAllTagKeyDown = (e: React.KeyboardEvent): void => {
|
||||
if (e.key === 'Enter' || e.key === SPACEKEY) {
|
||||
handleAllTagClose(e);
|
||||
}
|
||||
// Prevent Backspace/Delete propagation if needed, handle in main keydown handler
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('ant-select-selection-item', {
|
||||
'ant-select-selection-item-active': activeChipIndex === 0, // Treat ALL tag as index 0 when active
|
||||
'ant-select-selection-item-selected': selectedChips.includes(0),
|
||||
})}
|
||||
style={
|
||||
activeChipIndex === 0 || selectedChips.includes(0)
|
||||
? {
|
||||
borderColor: Color.BG_ROBIN_500,
|
||||
backgroundColor: Color.BG_SLATE_400,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="ant-select-selection-item-content">ALL</span>
|
||||
{closable && (
|
||||
<span
|
||||
className="ant-select-selection-item-remove"
|
||||
onClick={handleAllTagClose}
|
||||
onKeyDown={handleAllTagKeyDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Remove ALL tag (deselect all)"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
if (allOptionShown) {
|
||||
// Don't render a visible tag - will be shown as placeholder
|
||||
return <div style={{ display: 'none' }} />;
|
||||
}
|
||||
|
||||
// If not isAllSelected, render individual tags using previous logic
|
||||
@@ -1713,52 +1794,69 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
// Fallback for safety, should not be reached
|
||||
return <div />;
|
||||
},
|
||||
[
|
||||
isAllSelected,
|
||||
handleInternalChange,
|
||||
activeChipIndex,
|
||||
selectedChips,
|
||||
selectedValues,
|
||||
maxTagCount,
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[isAllSelected, activeChipIndex, selectedChips, selectedValues, maxTagCount],
|
||||
);
|
||||
|
||||
// Simple onClear handler to prevent clearing ALL
|
||||
const onClearHandler = useCallback((): void => {
|
||||
// Skip clearing if ALL is selected
|
||||
if (allOptionShown || isAllSelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal clear behavior
|
||||
handleInternalChange([], true);
|
||||
if (onClear) onClear();
|
||||
}, [onClear, handleInternalChange, allOptionShown, isAllSelected]);
|
||||
|
||||
// ===== Component Rendering =====
|
||||
return (
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-multiselect', className, {
|
||||
'has-selection': selectedChips.length > 0 && !isAllSelected,
|
||||
'is-all-selected': isAllSelected,
|
||||
<div
|
||||
className={cx('custom-multiselect-wrapper', {
|
||||
'all-selected': allOptionShown || isAllSelected,
|
||||
})}
|
||||
placeholder={placeholder}
|
||||
mode="multiple"
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={displayValue}
|
||||
onChange={handleInternalChange}
|
||||
onClear={(): void => handleInternalChange([])}
|
||||
onDropdownVisibleChange={setIsOpen}
|
||||
open={isOpen}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={dropdownMatchSelectWidth}
|
||||
allowClear={allowClear}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
tagRender={tagRender as any}
|
||||
placement={placement}
|
||||
listHeight={300}
|
||||
searchValue={searchText}
|
||||
maxTagTextLength={maxTagTextLength}
|
||||
maxTagCount={isAllSelected ? 1 : maxTagCount}
|
||||
{...rest}
|
||||
/>
|
||||
>
|
||||
{(allOptionShown || isAllSelected) && !searchText && (
|
||||
<div className="all-text">ALL</div>
|
||||
)}
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-multiselect', className, {
|
||||
'has-selection': selectedChips.length > 0 && !isAllSelected,
|
||||
'is-all-selected': isAllSelected,
|
||||
})}
|
||||
placeholder={placeholder}
|
||||
mode="multiple"
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={displayValue}
|
||||
onChange={(newValue): void => {
|
||||
handleInternalChange(newValue, false);
|
||||
}}
|
||||
onClear={onClearHandler}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={dropdownMatchSelectWidth}
|
||||
allowClear={allowClear}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<DownOutlined style={{ cursor: 'default' }} />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
tagRender={tagRender as any}
|
||||
placement={placement}
|
||||
listHeight={300}
|
||||
searchValue={searchText}
|
||||
maxTagTextLength={maxTagTextLength}
|
||||
maxTagCount={isAllSelected ? undefined : maxTagCount}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { CustomSelectProps, OptionData } from './types';
|
||||
import {
|
||||
filterOptionsBySearch,
|
||||
handleScrollToBottom,
|
||||
prioritizeOrAddOptionForSingleSelect,
|
||||
SPACEKEY,
|
||||
} from './utils';
|
||||
@@ -57,17 +58,29 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
errorMessage,
|
||||
allowClear = false,
|
||||
onRetry,
|
||||
showIncompleteDataMessage = false,
|
||||
...rest
|
||||
}) => {
|
||||
// ===== State & Refs =====
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(-1);
|
||||
const [isScrolledToBottom, setIsScrolledToBottom] = useState(false);
|
||||
|
||||
// Refs for element access and scroll behavior
|
||||
const selectRef = useRef<BaseSelectRef>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const optionRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
// Flag to track if dropdown just opened
|
||||
const justOpenedRef = useRef<boolean>(false);
|
||||
|
||||
// Add a scroll handler for the dropdown
|
||||
const handleDropdownScroll = useCallback(
|
||||
(e: React.UIEvent<HTMLDivElement>): void => {
|
||||
setIsScrolledToBottom(handleScrollToBottom(e));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ===== Option Filtering & Processing Utilities =====
|
||||
|
||||
@@ -130,23 +143,33 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
(text: string, searchQuery: string): React.ReactNode => {
|
||||
if (!searchQuery || !highlightSearch) return text;
|
||||
|
||||
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
// Create a deterministic but unique key
|
||||
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
||||
try {
|
||||
const parts = text.split(
|
||||
new RegExp(
|
||||
`(${searchQuery.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')})`,
|
||||
'gi',
|
||||
),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => {
|
||||
// Create a deterministic but unique key
|
||||
const uniqueKey = `${text.substring(0, 3)}-${part.substring(0, 3)}-${i}`;
|
||||
|
||||
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||
<span key={uniqueKey} className="highlight-text">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
return part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||
<span key={uniqueKey} className="highlight-text">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in text highlighting:', error);
|
||||
return text;
|
||||
}
|
||||
},
|
||||
[highlightSearch],
|
||||
);
|
||||
@@ -246,9 +269,14 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
const trimmedValue = value.trim();
|
||||
setSearchText(trimmedValue);
|
||||
|
||||
// Reset active option index when search changes
|
||||
if (isOpen) {
|
||||
setActiveOptionIndex(0);
|
||||
}
|
||||
|
||||
if (onSearch) onSearch(trimmedValue);
|
||||
},
|
||||
[onSearch],
|
||||
[onSearch, isOpen],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -272,14 +300,23 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
const flatList: OptionData[] = [];
|
||||
|
||||
// Process options
|
||||
let processedOptions = isEmpty(value)
|
||||
? filteredOptions
|
||||
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value);
|
||||
|
||||
if (!isEmpty(searchText)) {
|
||||
processedOptions = filterOptionsBySearch(processedOptions, searchText);
|
||||
}
|
||||
|
||||
const { sectionOptions, nonSectionOptions } = splitOptions(
|
||||
isEmpty(value)
|
||||
? filteredOptions
|
||||
: prioritizeOrAddOptionForSingleSelect(filteredOptions, value),
|
||||
processedOptions,
|
||||
);
|
||||
|
||||
// Add custom option if needed
|
||||
if (!isEmpty(searchText) && !isLabelPresent(filteredOptions, searchText)) {
|
||||
if (
|
||||
!isEmpty(searchText) &&
|
||||
!isLabelPresent(processedOptions, searchText)
|
||||
) {
|
||||
flatList.push({
|
||||
label: searchText,
|
||||
value: searchText,
|
||||
@@ -300,33 +337,52 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
|
||||
const options = getFlatOptions();
|
||||
|
||||
// If we just opened the dropdown and have options, set first option as active
|
||||
if (justOpenedRef.current && options.length > 0) {
|
||||
setActiveOptionIndex(0);
|
||||
justOpenedRef.current = false;
|
||||
}
|
||||
|
||||
// If no option is active but we have options, activate the first one
|
||||
if (activeOptionIndex === -1 && options.length > 0) {
|
||||
setActiveOptionIndex(0);
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
if (options.length > 0) {
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : options.length - 1,
|
||||
);
|
||||
if (options.length > 0) {
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : options.length - 1,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Tab':
|
||||
// Tab navigation with Shift key support
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : options.length - 1,
|
||||
);
|
||||
if (options.length > 0) {
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : options.length - 1,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
e.preventDefault();
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
if (options.length > 0) {
|
||||
setActiveOptionIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -339,6 +395,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
onChange(selectedOption.value, selectedOption);
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
setSearchText('');
|
||||
}
|
||||
} else if (!isEmpty(searchText)) {
|
||||
// Add custom value when no option is focused
|
||||
@@ -351,6 +408,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
onChange(customOption.value, customOption);
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
setSearchText('');
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -359,6 +417,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
setSearchText('');
|
||||
break;
|
||||
|
||||
case ' ': // Space key
|
||||
@@ -369,6 +428,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
onChange(selectedOption.value, selectedOption);
|
||||
setIsOpen(false);
|
||||
setActiveOptionIndex(-1);
|
||||
setSearchText('');
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -379,7 +439,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
// Open dropdown when Down or Tab is pressed while closed
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
setActiveOptionIndex(0);
|
||||
justOpenedRef.current = true; // Set flag to initialize active option on next render
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -444,6 +504,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
className="custom-select-dropdown"
|
||||
onClick={handleDropdownClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={handleDropdownScroll}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-activedescendant={
|
||||
@@ -454,7 +515,6 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
<div className="no-section-options">
|
||||
{nonSectionOptions.length > 0 && mapOptions(nonSectionOptions)}
|
||||
</div>
|
||||
|
||||
{/* Section options */}
|
||||
{sectionOptions.length > 0 &&
|
||||
sectionOptions.map((section) =>
|
||||
@@ -472,13 +532,16 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
|
||||
{/* Navigation help footer */}
|
||||
<div className="navigation-footer" role="note">
|
||||
{!loading && !errorMessage && !noDataMessage && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
<ArrowUp size={8} className="icons" />
|
||||
<span className="keyboard-text">to navigate</span>
|
||||
</section>
|
||||
)}
|
||||
{!loading &&
|
||||
!errorMessage &&
|
||||
!noDataMessage &&
|
||||
!(showIncompleteDataMessage && isScrolledToBottom) && (
|
||||
<section className="navigate">
|
||||
<ArrowDown size={8} className="icons" />
|
||||
<ArrowUp size={8} className="icons" />
|
||||
<span className="keyboard-text">to navigate</span>
|
||||
</section>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="navigation-loading">
|
||||
<div className="navigation-icons">
|
||||
@@ -504,9 +567,19 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noDataMessage && !loading && (
|
||||
<div className="navigation-text">{noDataMessage}</div>
|
||||
)}
|
||||
{showIncompleteDataMessage &&
|
||||
isScrolledToBottom &&
|
||||
!loading &&
|
||||
!errorMessage && (
|
||||
<div className="navigation-text-incomplete">
|
||||
Use search for more options
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noDataMessage &&
|
||||
!loading &&
|
||||
!(showIncompleteDataMessage && isScrolledToBottom) &&
|
||||
!errorMessage && <div className="navigation-text">{noDataMessage}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -520,6 +593,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
isLabelPresent,
|
||||
handleDropdownClick,
|
||||
handleKeyDown,
|
||||
handleDropdownScroll,
|
||||
activeOptionIndex,
|
||||
loading,
|
||||
errorMessage,
|
||||
@@ -527,8 +601,22 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
dropdownRender,
|
||||
renderOptionWithIndex,
|
||||
onRetry,
|
||||
showIncompleteDataMessage,
|
||||
isScrolledToBottom,
|
||||
]);
|
||||
|
||||
// Handle dropdown visibility changes
|
||||
const handleDropdownVisibleChange = useCallback((visible: boolean): void => {
|
||||
setIsOpen(visible);
|
||||
if (visible) {
|
||||
justOpenedRef.current = true;
|
||||
setActiveOptionIndex(0);
|
||||
} else {
|
||||
setSearchText('');
|
||||
setActiveOptionIndex(-1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ===== Side Effects =====
|
||||
|
||||
// Clear search text when dropdown closes
|
||||
@@ -582,7 +670,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
onSearch={handleSearch}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={setIsOpen}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
options={optionsWithHighlight}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
|
||||
@@ -35,6 +35,43 @@ $custom-border-color: #2c3044;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
&.is-all-selected {
|
||||
.ant-select-selection-search-input {
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
opacity: 1 !important;
|
||||
color: var(--bg-vanilla-400) !important;
|
||||
font-weight: 500;
|
||||
visibility: visible !important;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
|
||||
.lightMode & {
|
||||
color: rgba(0, 0, 0, 0.85) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selection-placeholder {
|
||||
opacity: 0.45 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.all-selected-text {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--bg-vanilla-400);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
|
||||
.lightMode & {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
@@ -158,7 +195,7 @@ $custom-border-color: #2c3044;
|
||||
// Custom dropdown styles for single select
|
||||
.custom-select-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
max-height: 500px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
@@ -276,6 +313,10 @@ $custom-border-color: #2c3044;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.navigation-text-incomplete {
|
||||
color: var(--bg-amber-600) !important;
|
||||
}
|
||||
|
||||
.navigation-error {
|
||||
.navigation-text,
|
||||
.navigation-icons {
|
||||
@@ -322,7 +363,7 @@ $custom-border-color: #2c3044;
|
||||
// Custom dropdown styles for multi-select
|
||||
.custom-multiselect-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
max-height: 500px;
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
@@ -656,6 +697,10 @@ $custom-border-color: #2c3044;
|
||||
border: 1px solid #e8e8e8;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
font-size: 12px !important;
|
||||
height: 20px;
|
||||
line-height: 18px;
|
||||
|
||||
.ant-select-selection-item-content {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
@@ -836,3 +881,38 @@ $custom-border-color: #2c3044;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-multiselect-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&.all-selected {
|
||||
.all-text {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--bg-vanilla-400);
|
||||
font-weight: 500;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
|
||||
.lightMode & {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-within .all-text {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.ant-select-selection-search-input {
|
||||
caret-color: auto;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,10 @@ export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
|
||||
highlightSearch?: boolean;
|
||||
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
||||
popupMatchSelectWidth?: boolean;
|
||||
errorMessage?: string;
|
||||
errorMessage?: string | null;
|
||||
allowClear?: SelectProps['allowClear'];
|
||||
onRetry?: () => void;
|
||||
showIncompleteDataMessage?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomTagProps {
|
||||
@@ -51,10 +52,12 @@ export interface CustomMultiSelectProps
|
||||
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
|
||||
dropdownRender?: (menu: React.ReactElement) => React.ReactElement;
|
||||
highlightSearch?: boolean;
|
||||
errorMessage?: string;
|
||||
errorMessage?: string | null;
|
||||
popupClassName?: string;
|
||||
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
|
||||
maxTagCount?: number;
|
||||
allowClear?: SelectProps['allowClear'];
|
||||
onRetry?: () => void;
|
||||
maxTagTextLength?: number;
|
||||
showIncompleteDataMessage?: boolean;
|
||||
}
|
||||
|
||||
@@ -133,3 +133,15 @@ export const filterOptionsBySearch = (
|
||||
})
|
||||
.filter(Boolean) as OptionData[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to handle dropdown scroll and detect when scrolled to bottom
|
||||
* Returns true when scrolled to within 20px of the bottom
|
||||
*/
|
||||
export const handleScrollToBottom = (
|
||||
e: React.UIEvent<HTMLDivElement>,
|
||||
): boolean => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
// Consider "scrolled to bottom" when within 20px of the bottom or at the bottom
|
||||
return scrollHeight - scrollTop - clientHeight < 20;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,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 || ''}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -18,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 {
|
||||
@@ -208,6 +210,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
disabled={!queryAggregation.metricName}
|
||||
query={query}
|
||||
onChange={handleChangeGroupByKeys}
|
||||
signalSource={signalSource}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,6 +247,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
disabled={!queryAggregation.metricName}
|
||||
query={query}
|
||||
onChange={handleChangeGroupByKeys}
|
||||
signalSource={signalSource}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,13 +44,14 @@
|
||||
.lightMode {
|
||||
.metrics-select-container {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-slate-300) !important;
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100);
|
||||
color: var(--text-ink-100);
|
||||
}
|
||||
|
||||
.ant-select-dropdown {
|
||||
background: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
backdrop-filter: none;
|
||||
|
||||
@@ -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,
|
||||
@@ -26,6 +28,7 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
onChange={handleChangeAggregatorAttribute}
|
||||
query={query}
|
||||
index={index}
|
||||
signalSource={signalSource || ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -81,10 +81,12 @@ 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();
|
||||
@@ -218,6 +220,7 @@ function QuerySearch({
|
||||
signal: dataSource,
|
||||
searchText: searchText || '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
});
|
||||
|
||||
if (response.data.data) {
|
||||
@@ -245,6 +248,7 @@ function QuerySearch({
|
||||
keySuggestions,
|
||||
toggleSuggestions,
|
||||
queryData.aggregateAttribute?.key,
|
||||
signalSource,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -378,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
|
||||
@@ -465,8 +471,14 @@ function QuerySearch({
|
||||
setIsFetchingCompleteValuesList(false);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[activeKey, dataSource, isFocused],
|
||||
[
|
||||
activeKey,
|
||||
dataSource,
|
||||
isLoadingSuggestions,
|
||||
debouncedMetricName,
|
||||
signalSource,
|
||||
toggleSuggestions,
|
||||
],
|
||||
);
|
||||
|
||||
const debouncedFetchValueSuggestions = useMemo(
|
||||
@@ -1440,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' | ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
536
frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts
Normal file
536
frontend/src/components/QueryBuilderV2/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { convertFiltersToExpression } from '../utils';
|
||||
|
||||
describe('convertFiltersToExpression', () => {
|
||||
it('should handle empty, null, and undefined inputs', () => {
|
||||
// Test null and undefined
|
||||
expect(convertFiltersToExpression(null as any)).toEqual({ expression: '' });
|
||||
expect(convertFiltersToExpression(undefined as any)).toEqual({
|
||||
expression: '',
|
||||
});
|
||||
|
||||
// Test empty filters
|
||||
expect(convertFiltersToExpression({ items: [], op: 'AND' })).toEqual({
|
||||
expression: '',
|
||||
});
|
||||
expect(
|
||||
convertFiltersToExpression({ items: undefined, op: 'AND' } as any),
|
||||
).toEqual({ expression: '' });
|
||||
});
|
||||
|
||||
it('should convert basic comparison operators with proper value formatting', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'service', type: 'string' },
|
||||
op: '=',
|
||||
value: 'api-gateway',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'status', type: 'string' },
|
||||
op: '!=',
|
||||
value: 'error',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'duration', type: 'number' },
|
||||
op: '>',
|
||||
value: 100,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'count', type: 'number' },
|
||||
op: '<=',
|
||||
value: 50,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: { key: 'is_active', type: 'boolean' },
|
||||
op: '=',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
key: { key: 'enabled', type: 'boolean' },
|
||||
op: '=',
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
key: { key: 'count', type: 'number' },
|
||||
op: '=',
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
key: { key: 'regex', type: 'string' },
|
||||
op: 'regex',
|
||||
value: '.*',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"service = 'api-gateway' AND status != 'error' AND duration > 100 AND count <= 50 AND is_active = true AND enabled = false AND count = 0 AND regex REGEXP '.*'",
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle string value formatting and escaping', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'message', type: 'string' },
|
||||
op: '=',
|
||||
value: "user's data",
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'description', type: 'string' },
|
||||
op: '=',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'path', type: 'string' },
|
||||
op: '=',
|
||||
value: '/api/v1/users',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"message = 'user\\'s data' AND description = '' AND path = '/api/v1/users'",
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle IN operator with various value types and array formatting', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'service', type: 'string' },
|
||||
op: 'IN',
|
||||
value: ['api-gateway', 'user-service', 'auth-service'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'status', type: 'string' },
|
||||
op: 'IN',
|
||||
value: 'success', // Single value should be converted to array
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'tags', type: 'string' },
|
||||
op: 'IN',
|
||||
value: [], // Empty array
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'name', type: 'string' },
|
||||
op: 'IN',
|
||||
value: ["John's", "Mary's", 'Bob'], // Values with quotes
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"service in ['api-gateway', 'user-service', 'auth-service'] AND status in ['success'] AND tags in [] AND name in ['John\\'s', 'Mary\\'s', 'Bob']",
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert deprecated operators to their modern equivalents', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'service', type: 'string' },
|
||||
op: 'nin',
|
||||
value: ['api-gateway', 'user-service'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'message', type: 'string' },
|
||||
op: 'nlike',
|
||||
value: 'error',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'path', type: 'string' },
|
||||
op: 'nregex',
|
||||
value: '/api/.*',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'service', type: 'string' },
|
||||
op: 'NIN', // Test case insensitivity
|
||||
value: ['api-gateway'],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: { key: 'user_id', type: 'string' },
|
||||
op: 'nexists',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
key: { key: 'description', type: 'string' },
|
||||
op: 'ncontains',
|
||||
value: 'error',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
key: { key: 'tags', type: 'string' },
|
||||
op: 'nhas',
|
||||
value: 'production',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
key: { key: 'labels', type: 'string' },
|
||||
op: 'nhasany',
|
||||
value: ['env:prod', 'service:api'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"service NOT IN ['api-gateway', 'user-service'] AND message NOT LIKE 'error' AND path NOT REGEXP '/api/.*' AND service NOT IN ['api-gateway'] AND user_id NOT EXISTS AND description NOT CONTAINS 'error' AND NOT has(tags, 'production') AND NOT hasAny(labels, ['env:prod', 'service:api'])",
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-value operators and function operators', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'user_id', type: 'string' },
|
||||
op: 'EXISTS',
|
||||
value: '', // Value should be ignored for EXISTS
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'user_id', type: 'string' },
|
||||
op: 'EXISTS',
|
||||
value: 'some-value', // Value should be ignored for EXISTS
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'tags', type: 'string' },
|
||||
op: 'has',
|
||||
value: 'production',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'tags', type: 'string' },
|
||||
op: 'hasAny',
|
||||
value: ['production', 'staging'],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: { key: 'tags', type: 'string' },
|
||||
op: 'hasAll',
|
||||
value: ['production', 'monitoring'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"user_id exists AND user_id exists AND has(tags, 'production') AND hasAny(tags, ['production', 'staging']) AND hasAll(tags, ['production', 'monitoring'])",
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out invalid filters and handle edge cases', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'service', type: 'string' },
|
||||
op: '=',
|
||||
value: 'api-gateway',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: undefined, // Invalid filter - should be skipped
|
||||
op: '=',
|
||||
value: 'error',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: '', type: 'string' }, // Invalid filter with empty key - should be skipped
|
||||
op: '=',
|
||||
value: 'test',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'status', type: 'string' },
|
||||
op: ' = ', // Test whitespace handling
|
||||
value: 'success',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: { key: 'service', type: 'string' },
|
||||
op: 'In', // Test mixed case handling
|
||||
value: ['api-gateway'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"service = 'api-gateway' AND status = 'success' AND service in ['api-gateway']",
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex mixed operator scenarios with proper joining', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'service', type: 'string' },
|
||||
op: 'IN',
|
||||
value: ['api-gateway', 'user-service'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'user_id', type: 'string' },
|
||||
op: 'EXISTS',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'tags', type: 'string' },
|
||||
op: 'has',
|
||||
value: 'production',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'duration', type: 'number' },
|
||||
op: '>',
|
||||
value: 100,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: { key: 'status', type: 'string' },
|
||||
op: 'nin',
|
||||
value: ['error', 'timeout'],
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
key: { key: 'method', type: 'string' },
|
||||
op: '=',
|
||||
value: 'POST',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"service in ['api-gateway', 'user-service'] AND user_id exists AND has(tags, 'production') AND duration > 100 AND status NOT IN ['error', 'timeout'] AND method = 'POST'",
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle all numeric comparison operators and edge cases', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'count', type: 'number' },
|
||||
op: '=',
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'score', type: 'number' },
|
||||
op: '>',
|
||||
value: 100,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'limit', type: 'number' },
|
||||
op: '>=',
|
||||
value: 50,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'threshold', type: 'number' },
|
||||
op: '<',
|
||||
value: 1000,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: { key: 'max_value', type: 'number' },
|
||||
op: '<=',
|
||||
value: 999,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
key: { key: 'values', type: 'string' },
|
||||
op: 'IN',
|
||||
value: ['1', '2', '3', '4', '5'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"count = 0 AND score > 100 AND limit >= 50 AND threshold < 1000 AND max_value <= 999 AND values in ['1', '2', '3', '4', '5']",
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle boolean values and string comparisons with special characters', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'is_active', type: 'boolean' },
|
||||
op: '=',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'is_deleted', type: 'boolean' },
|
||||
op: '=',
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'email', type: 'string' },
|
||||
op: '=',
|
||||
value: 'user@example.com',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'description', type: 'string' },
|
||||
op: '=',
|
||||
value: 'Contains "quotes" and \'apostrophes\'',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: { key: 'path', type: 'string' },
|
||||
op: '=',
|
||||
value: '/api/v1/users/123?filter=true',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"is_active = true AND is_deleted = false AND email = 'user@example.com' AND description = 'Contains \"quotes\" and \\'apostrophes\\'' AND path = '/api/v1/users/123?filter=true'",
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle all function operators and complex array scenarios', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'tags', type: 'string' },
|
||||
op: 'has',
|
||||
value: 'production',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'labels', type: 'string' },
|
||||
op: 'hasAny',
|
||||
value: ['env:prod', 'service:api'],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'metadata', type: 'string' },
|
||||
op: 'hasAll',
|
||||
value: ['version:1.0', 'team:backend'],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'services', type: 'string' },
|
||||
op: 'IN',
|
||||
value: ['api-gateway', 'user-service', 'auth-service', 'payment-service'],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
key: { key: 'excluded_services', type: 'string' },
|
||||
op: 'nin',
|
||||
value: ['legacy-service', 'deprecated-service'],
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
key: { key: 'status_codes', type: 'string' },
|
||||
op: 'IN',
|
||||
value: ['200', '201', '400', '500'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"has(tags, 'production') AND hasAny(labels, ['env:prod', 'service:api']) AND hasAll(metadata, ['version:1.0', 'team:backend']) AND services in ['api-gateway', 'user-service', 'auth-service', 'payment-service'] AND excluded_services NOT IN ['legacy-service', 'deprecated-service'] AND status_codes in ['200', '201', '400', '500']",
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle specific deprecated operators: nhas, ncontains, nexists', () => {
|
||||
const filters: TagFilter = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
key: { key: 'user_id', type: 'string' },
|
||||
op: 'nexists',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
key: { key: 'description', type: 'string' },
|
||||
op: 'ncontains',
|
||||
value: 'error',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
key: { key: 'tags', type: 'string' },
|
||||
op: 'nhas',
|
||||
value: 'production',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
key: { key: 'labels', type: 'string' },
|
||||
op: 'nhasany',
|
||||
value: ['env:prod', 'service:api'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
const result = convertFiltersToExpression(filters);
|
||||
expect(result).toEqual({
|
||||
expression:
|
||||
"user_id NOT EXISTS AND description NOT CONTAINS 'error' AND NOT has(tags, 'production') AND NOT hasAny(labels, ['env:prod', 'service:api'])",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { createAggregation } from 'api/v5/queryRange/prepareQueryRangePayloadV5';
|
||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import {
|
||||
DEPRECATED_OPERATORS_MAP,
|
||||
OPERATORS,
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { IQueryPair } from 'types/antlrQueryTypes';
|
||||
@@ -21,7 +25,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 { isFunctionOperator, isNonValueOperator } from 'utils/tokenUtils';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
/**
|
||||
@@ -87,12 +91,32 @@ export const convertFiltersToExpression = (
|
||||
return '';
|
||||
}
|
||||
|
||||
if (isFunctionOperator(op)) {
|
||||
return `${op}(${key.key}, ${value})`;
|
||||
let operator = op.trim().toLowerCase();
|
||||
if (Object.keys(DEPRECATED_OPERATORS_MAP).includes(operator)) {
|
||||
operator =
|
||||
DEPRECATED_OPERATORS_MAP[
|
||||
operator as keyof typeof DEPRECATED_OPERATORS_MAP
|
||||
];
|
||||
}
|
||||
|
||||
const formattedValue = formatValueForExpression(value, op);
|
||||
return `${key.key} ${op} ${formattedValue}`;
|
||||
if (isNonValueOperator(operator)) {
|
||||
return `${key.key} ${operator}`;
|
||||
}
|
||||
|
||||
if (isFunctionOperator(operator)) {
|
||||
// Get the proper function name from QUERY_BUILDER_FUNCTIONS
|
||||
const functionOperators = Object.values(QUERY_BUILDER_FUNCTIONS);
|
||||
const properFunctionName =
|
||||
functionOperators.find(
|
||||
(func: string) => func.toLowerCase() === operator.toLowerCase(),
|
||||
) || operator;
|
||||
|
||||
const formattedValue = formatValueForExpression(value, operator);
|
||||
return `${properFunctionName}(${key.key}, ${formattedValue})`;
|
||||
}
|
||||
|
||||
const formattedValue = formatValueForExpression(value, operator);
|
||||
return `${key.key} ${operator} ${formattedValue}`;
|
||||
})
|
||||
.filter((expression) => expression !== ''); // Remove empty expressions
|
||||
|
||||
@@ -117,7 +141,6 @@ export const convertExpressionToFilters = (
|
||||
if (!expression) return [];
|
||||
|
||||
const queryPairs = extractQueryPairs(expression);
|
||||
|
||||
const filters: TagFilterItem[] = [];
|
||||
|
||||
queryPairs.forEach((pair) => {
|
||||
@@ -145,19 +168,36 @@ export const convertFiltersToExpressionWithExistingQuery = (
|
||||
filters: TagFilter,
|
||||
existingQuery: string | undefined,
|
||||
): { filters: TagFilter; filter: { expression: string } } => {
|
||||
// Check for deprecated operators and replace them with new operators
|
||||
const updatedFilters = cloneDeep(filters);
|
||||
|
||||
// Replace deprecated operators in filter items
|
||||
if (updatedFilters?.items) {
|
||||
updatedFilters.items = updatedFilters.items.map((item) => {
|
||||
const opLower = item.op?.toLowerCase();
|
||||
if (Object.keys(DEPRECATED_OPERATORS_MAP).includes(opLower)) {
|
||||
return {
|
||||
...item,
|
||||
op: DEPRECATED_OPERATORS_MAP[
|
||||
opLower as keyof typeof DEPRECATED_OPERATORS_MAP
|
||||
].toLowerCase(),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
if (!existingQuery) {
|
||||
// If no existing query, return filters with a newly generated expression
|
||||
return {
|
||||
filters,
|
||||
filter: convertFiltersToExpression(filters),
|
||||
filters: updatedFilters,
|
||||
filter: convertFiltersToExpression(updatedFilters),
|
||||
};
|
||||
}
|
||||
|
||||
// Extract query pairs from the existing query
|
||||
const queryPairs = extractQueryPairs(existingQuery.trim());
|
||||
let queryPairsMap: Map<string, IQueryPair> = new Map();
|
||||
|
||||
const updatedFilters = cloneDeep(filters); // Clone filters to avoid direct mutation
|
||||
const nonExistingFilters: TagFilterItem[] = [];
|
||||
let modifiedQuery = existingQuery; // We'll modify this query as we proceed
|
||||
const visitedPairs: Set<string> = new Set(); // Set to track visited query pairs
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,8 +5,11 @@ import { SignalType } from 'components/QuickFilters/types';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { useMemo } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -40,6 +43,10 @@ function OtherFilters({
|
||||
() => SIGNAL_DATA_SOURCE_MAP[signal as SignalType] === DataSource.LOGS,
|
||||
[signal],
|
||||
);
|
||||
const isMeterDataSource = useMemo(
|
||||
() => signal && signal === SignalType.METER_EXPLORER,
|
||||
[signal],
|
||||
);
|
||||
|
||||
const {
|
||||
data: suggestionsData,
|
||||
@@ -69,7 +76,22 @@ function OtherFilters({
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && !isLogDataSource,
|
||||
enabled: !!signal && !isLogDataSource && !isMeterDataSource,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: fieldKeysData,
|
||||
isLoading: isLoadingFieldKeys,
|
||||
} = useGetQueryKeySuggestions(
|
||||
{
|
||||
searchText: inputValue,
|
||||
signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
signalSource: 'meter',
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && isMeterDataSource,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -77,13 +99,33 @@ function OtherFilters({
|
||||
let filterAttributes;
|
||||
if (isLogDataSource) {
|
||||
filterAttributes = suggestionsData?.payload?.attributes || [];
|
||||
} else if (isMeterDataSource) {
|
||||
const fieldKeys: QueryKeyDataSuggestionsProps[] = Object.values(
|
||||
fieldKeysData?.data?.data?.keys || {},
|
||||
)?.flat();
|
||||
filterAttributes = fieldKeys.map(
|
||||
(attr) =>
|
||||
({
|
||||
key: attr.name,
|
||||
dataType: attr.fieldDataType,
|
||||
type: attr.fieldContext,
|
||||
signal: attr.signal,
|
||||
} as BaseAutocompleteData),
|
||||
);
|
||||
} else {
|
||||
filterAttributes = aggregateKeysData?.payload?.attributeKeys || [];
|
||||
}
|
||||
return filterAttributes?.filter(
|
||||
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
|
||||
);
|
||||
}, [suggestionsData, aggregateKeysData, addedFilters, isLogDataSource]);
|
||||
}, [
|
||||
suggestionsData,
|
||||
aggregateKeysData,
|
||||
addedFilters,
|
||||
isLogDataSource,
|
||||
fieldKeysData,
|
||||
isMeterDataSource,
|
||||
]);
|
||||
|
||||
const handleAddFilter = (filter: FilterType): void => {
|
||||
setAddedFilters((prev) => [
|
||||
@@ -99,7 +141,8 @@ function OtherFilters({
|
||||
};
|
||||
|
||||
const renderFilters = (): React.ReactNode => {
|
||||
const isLoading = isFetchingSuggestions || isFetchingAggregateKeys;
|
||||
const isLoading =
|
||||
isFetchingSuggestions || isFetchingAggregateKeys || isLoadingFieldKeys;
|
||||
if (isLoading) return <OtherFiltersSkeleton />;
|
||||
if (!otherFilters?.length)
|
||||
return <div className="no-values-found">No values found</div>;
|
||||
|
||||
@@ -6,4 +6,5 @@ export const SIGNAL_DATA_SOURCE_MAP = {
|
||||
[SignalType.TRACES]: DataSource.TRACES,
|
||||
[SignalType.EXCEPTIONS]: DataSource.TRACES,
|
||||
[SignalType.API_MONITORING]: DataSource.TRACES,
|
||||
[SignalType.METER_EXPLORER]: DataSource.METRICS,
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/${SIGNAL}`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v1/orgs/me/filters`;
|
||||
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v3/filter_suggestions`;
|
||||
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
|
||||
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
|
||||
|
||||
const FILTER_OS_DESCRIPTION = 'os.description';
|
||||
const FILTER_K8S_DEPLOYMENT_NAME = 'k8s.deployment.name';
|
||||
@@ -77,7 +78,11 @@ const setupServer = (): void => {
|
||||
putHandler(await req.json());
|
||||
return res(ctx.status(200), ctx.json({}));
|
||||
}),
|
||||
rest.get(quickFiltersAttributeValuesURL, (_, res, ctx) =>
|
||||
|
||||
rest.get(quickFiltersAttributeValuesURL, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||
),
|
||||
rest.get(fieldsValuesURL, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ export enum SignalType {
|
||||
LOGS = 'logs',
|
||||
API_MONITORING = 'api_monitoring',
|
||||
EXCEPTIONS = 'exceptions',
|
||||
METER_EXPLORER = 'meter',
|
||||
}
|
||||
|
||||
export interface IQuickFiltersConfig {
|
||||
@@ -53,4 +54,5 @@ export enum QuickFiltersSource {
|
||||
TRACES_EXPLORER = 'traces-explorer',
|
||||
API_MONITORING = 'api-monitoring',
|
||||
EXCEPTIONS = 'exceptions',
|
||||
METER_EXPLORER = 'meter',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { Select } from 'antd';
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
|
||||
import { UniversalYAxisUnitMappings, Y_AXIS_CATEGORIES } from './constants';
|
||||
import { UniversalYAxisUnit, YAxisUnitSelectorProps } from './types';
|
||||
import { mapMetricUnitToUniversalUnit } from './utils';
|
||||
|
||||
function YAxisUnitSelector({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Please select a unit',
|
||||
loading = false,
|
||||
}: YAxisUnitSelectorProps): JSX.Element {
|
||||
const universalUnit = mapMetricUnitToUniversalUnit(value);
|
||||
|
||||
const handleSearch = (
|
||||
searchTerm: string,
|
||||
currentOption: DefaultOptionType | undefined,
|
||||
): boolean => {
|
||||
if (!currentOption?.value) return false;
|
||||
|
||||
const search = searchTerm.toLowerCase();
|
||||
const unitId = currentOption.value.toString().toLowerCase();
|
||||
const unitLabel = currentOption.children?.toString().toLowerCase() || '';
|
||||
|
||||
// Check label and id
|
||||
if (unitId.includes(search) || unitLabel.includes(search)) return true;
|
||||
|
||||
// Check aliases (from the mapping) using array iteration
|
||||
const aliases = Array.from(
|
||||
UniversalYAxisUnitMappings[currentOption.value as UniversalYAxisUnit] ?? [],
|
||||
);
|
||||
|
||||
return aliases.some((alias) => alias.toLowerCase().includes(search));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="y-axis-unit-selector-component">
|
||||
<Select
|
||||
showSearch
|
||||
value={universalUnit}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
filterOption={(input, option): boolean => handleSearch(input, option)}
|
||||
loading={loading}
|
||||
>
|
||||
{Y_AXIS_CATEGORIES.map((category) => (
|
||||
<Select.OptGroup key={category.name} label={category.name}>
|
||||
{category.units.map((unit) => (
|
||||
<Select.Option key={unit.id} value={unit.id}>
|
||||
{unit.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select.OptGroup>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default YAxisUnitSelector;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import YAxisUnitSelector from '../YAxisUnitSelector';
|
||||
|
||||
describe('YAxisUnitSelector', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnChange.mockClear();
|
||||
});
|
||||
|
||||
it('renders with default placeholder', () => {
|
||||
render(<YAxisUnitSelector value="" onChange={mockOnChange} />);
|
||||
expect(screen.getByText('Please select a unit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with custom placeholder', () => {
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
value=""
|
||||
onChange={mockOnChange}
|
||||
placeholder="Custom placeholder"
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Custom placeholder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange when a value is selected', () => {
|
||||
render(<YAxisUnitSelector value="" onChange={mockOnChange} />);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
const option = screen.getByText('Bytes (B)');
|
||||
fireEvent.click(option);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('By', {
|
||||
children: 'Bytes (B)',
|
||||
key: 'By',
|
||||
value: 'By',
|
||||
});
|
||||
});
|
||||
|
||||
it('filters options based on search input', () => {
|
||||
render(<YAxisUnitSelector value="" onChange={mockOnChange} />);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'byte' } });
|
||||
|
||||
expect(screen.getByText('Bytes/sec')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all categories and their units', () => {
|
||||
render(<YAxisUnitSelector value="" onChange={mockOnChange} />);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
|
||||
// Check for category headers
|
||||
expect(screen.getByText('Data')).toBeInTheDocument();
|
||||
expect(screen.getByText('Time')).toBeInTheDocument();
|
||||
|
||||
// Check for some common units
|
||||
expect(screen.getByText('Bytes (B)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Seconds (s)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
getUniversalNameFromMetricUnit,
|
||||
mapMetricUnitToUniversalUnit,
|
||||
} from '../utils';
|
||||
|
||||
describe('YAxisUnitSelector utils', () => {
|
||||
describe('mapMetricUnitToUniversalUnit', () => {
|
||||
it('maps known units correctly', () => {
|
||||
expect(mapMetricUnitToUniversalUnit('bytes')).toBe('By');
|
||||
expect(mapMetricUnitToUniversalUnit('seconds')).toBe('s');
|
||||
expect(mapMetricUnitToUniversalUnit('bytes_per_second')).toBe('By/s');
|
||||
});
|
||||
|
||||
it('returns null or self for unknown units', () => {
|
||||
expect(mapMetricUnitToUniversalUnit('unknown_unit')).toBe('unknown_unit');
|
||||
expect(mapMetricUnitToUniversalUnit('')).toBe(null);
|
||||
expect(mapMetricUnitToUniversalUnit(undefined)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUniversalNameFromMetricUnit', () => {
|
||||
it('returns human readable names for known units', () => {
|
||||
expect(getUniversalNameFromMetricUnit('bytes')).toBe('Bytes (B)');
|
||||
expect(getUniversalNameFromMetricUnit('seconds')).toBe('Seconds (s)');
|
||||
expect(getUniversalNameFromMetricUnit('bytes_per_second')).toBe('Bytes/sec');
|
||||
});
|
||||
|
||||
it('returns original unit for unknown units', () => {
|
||||
expect(getUniversalNameFromMetricUnit('unknown_unit')).toBe('unknown_unit');
|
||||
expect(getUniversalNameFromMetricUnit('')).toBe('-');
|
||||
expect(getUniversalNameFromMetricUnit(undefined)).toBe('-');
|
||||
});
|
||||
|
||||
it('handles case variations', () => {
|
||||
expect(getUniversalNameFromMetricUnit('bytes')).toBe('Bytes (B)');
|
||||
expect(getUniversalNameFromMetricUnit('s')).toBe('Seconds (s)');
|
||||
});
|
||||
});
|
||||
});
|
||||
627
frontend/src/components/YAxisUnitSelector/constants.ts
Normal file
627
frontend/src/components/YAxisUnitSelector/constants.ts
Normal file
@@ -0,0 +1,627 @@
|
||||
import { UniversalYAxisUnit, YAxisUnit } from './types';
|
||||
|
||||
// Mapping of universal y-axis units to their AWS, UCUM, and OpenMetrics equivalents
|
||||
export const UniversalYAxisUnitMappings: Record<
|
||||
UniversalYAxisUnit,
|
||||
Set<YAxisUnit>
|
||||
> = {
|
||||
// Time
|
||||
[UniversalYAxisUnit.NANOSECONDS]: new Set([
|
||||
YAxisUnit.UCUM_NANOSECONDS,
|
||||
YAxisUnit.OPEN_METRICS_NANOSECONDS,
|
||||
]),
|
||||
[UniversalYAxisUnit.MICROSECONDS]: new Set([
|
||||
YAxisUnit.AWS_MICROSECONDS,
|
||||
YAxisUnit.UCUM_MICROSECONDS,
|
||||
YAxisUnit.OPEN_METRICS_MICROSECONDS,
|
||||
]),
|
||||
[UniversalYAxisUnit.MILLISECONDS]: new Set([
|
||||
YAxisUnit.AWS_MILLISECONDS,
|
||||
YAxisUnit.UCUM_MILLISECONDS,
|
||||
YAxisUnit.OPEN_METRICS_MILLISECONDS,
|
||||
]),
|
||||
[UniversalYAxisUnit.SECONDS]: new Set([
|
||||
YAxisUnit.AWS_SECONDS,
|
||||
YAxisUnit.UCUM_SECONDS,
|
||||
YAxisUnit.OPEN_METRICS_SECONDS,
|
||||
]),
|
||||
[UniversalYAxisUnit.MINUTES]: new Set([
|
||||
YAxisUnit.UCUM_MINUTES,
|
||||
YAxisUnit.OPEN_METRICS_MINUTES,
|
||||
]),
|
||||
[UniversalYAxisUnit.HOURS]: new Set([
|
||||
YAxisUnit.UCUM_HOURS,
|
||||
YAxisUnit.OPEN_METRICS_HOURS,
|
||||
]),
|
||||
[UniversalYAxisUnit.DAYS]: new Set([
|
||||
YAxisUnit.UCUM_DAYS,
|
||||
YAxisUnit.OPEN_METRICS_DAYS,
|
||||
]),
|
||||
[UniversalYAxisUnit.WEEKS]: new Set([YAxisUnit.UCUM_WEEKS]),
|
||||
|
||||
// Data
|
||||
[UniversalYAxisUnit.BYTES]: new Set([
|
||||
YAxisUnit.AWS_BYTES,
|
||||
YAxisUnit.UCUM_BYTES,
|
||||
YAxisUnit.OPEN_METRICS_BYTES,
|
||||
]),
|
||||
[UniversalYAxisUnit.KILOBYTES]: new Set([
|
||||
YAxisUnit.AWS_KILOBYTES,
|
||||
YAxisUnit.UCUM_KILOBYTES,
|
||||
YAxisUnit.OPEN_METRICS_KILOBYTES,
|
||||
]),
|
||||
[UniversalYAxisUnit.MEGABYTES]: new Set([
|
||||
YAxisUnit.AWS_MEGABYTES,
|
||||
YAxisUnit.UCUM_MEGABYTES,
|
||||
YAxisUnit.OPEN_METRICS_MEGABYTES,
|
||||
]),
|
||||
[UniversalYAxisUnit.GIGABYTES]: new Set([
|
||||
YAxisUnit.AWS_GIGABYTES,
|
||||
YAxisUnit.UCUM_GIGABYTES,
|
||||
YAxisUnit.OPEN_METRICS_GIGABYTES,
|
||||
]),
|
||||
[UniversalYAxisUnit.TERABYTES]: new Set([
|
||||
YAxisUnit.AWS_TERABYTES,
|
||||
YAxisUnit.UCUM_TERABYTES,
|
||||
YAxisUnit.OPEN_METRICS_TERABYTES,
|
||||
]),
|
||||
[UniversalYAxisUnit.PETABYTES]: new Set([
|
||||
YAxisUnit.AWS_PETABYTES,
|
||||
YAxisUnit.UCUM_PEBIBYTES,
|
||||
YAxisUnit.OPEN_METRICS_PEBIBYTES,
|
||||
]),
|
||||
[UniversalYAxisUnit.EXABYTES]: new Set([
|
||||
YAxisUnit.AWS_EXABYTES,
|
||||
YAxisUnit.UCUM_EXABYTES,
|
||||
YAxisUnit.OPEN_METRICS_EXABYTES,
|
||||
]),
|
||||
[UniversalYAxisUnit.ZETTABYTES]: new Set([
|
||||
YAxisUnit.AWS_ZETTABYTES,
|
||||
YAxisUnit.UCUM_ZETTABYTES,
|
||||
YAxisUnit.OPEN_METRICS_ZETTABYTES,
|
||||
]),
|
||||
[UniversalYAxisUnit.YOTTABYTES]: new Set([
|
||||
YAxisUnit.AWS_YOTTABYTES,
|
||||
YAxisUnit.UCUM_YOTTABYTES,
|
||||
YAxisUnit.OPEN_METRICS_YOTTABYTES,
|
||||
]),
|
||||
|
||||
// Data Rate
|
||||
[UniversalYAxisUnit.BYTES_SECOND]: new Set([
|
||||
YAxisUnit.AWS_BYTES_SECOND,
|
||||
YAxisUnit.UCUM_BYTES_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_BYTES_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.KILOBYTES_SECOND]: new Set([
|
||||
YAxisUnit.AWS_KILOBYTES_SECOND,
|
||||
YAxisUnit.UCUM_KILOBYTES_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_KILOBYTES_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.MEGABYTES_SECOND]: new Set([
|
||||
YAxisUnit.AWS_MEGABYTES_SECOND,
|
||||
YAxisUnit.UCUM_MEGABYTES_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_MEGABYTES_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.GIGABYTES_SECOND]: new Set([
|
||||
YAxisUnit.AWS_GIGABYTES_SECOND,
|
||||
YAxisUnit.UCUM_GIGABYTES_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_GIGABYTES_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.TERABYTES_SECOND]: new Set([
|
||||
YAxisUnit.AWS_TERABYTES_SECOND,
|
||||
YAxisUnit.UCUM_TERABYTES_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_TERABYTES_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.PETABYTES_SECOND]: new Set([
|
||||
YAxisUnit.AWS_PETABYTES_SECOND,
|
||||
YAxisUnit.UCUM_PETABYTES_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_PETABYTES_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.EXABYTES_SECOND]: new Set([
|
||||
YAxisUnit.AWS_EXABYTES_SECOND,
|
||||
YAxisUnit.UCUM_EXABYTES_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_EXABYTES_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.ZETTABYTES_SECOND]: new Set([
|
||||
YAxisUnit.AWS_ZETTABYTES_SECOND,
|
||||
YAxisUnit.UCUM_ZETTABYTES_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_ZETTABYTES_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.YOTTABYTES_SECOND]: new Set([
|
||||
YAxisUnit.AWS_YOTTABYTES_SECOND,
|
||||
YAxisUnit.UCUM_YOTTABYTES_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_YOTTABYTES_SECOND,
|
||||
]),
|
||||
|
||||
// Bits
|
||||
[UniversalYAxisUnit.BITS]: new Set([
|
||||
YAxisUnit.AWS_BITS,
|
||||
YAxisUnit.UCUM_BITS,
|
||||
YAxisUnit.OPEN_METRICS_BITS,
|
||||
]),
|
||||
[UniversalYAxisUnit.KILOBITS]: new Set([
|
||||
YAxisUnit.AWS_KILOBITS,
|
||||
YAxisUnit.UCUM_KILOBITS,
|
||||
YAxisUnit.OPEN_METRICS_KILOBITS,
|
||||
]),
|
||||
[UniversalYAxisUnit.MEGABITS]: new Set([
|
||||
YAxisUnit.AWS_MEGABITS,
|
||||
YAxisUnit.UCUM_MEGABITS,
|
||||
YAxisUnit.OPEN_METRICS_MEGABITS,
|
||||
]),
|
||||
[UniversalYAxisUnit.GIGABITS]: new Set([
|
||||
YAxisUnit.AWS_GIGABITS,
|
||||
YAxisUnit.UCUM_GIGABITS,
|
||||
YAxisUnit.OPEN_METRICS_GIGABITS,
|
||||
]),
|
||||
[UniversalYAxisUnit.TERABITS]: new Set([
|
||||
YAxisUnit.AWS_TERABITS,
|
||||
YAxisUnit.UCUM_TERABITS,
|
||||
YAxisUnit.OPEN_METRICS_TERABITS,
|
||||
]),
|
||||
[UniversalYAxisUnit.PETABITS]: new Set([
|
||||
YAxisUnit.AWS_PETABITS,
|
||||
YAxisUnit.UCUM_PETABITS,
|
||||
YAxisUnit.OPEN_METRICS_PETABITS,
|
||||
]),
|
||||
[UniversalYAxisUnit.EXABITS]: new Set([
|
||||
YAxisUnit.AWS_EXABITS,
|
||||
YAxisUnit.UCUM_EXABITS,
|
||||
YAxisUnit.OPEN_METRICS_EXABITS,
|
||||
]),
|
||||
[UniversalYAxisUnit.ZETTABITS]: new Set([
|
||||
YAxisUnit.AWS_ZETTABITS,
|
||||
YAxisUnit.UCUM_ZETTABITS,
|
||||
YAxisUnit.OPEN_METRICS_ZETTABITS,
|
||||
]),
|
||||
[UniversalYAxisUnit.YOTTABITS]: new Set([
|
||||
YAxisUnit.AWS_YOTTABITS,
|
||||
YAxisUnit.UCUM_YOTTABITS,
|
||||
YAxisUnit.OPEN_METRICS_YOTTABITS,
|
||||
]),
|
||||
|
||||
// Bit Rate
|
||||
[UniversalYAxisUnit.BITS_SECOND]: new Set([
|
||||
YAxisUnit.AWS_BITS_SECOND,
|
||||
YAxisUnit.UCUM_BITS_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_BITS_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.KILOBITS_SECOND]: new Set([
|
||||
YAxisUnit.AWS_KILOBITS_SECOND,
|
||||
YAxisUnit.UCUM_KILOBITS_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_KILOBITS_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.MEGABITS_SECOND]: new Set([
|
||||
YAxisUnit.AWS_MEGABITS_SECOND,
|
||||
YAxisUnit.UCUM_MEGABITS_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_MEGABITS_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.GIGABITS_SECOND]: new Set([
|
||||
YAxisUnit.AWS_GIGABITS_SECOND,
|
||||
YAxisUnit.UCUM_GIGABITS_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_GIGABITS_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.TERABITS_SECOND]: new Set([
|
||||
YAxisUnit.AWS_TERABITS_SECOND,
|
||||
YAxisUnit.UCUM_TERABITS_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_TERABITS_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.PETABITS_SECOND]: new Set([
|
||||
YAxisUnit.AWS_PETABITS_SECOND,
|
||||
YAxisUnit.UCUM_PETABITS_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_PETABITS_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.EXABITS_SECOND]: new Set([
|
||||
YAxisUnit.AWS_EXABITS_SECOND,
|
||||
YAxisUnit.UCUM_EXABITS_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_EXABITS_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.ZETTABITS_SECOND]: new Set([
|
||||
YAxisUnit.AWS_ZETTABITS_SECOND,
|
||||
YAxisUnit.UCUM_ZETTABITS_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_ZETTABITS_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.YOTTABITS_SECOND]: new Set([
|
||||
YAxisUnit.AWS_YOTTABITS_SECOND,
|
||||
YAxisUnit.UCUM_YOTTABITS_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_YOTTABITS_SECOND,
|
||||
]),
|
||||
|
||||
// Count
|
||||
[UniversalYAxisUnit.COUNT]: new Set([
|
||||
YAxisUnit.AWS_COUNT,
|
||||
YAxisUnit.UCUM_COUNT,
|
||||
YAxisUnit.OPEN_METRICS_COUNT,
|
||||
]),
|
||||
[UniversalYAxisUnit.COUNT_SECOND]: new Set([
|
||||
YAxisUnit.AWS_COUNT_SECOND,
|
||||
YAxisUnit.UCUM_COUNT_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_COUNT_SECOND,
|
||||
]),
|
||||
|
||||
// Percent
|
||||
[UniversalYAxisUnit.PERCENT]: new Set([
|
||||
YAxisUnit.AWS_PERCENT,
|
||||
YAxisUnit.UCUM_PERCENT,
|
||||
YAxisUnit.OPEN_METRICS_PERCENT,
|
||||
]),
|
||||
[UniversalYAxisUnit.NONE]: new Set([
|
||||
YAxisUnit.AWS_NONE,
|
||||
YAxisUnit.UCUM_NONE,
|
||||
YAxisUnit.OPEN_METRICS_NONE,
|
||||
]),
|
||||
[UniversalYAxisUnit.PERCENT_UNIT]: new Set([
|
||||
YAxisUnit.OPEN_METRICS_PERCENT_UNIT,
|
||||
]),
|
||||
|
||||
// Count Rate
|
||||
[UniversalYAxisUnit.COUNT_MINUTE]: new Set([
|
||||
YAxisUnit.UCUM_COUNTS_MINUTE,
|
||||
YAxisUnit.OPEN_METRICS_COUNTS_MINUTE,
|
||||
]),
|
||||
[UniversalYAxisUnit.OPS_SECOND]: new Set([
|
||||
YAxisUnit.UCUM_OPS_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_OPS_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.OPS_MINUTE]: new Set([
|
||||
YAxisUnit.UCUM_OPS_MINUTE,
|
||||
YAxisUnit.OPEN_METRICS_OPS_MINUTE,
|
||||
]),
|
||||
[UniversalYAxisUnit.REQUESTS_SECOND]: new Set([
|
||||
YAxisUnit.UCUM_REQUESTS_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_REQUESTS_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.REQUESTS_MINUTE]: new Set([
|
||||
YAxisUnit.UCUM_REQUESTS_MINUTE,
|
||||
YAxisUnit.OPEN_METRICS_REQUESTS_MINUTE,
|
||||
]),
|
||||
[UniversalYAxisUnit.READS_SECOND]: new Set([
|
||||
YAxisUnit.UCUM_READS_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_READS_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.WRITES_SECOND]: new Set([
|
||||
YAxisUnit.UCUM_WRITES_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_WRITES_SECOND,
|
||||
]),
|
||||
[UniversalYAxisUnit.READS_MINUTE]: new Set([
|
||||
YAxisUnit.UCUM_READS_MINUTE,
|
||||
YAxisUnit.OPEN_METRICS_READS_MINUTE,
|
||||
]),
|
||||
[UniversalYAxisUnit.WRITES_MINUTE]: new Set([
|
||||
YAxisUnit.UCUM_WRITES_MINUTE,
|
||||
YAxisUnit.OPEN_METRICS_WRITES_MINUTE,
|
||||
]),
|
||||
[UniversalYAxisUnit.IOOPS_SECOND]: new Set([
|
||||
YAxisUnit.UCUM_IOPS_SECOND,
|
||||
YAxisUnit.OPEN_METRICS_IOPS_SECOND,
|
||||
]),
|
||||
};
|
||||
|
||||
// Mapping of universal y-axis units to their display labels
|
||||
export const Y_AXIS_UNIT_NAMES: Record<UniversalYAxisUnit, string> = {
|
||||
[UniversalYAxisUnit.SECONDS]: 'Seconds (s)',
|
||||
[UniversalYAxisUnit.MILLISECONDS]: 'Milliseconds (ms)',
|
||||
[UniversalYAxisUnit.MICROSECONDS]: 'Microseconds (µs)',
|
||||
[UniversalYAxisUnit.BYTES]: 'Bytes (B)',
|
||||
[UniversalYAxisUnit.KILOBYTES]: 'Kilobytes (KB)',
|
||||
[UniversalYAxisUnit.MEGABYTES]: 'Megabytes (MB)',
|
||||
[UniversalYAxisUnit.GIGABYTES]: 'Gigabytes (GB)',
|
||||
[UniversalYAxisUnit.TERABYTES]: 'Terabytes (TB)',
|
||||
[UniversalYAxisUnit.PETABYTES]: 'Petabytes (PB)',
|
||||
[UniversalYAxisUnit.EXABYTES]: 'Exabytes (EB)',
|
||||
[UniversalYAxisUnit.ZETTABYTES]: 'Zettabytes (ZB)',
|
||||
[UniversalYAxisUnit.YOTTABYTES]: 'Yottabytes (YB)',
|
||||
[UniversalYAxisUnit.BITS]: 'Bits (b)',
|
||||
[UniversalYAxisUnit.KILOBITS]: 'Kilobits (Kb)',
|
||||
[UniversalYAxisUnit.MEGABITS]: 'Megabits (Mb)',
|
||||
[UniversalYAxisUnit.GIGABITS]: 'Gigabits (Gb)',
|
||||
[UniversalYAxisUnit.TERABITS]: 'Terabits (Tb)',
|
||||
[UniversalYAxisUnit.PETABITS]: 'Petabits (Pb)',
|
||||
[UniversalYAxisUnit.EXABITS]: 'Exabits (Eb)',
|
||||
[UniversalYAxisUnit.ZETTABITS]: 'Zettabits (Zb)',
|
||||
[UniversalYAxisUnit.YOTTABITS]: 'Yottabits (Yb)',
|
||||
[UniversalYAxisUnit.BYTES_SECOND]: 'Bytes/sec',
|
||||
[UniversalYAxisUnit.KILOBYTES_SECOND]: 'Kilobytes/sec',
|
||||
[UniversalYAxisUnit.MEGABYTES_SECOND]: 'Megabytes/sec',
|
||||
[UniversalYAxisUnit.GIGABYTES_SECOND]: 'Gigabytes/sec',
|
||||
[UniversalYAxisUnit.TERABYTES_SECOND]: 'Terabytes/sec',
|
||||
[UniversalYAxisUnit.PETABYTES_SECOND]: 'Petabytes/sec',
|
||||
[UniversalYAxisUnit.EXABYTES_SECOND]: 'Exabytes/sec',
|
||||
[UniversalYAxisUnit.ZETTABYTES_SECOND]: 'Zettabytes/sec',
|
||||
[UniversalYAxisUnit.YOTTABYTES_SECOND]: 'Yottabytes/sec',
|
||||
[UniversalYAxisUnit.BITS_SECOND]: 'Bits/sec',
|
||||
[UniversalYAxisUnit.KILOBITS_SECOND]: 'Kilobits/sec',
|
||||
[UniversalYAxisUnit.MEGABITS_SECOND]: 'Megabits/sec',
|
||||
[UniversalYAxisUnit.GIGABITS_SECOND]: 'Gigabits/sec',
|
||||
[UniversalYAxisUnit.TERABITS_SECOND]: 'Terabits/sec',
|
||||
[UniversalYAxisUnit.PETABITS_SECOND]: 'Petabits/sec',
|
||||
[UniversalYAxisUnit.EXABITS_SECOND]: 'Exabits/sec',
|
||||
[UniversalYAxisUnit.ZETTABITS_SECOND]: 'Zettabits/sec',
|
||||
[UniversalYAxisUnit.YOTTABITS_SECOND]: 'Yottabits/sec',
|
||||
[UniversalYAxisUnit.COUNT]: 'Count',
|
||||
[UniversalYAxisUnit.COUNT_SECOND]: 'Count/sec',
|
||||
[UniversalYAxisUnit.PERCENT]: 'Percent (0 - 100)',
|
||||
[UniversalYAxisUnit.NONE]: 'None',
|
||||
[UniversalYAxisUnit.WEEKS]: 'Weeks',
|
||||
[UniversalYAxisUnit.DAYS]: 'Days',
|
||||
[UniversalYAxisUnit.HOURS]: 'Hours',
|
||||
[UniversalYAxisUnit.MINUTES]: 'Minutes',
|
||||
[UniversalYAxisUnit.NANOSECONDS]: 'Nanoseconds',
|
||||
[UniversalYAxisUnit.COUNT_MINUTE]: 'Count/min',
|
||||
[UniversalYAxisUnit.OPS_SECOND]: 'Ops/sec',
|
||||
[UniversalYAxisUnit.OPS_MINUTE]: 'Ops/min',
|
||||
[UniversalYAxisUnit.REQUESTS_SECOND]: 'Requests/sec',
|
||||
[UniversalYAxisUnit.REQUESTS_MINUTE]: 'Requests/min',
|
||||
[UniversalYAxisUnit.READS_SECOND]: 'Reads/sec',
|
||||
[UniversalYAxisUnit.WRITES_SECOND]: 'Writes/sec',
|
||||
[UniversalYAxisUnit.READS_MINUTE]: 'Reads/min',
|
||||
[UniversalYAxisUnit.WRITES_MINUTE]: 'Writes/min',
|
||||
[UniversalYAxisUnit.IOOPS_SECOND]: 'IOPS/sec',
|
||||
[UniversalYAxisUnit.PERCENT_UNIT]: 'Percent (0.0 - 1.0)',
|
||||
};
|
||||
|
||||
// Splitting the universal y-axis units into categories
|
||||
export const Y_AXIS_CATEGORIES = [
|
||||
{
|
||||
name: 'Time',
|
||||
units: [
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.SECONDS],
|
||||
id: UniversalYAxisUnit.SECONDS,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MILLISECONDS],
|
||||
id: UniversalYAxisUnit.MILLISECONDS,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MICROSECONDS],
|
||||
id: UniversalYAxisUnit.MICROSECONDS,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.NANOSECONDS],
|
||||
id: UniversalYAxisUnit.NANOSECONDS,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MINUTES],
|
||||
id: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.HOURS],
|
||||
id: UniversalYAxisUnit.HOURS,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.DAYS],
|
||||
id: UniversalYAxisUnit.DAYS,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.WEEKS],
|
||||
id: UniversalYAxisUnit.WEEKS,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Data',
|
||||
units: [
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.BYTES],
|
||||
id: UniversalYAxisUnit.BYTES,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.KILOBYTES],
|
||||
id: UniversalYAxisUnit.KILOBYTES,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MEGABYTES],
|
||||
id: UniversalYAxisUnit.MEGABYTES,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.GIGABYTES],
|
||||
id: UniversalYAxisUnit.GIGABYTES,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.TERABYTES],
|
||||
id: UniversalYAxisUnit.TERABYTES,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PETABYTES],
|
||||
id: UniversalYAxisUnit.PETABYTES,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXABYTES],
|
||||
id: UniversalYAxisUnit.EXABYTES,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZETTABYTES],
|
||||
id: UniversalYAxisUnit.ZETTABYTES,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOTTABYTES],
|
||||
id: UniversalYAxisUnit.YOTTABYTES,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.BITS],
|
||||
id: UniversalYAxisUnit.BITS,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.KILOBITS],
|
||||
id: UniversalYAxisUnit.KILOBITS,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MEGABITS],
|
||||
id: UniversalYAxisUnit.MEGABITS,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.GIGABITS],
|
||||
id: UniversalYAxisUnit.GIGABITS,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.TERABITS],
|
||||
id: UniversalYAxisUnit.TERABITS,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PETABITS],
|
||||
id: UniversalYAxisUnit.PETABITS,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXABITS],
|
||||
id: UniversalYAxisUnit.EXABITS,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZETTABITS],
|
||||
id: UniversalYAxisUnit.ZETTABITS,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOTTABITS],
|
||||
id: UniversalYAxisUnit.YOTTABITS,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Data Rate',
|
||||
units: [
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.BYTES_SECOND],
|
||||
id: UniversalYAxisUnit.BYTES_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.KILOBYTES_SECOND],
|
||||
id: UniversalYAxisUnit.KILOBYTES_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MEGABYTES_SECOND],
|
||||
id: UniversalYAxisUnit.MEGABYTES_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.GIGABYTES_SECOND],
|
||||
id: UniversalYAxisUnit.GIGABYTES_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.TERABYTES_SECOND],
|
||||
id: UniversalYAxisUnit.TERABYTES_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PETABYTES_SECOND],
|
||||
id: UniversalYAxisUnit.PETABYTES_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXABYTES_SECOND],
|
||||
id: UniversalYAxisUnit.EXABYTES_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZETTABYTES_SECOND],
|
||||
id: UniversalYAxisUnit.ZETTABYTES_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOTTABYTES_SECOND],
|
||||
id: UniversalYAxisUnit.YOTTABYTES_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.BITS_SECOND],
|
||||
id: UniversalYAxisUnit.BITS_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.KILOBITS_SECOND],
|
||||
id: UniversalYAxisUnit.KILOBITS_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.MEGABITS_SECOND],
|
||||
id: UniversalYAxisUnit.MEGABITS_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.GIGABITS_SECOND],
|
||||
id: UniversalYAxisUnit.GIGABITS_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.TERABITS_SECOND],
|
||||
id: UniversalYAxisUnit.TERABITS_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PETABITS_SECOND],
|
||||
id: UniversalYAxisUnit.PETABITS_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.EXABITS_SECOND],
|
||||
id: UniversalYAxisUnit.EXABITS_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.ZETTABITS_SECOND],
|
||||
id: UniversalYAxisUnit.ZETTABITS_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.YOTTABITS_SECOND],
|
||||
id: UniversalYAxisUnit.YOTTABITS_SECOND,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Count',
|
||||
units: [
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.COUNT],
|
||||
id: UniversalYAxisUnit.COUNT,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.COUNT_SECOND],
|
||||
id: UniversalYAxisUnit.COUNT_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.COUNT_MINUTE],
|
||||
id: UniversalYAxisUnit.COUNT_MINUTE,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Operations',
|
||||
units: [
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.OPS_SECOND],
|
||||
id: UniversalYAxisUnit.OPS_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.OPS_MINUTE],
|
||||
id: UniversalYAxisUnit.OPS_MINUTE,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.REQUESTS_SECOND],
|
||||
id: UniversalYAxisUnit.REQUESTS_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.REQUESTS_MINUTE],
|
||||
id: UniversalYAxisUnit.REQUESTS_MINUTE,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.READS_SECOND],
|
||||
id: UniversalYAxisUnit.READS_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.WRITES_SECOND],
|
||||
id: UniversalYAxisUnit.WRITES_SECOND,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.READS_MINUTE],
|
||||
id: UniversalYAxisUnit.READS_MINUTE,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.WRITES_MINUTE],
|
||||
id: UniversalYAxisUnit.WRITES_MINUTE,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.IOOPS_SECOND],
|
||||
id: UniversalYAxisUnit.IOOPS_SECOND,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Percentage',
|
||||
units: [
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PERCENT],
|
||||
id: UniversalYAxisUnit.PERCENT,
|
||||
},
|
||||
{
|
||||
name: Y_AXIS_UNIT_NAMES[UniversalYAxisUnit.PERCENT_UNIT],
|
||||
id: UniversalYAxisUnit.PERCENT_UNIT,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
3
frontend/src/components/YAxisUnitSelector/index.ts
Normal file
3
frontend/src/components/YAxisUnitSelector/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import YAxisUnitSelector from './YAxisUnitSelector';
|
||||
|
||||
export default YAxisUnitSelector;
|
||||
5
frontend/src/components/YAxisUnitSelector/styles.scss
Normal file
5
frontend/src/components/YAxisUnitSelector/styles.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.y-axis-unit-selector-component {
|
||||
.ant-select {
|
||||
width: 220px;
|
||||
}
|
||||
}
|
||||
365
frontend/src/components/YAxisUnitSelector/types.ts
Normal file
365
frontend/src/components/YAxisUnitSelector/types.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
export interface YAxisUnitSelectorProps {
|
||||
value: string | undefined;
|
||||
onChange: (value: UniversalYAxisUnit) => void;
|
||||
placeholder?: string;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export enum UniversalYAxisUnit {
|
||||
// Time
|
||||
WEEKS = 'wk',
|
||||
DAYS = 'd',
|
||||
HOURS = 'h',
|
||||
MINUTES = 'min',
|
||||
SECONDS = 's',
|
||||
MICROSECONDS = 'us',
|
||||
MILLISECONDS = 'ms',
|
||||
NANOSECONDS = 'ns',
|
||||
|
||||
// Data
|
||||
BYTES = 'By',
|
||||
KILOBYTES = 'kBy',
|
||||
MEGABYTES = 'MBy',
|
||||
GIGABYTES = 'GBy',
|
||||
TERABYTES = 'TBy',
|
||||
PETABYTES = 'PBy',
|
||||
EXABYTES = 'EBy',
|
||||
ZETTABYTES = 'ZBy',
|
||||
YOTTABYTES = 'YBy',
|
||||
|
||||
// Data Rate
|
||||
BYTES_SECOND = 'By/s',
|
||||
KILOBYTES_SECOND = 'kBy/s',
|
||||
MEGABYTES_SECOND = 'MBy/s',
|
||||
GIGABYTES_SECOND = 'GBy/s',
|
||||
TERABYTES_SECOND = 'TBy/s',
|
||||
PETABYTES_SECOND = 'PBy/s',
|
||||
EXABYTES_SECOND = 'EBy/s',
|
||||
ZETTABYTES_SECOND = 'ZBy/s',
|
||||
YOTTABYTES_SECOND = 'YBy/s',
|
||||
|
||||
// Bits
|
||||
BITS = 'bit',
|
||||
KILOBITS = 'kbit',
|
||||
MEGABITS = 'Mbit',
|
||||
GIGABITS = 'Gbit',
|
||||
TERABITS = 'Tbit',
|
||||
PETABITS = 'Pbit',
|
||||
EXABITS = 'Ebit',
|
||||
ZETTABITS = 'Zbit',
|
||||
YOTTABITS = 'Ybit',
|
||||
|
||||
// Bit Rate
|
||||
BITS_SECOND = 'bit/s',
|
||||
KILOBITS_SECOND = 'kbit/s',
|
||||
MEGABITS_SECOND = 'Mbit/s',
|
||||
GIGABITS_SECOND = 'Gbit/s',
|
||||
TERABITS_SECOND = 'Tbit/s',
|
||||
PETABITS_SECOND = 'Pbit/s',
|
||||
EXABITS_SECOND = 'Ebit/s',
|
||||
ZETTABITS_SECOND = 'Zbit/s',
|
||||
YOTTABITS_SECOND = 'Ybit/s',
|
||||
|
||||
// Count
|
||||
COUNT = '{count}',
|
||||
COUNT_SECOND = '{count}/s',
|
||||
COUNT_MINUTE = '{count}/min',
|
||||
|
||||
// Operations
|
||||
OPS_SECOND = '{ops}/s',
|
||||
OPS_MINUTE = '{ops}/min',
|
||||
|
||||
// Requests
|
||||
REQUESTS_SECOND = '{req}/s',
|
||||
REQUESTS_MINUTE = '{req}/min',
|
||||
|
||||
// Reads/Writes
|
||||
READS_SECOND = '{read}/s',
|
||||
WRITES_SECOND = '{write}/s',
|
||||
READS_MINUTE = '{read}/min',
|
||||
WRITES_MINUTE = '{write}/min',
|
||||
|
||||
// IO Operations
|
||||
IOOPS_SECOND = '{iops}/s',
|
||||
|
||||
// Percent
|
||||
PERCENT = '%',
|
||||
PERCENT_UNIT = 'percentunit',
|
||||
NONE = '1',
|
||||
}
|
||||
|
||||
export enum YAxisUnit {
|
||||
AWS_SECONDS = 'Seconds',
|
||||
UCUM_SECONDS = 's',
|
||||
OPEN_METRICS_SECONDS = 'seconds',
|
||||
|
||||
AWS_MICROSECONDS = 'Microseconds',
|
||||
UCUM_MICROSECONDS = 'us',
|
||||
OPEN_METRICS_MICROSECONDS = 'microseconds',
|
||||
|
||||
AWS_MILLISECONDS = 'Milliseconds',
|
||||
UCUM_MILLISECONDS = 'ms',
|
||||
OPEN_METRICS_MILLISECONDS = 'milliseconds',
|
||||
|
||||
AWS_BYTES = 'Bytes',
|
||||
UCUM_BYTES = 'By',
|
||||
OPEN_METRICS_BYTES = 'bytes',
|
||||
|
||||
AWS_KILOBYTES = 'Kilobytes',
|
||||
UCUM_KILOBYTES = 'kBy',
|
||||
OPEN_METRICS_KILOBYTES = 'kilobytes',
|
||||
|
||||
AWS_MEGABYTES = 'Megabytes',
|
||||
UCUM_MEGABYTES = 'MBy',
|
||||
OPEN_METRICS_MEGABYTES = 'megabytes',
|
||||
|
||||
AWS_GIGABYTES = 'Gigabytes',
|
||||
UCUM_GIGABYTES = 'GBy',
|
||||
OPEN_METRICS_GIGABYTES = 'gigabytes',
|
||||
|
||||
AWS_TERABYTES = 'Terabytes',
|
||||
UCUM_TERABYTES = 'TBy',
|
||||
OPEN_METRICS_TERABYTES = 'terabytes',
|
||||
|
||||
AWS_PETABYTES = 'Petabytes',
|
||||
UCUM_PETABYTES = 'PBy',
|
||||
OPEN_METRICS_PETABYTES = 'petabytes',
|
||||
|
||||
AWS_EXABYTES = 'Exabytes',
|
||||
UCUM_EXABYTES = 'EBy',
|
||||
OPEN_METRICS_EXABYTES = 'exabytes',
|
||||
|
||||
AWS_ZETTABYTES = 'Zettabytes',
|
||||
UCUM_ZETTABYTES = 'ZBy',
|
||||
OPEN_METRICS_ZETTABYTES = 'zettabytes',
|
||||
|
||||
AWS_YOTTABYTES = 'Yottabytes',
|
||||
UCUM_YOTTABYTES = 'YBy',
|
||||
OPEN_METRICS_YOTTABYTES = 'yottabytes',
|
||||
|
||||
AWS_BYTES_SECOND = 'Bytes/Second',
|
||||
UCUM_BYTES_SECOND = 'By/s',
|
||||
OPEN_METRICS_BYTES_SECOND = 'bytes_per_second',
|
||||
|
||||
AWS_KILOBYTES_SECOND = 'Kilobytes/Second',
|
||||
UCUM_KILOBYTES_SECOND = 'kBy/s',
|
||||
OPEN_METRICS_KILOBYTES_SECOND = 'kilobytes_per_second',
|
||||
|
||||
AWS_MEGABYTES_SECOND = 'Megabytes/Second',
|
||||
UCUM_MEGABYTES_SECOND = 'MBy/s',
|
||||
OPEN_METRICS_MEGABYTES_SECOND = 'megabytes_per_second',
|
||||
|
||||
AWS_GIGABYTES_SECOND = 'Gigabytes/Second',
|
||||
UCUM_GIGABYTES_SECOND = 'GBy/s',
|
||||
OPEN_METRICS_GIGABYTES_SECOND = 'gigabytes_per_second',
|
||||
|
||||
AWS_TERABYTES_SECOND = 'Terabytes/Second',
|
||||
UCUM_TERABYTES_SECOND = 'TBy/s',
|
||||
OPEN_METRICS_TERABYTES_SECOND = 'terabytes_per_second',
|
||||
|
||||
AWS_PETABYTES_SECOND = 'Petabytes/Second',
|
||||
UCUM_PETABYTES_SECOND = 'PBy/s',
|
||||
OPEN_METRICS_PETABYTES_SECOND = 'petabytes_per_second',
|
||||
|
||||
AWS_EXABYTES_SECOND = 'Exabytes/Second',
|
||||
UCUM_EXABYTES_SECOND = 'EBy/s',
|
||||
OPEN_METRICS_EXABYTES_SECOND = 'exabytes_per_second',
|
||||
|
||||
AWS_ZETTABYTES_SECOND = 'Zettabytes/Second',
|
||||
UCUM_ZETTABYTES_SECOND = 'ZBy/s',
|
||||
OPEN_METRICS_ZETTABYTES_SECOND = 'zettabytes_per_second',
|
||||
|
||||
AWS_YOTTABYTES_SECOND = 'Yottabytes/Second',
|
||||
UCUM_YOTTABYTES_SECOND = 'YBy/s',
|
||||
OPEN_METRICS_YOTTABYTES_SECOND = 'yottabytes_per_second',
|
||||
|
||||
AWS_BITS = 'Bits',
|
||||
UCUM_BITS = 'bit',
|
||||
OPEN_METRICS_BITS = 'bits',
|
||||
|
||||
AWS_KILOBITS = 'Kilobits',
|
||||
UCUM_KILOBITS = 'kbit',
|
||||
OPEN_METRICS_KILOBITS = 'kilobits',
|
||||
|
||||
AWS_MEGABITS = 'Megabits',
|
||||
UCUM_MEGABITS = 'Mbit',
|
||||
OPEN_METRICS_MEGABITS = 'megabits',
|
||||
|
||||
AWS_GIGABITS = 'Gigabits',
|
||||
UCUM_GIGABITS = 'Gbit',
|
||||
OPEN_METRICS_GIGABITS = 'gigabits',
|
||||
|
||||
AWS_TERABITS = 'Terabits',
|
||||
UCUM_TERABITS = 'Tbit',
|
||||
OPEN_METRICS_TERABITS = 'terabits',
|
||||
|
||||
AWS_PETABITS = 'Petabits',
|
||||
UCUM_PETABITS = 'Pbit',
|
||||
OPEN_METRICS_PETABITS = 'petabits',
|
||||
|
||||
AWS_EXABITS = 'Exabits',
|
||||
UCUM_EXABITS = 'Ebit',
|
||||
OPEN_METRICS_EXABITS = 'exabits',
|
||||
|
||||
AWS_ZETTABITS = 'Zettabits',
|
||||
UCUM_ZETTABITS = 'Zbit',
|
||||
OPEN_METRICS_ZETTABITS = 'zettabits',
|
||||
|
||||
AWS_YOTTABITS = 'Yottabits',
|
||||
UCUM_YOTTABITS = 'Ybit',
|
||||
OPEN_METRICS_YOTTABITS = 'yottabits',
|
||||
|
||||
AWS_BITS_SECOND = 'Bits/Second',
|
||||
UCUM_BITS_SECOND = 'bit/s',
|
||||
OPEN_METRICS_BITS_SECOND = 'bits_per_second',
|
||||
|
||||
AWS_KILOBITS_SECOND = 'Kilobits/Second',
|
||||
UCUM_KILOBITS_SECOND = 'kbit/s',
|
||||
OPEN_METRICS_KILOBITS_SECOND = 'kilobits_per_second',
|
||||
|
||||
AWS_MEGABITS_SECOND = 'Megabits/Second',
|
||||
UCUM_MEGABITS_SECOND = 'Mbit/s',
|
||||
OPEN_METRICS_MEGABITS_SECOND = 'megabits_per_second',
|
||||
|
||||
AWS_GIGABITS_SECOND = 'Gigabits/Second',
|
||||
UCUM_GIGABITS_SECOND = 'Gbit/s',
|
||||
OPEN_METRICS_GIGABITS_SECOND = 'gigabits_per_second',
|
||||
|
||||
AWS_TERABITS_SECOND = 'Terabits/Second',
|
||||
UCUM_TERABITS_SECOND = 'Tbit/s',
|
||||
OPEN_METRICS_TERABITS_SECOND = 'terabits_per_second',
|
||||
|
||||
AWS_PETABITS_SECOND = 'Petabits/Second',
|
||||
UCUM_PETABITS_SECOND = 'Pbit/s',
|
||||
OPEN_METRICS_PETABITS_SECOND = 'petabits_per_second',
|
||||
|
||||
AWS_EXABITS_SECOND = 'Exabits/Second',
|
||||
UCUM_EXABITS_SECOND = 'Ebit/s',
|
||||
OPEN_METRICS_EXABITS_SECOND = 'exabits_per_second',
|
||||
|
||||
AWS_ZETTABITS_SECOND = 'Zettabits/Second',
|
||||
UCUM_ZETTABITS_SECOND = 'Zbit/s',
|
||||
OPEN_METRICS_ZETTABITS_SECOND = 'zettabits_per_second',
|
||||
|
||||
AWS_YOTTABITS_SECOND = 'Yottabits/Second',
|
||||
UCUM_YOTTABITS_SECOND = 'Ybit/s',
|
||||
OPEN_METRICS_YOTTABITS_SECOND = 'yottabits_per_second',
|
||||
|
||||
AWS_COUNT = 'Count',
|
||||
UCUM_COUNT = '{count}',
|
||||
OPEN_METRICS_COUNT = 'count',
|
||||
|
||||
AWS_COUNT_SECOND = 'Count/Second',
|
||||
UCUM_COUNT_SECOND = '{count}/s',
|
||||
OPEN_METRICS_COUNT_SECOND = 'count_per_second',
|
||||
|
||||
AWS_PERCENT = 'Percent',
|
||||
UCUM_PERCENT = '%',
|
||||
OPEN_METRICS_PERCENT = 'ratio',
|
||||
|
||||
AWS_NONE = 'None',
|
||||
UCUM_NONE = '1',
|
||||
OPEN_METRICS_NONE = 'none',
|
||||
|
||||
UCUM_NANOSECONDS = 'ns',
|
||||
OPEN_METRICS_NANOSECONDS = 'nanoseconds',
|
||||
|
||||
UCUM_MINUTES = 'min',
|
||||
OPEN_METRICS_MINUTES = 'minutes',
|
||||
|
||||
UCUM_HOURS = 'h',
|
||||
OPEN_METRICS_HOURS = 'hours',
|
||||
|
||||
UCUM_DAYS = 'd',
|
||||
OPEN_METRICS_DAYS = 'days',
|
||||
|
||||
UCUM_WEEKS = 'wk',
|
||||
OPEN_METRICS_WEEKS = 'weeks',
|
||||
|
||||
UCUM_KIBIBYTES = 'KiBy',
|
||||
OPEN_METRICS_KIBIBYTES = 'kibibytes',
|
||||
|
||||
UCUM_MEBIBYTES = 'MiBy',
|
||||
OPEN_METRICS_MEBIBYTES = 'mebibytes',
|
||||
|
||||
UCUM_GIBIBYTES = 'GiBy',
|
||||
OPEN_METRICS_GIBIBYTES = 'gibibytes',
|
||||
|
||||
UCUM_TEBIBYTES = 'TiBy',
|
||||
OPEN_METRICS_TEBIBYTES = 'tebibytes',
|
||||
|
||||
UCUM_PEBIBYTES = 'PiBy',
|
||||
OPEN_METRICS_PEBIBYTES = 'pebibytes',
|
||||
|
||||
UCUM_KIBIBYTES_SECOND = 'KiBy/s',
|
||||
OPEN_METRICS_KIBIBYTES_SECOND = 'kibibytes_per_second',
|
||||
|
||||
UCUM_KIBIBITS_SECOND = 'Kibit/s',
|
||||
OPEN_METRICS_KIBIBITS_SECOND = 'kibibits_per_second',
|
||||
|
||||
UCUM_MEBIBYTES_SECOND = 'MiBy/s',
|
||||
OPEN_METRICS_MEBIBYTES_SECOND = 'mebibytes_per_second',
|
||||
|
||||
UCUM_MEBIBITS_SECOND = 'Mibit/s',
|
||||
OPEN_METRICS_MEBIBITS_SECOND = 'mebibits_per_second',
|
||||
|
||||
UCUM_GIBIBYTES_SECOND = 'GiBy/s',
|
||||
OPEN_METRICS_GIBIBYTES_SECOND = 'gibibytes_per_second',
|
||||
|
||||
UCUM_GIBIBITS_SECOND = 'Gibit/s',
|
||||
OPEN_METRICS_GIBIBITS_SECOND = 'gibibits_per_second',
|
||||
|
||||
UCUM_TEBIBYTES_SECOND = 'TiBy/s',
|
||||
OPEN_METRICS_TEBIBYTES_SECOND = 'tebibytes_per_second',
|
||||
|
||||
UCUM_TEBIBITS_SECOND = 'Tibit/s',
|
||||
OPEN_METRICS_TEBIBITS_SECOND = 'tebibits_per_second',
|
||||
|
||||
UCUM_PEBIBYTES_SECOND = 'PiBy/s',
|
||||
OPEN_METRICS_PEBIBYTES_SECOND = 'pebibytes_per_second',
|
||||
|
||||
UCUM_PEBIBITS_SECOND = 'Pibit/s',
|
||||
OPEN_METRICS_PEBIBITS_SECOND = 'pebibits_per_second',
|
||||
|
||||
UCUM_TRUE_FALSE = '{bool}',
|
||||
OPEN_METRICS_TRUE_FALSE = 'boolean_true_false',
|
||||
|
||||
UCUM_YES_NO = '{bool}',
|
||||
OPEN_METRICS_YES_NO = 'boolean_yes_no',
|
||||
|
||||
UCUM_COUNTS_SECOND = '{count}/s',
|
||||
OPEN_METRICS_COUNTS_SECOND = 'counts_per_second',
|
||||
|
||||
UCUM_OPS_SECOND = '{ops}/s',
|
||||
OPEN_METRICS_OPS_SECOND = 'ops_per_second',
|
||||
|
||||
UCUM_REQUESTS_SECOND = '{requests}/s',
|
||||
OPEN_METRICS_REQUESTS_SECOND = 'requests_per_second',
|
||||
|
||||
UCUM_REQUESTS_MINUTE = '{requests}/min',
|
||||
OPEN_METRICS_REQUESTS_MINUTE = 'requests_per_minute',
|
||||
|
||||
UCUM_READS_SECOND = '{reads}/s',
|
||||
OPEN_METRICS_READS_SECOND = 'reads_per_second',
|
||||
|
||||
UCUM_WRITES_SECOND = '{writes}/s',
|
||||
OPEN_METRICS_WRITES_SECOND = 'writes_per_second',
|
||||
|
||||
UCUM_IOPS_SECOND = '{iops}/s',
|
||||
OPEN_METRICS_IOPS_SECOND = 'io_ops_per_second',
|
||||
|
||||
UCUM_COUNTS_MINUTE = '{count}/min',
|
||||
OPEN_METRICS_COUNTS_MINUTE = 'counts_per_minute',
|
||||
|
||||
UCUM_OPS_MINUTE = '{ops}/min',
|
||||
OPEN_METRICS_OPS_MINUTE = 'ops_per_minute',
|
||||
|
||||
UCUM_READS_MINUTE = '{reads}/min',
|
||||
OPEN_METRICS_READS_MINUTE = 'reads_per_minute',
|
||||
|
||||
UCUM_WRITES_MINUTE = '{writes}/min',
|
||||
OPEN_METRICS_WRITES_MINUTE = 'writes_per_minute',
|
||||
|
||||
OPEN_METRICS_PERCENT_UNIT = 'percentunit',
|
||||
}
|
||||
33
frontend/src/components/YAxisUnitSelector/utils.tsx
Normal file
33
frontend/src/components/YAxisUnitSelector/utils.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { UniversalYAxisUnitMappings, Y_AXIS_UNIT_NAMES } from './constants';
|
||||
import { UniversalYAxisUnit, YAxisUnit } from './types';
|
||||
|
||||
export const mapMetricUnitToUniversalUnit = (
|
||||
unit: string | undefined,
|
||||
): UniversalYAxisUnit | null => {
|
||||
if (!unit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const universalUnit = Object.values(UniversalYAxisUnit).find(
|
||||
(u) => UniversalYAxisUnitMappings[u].has(unit as YAxisUnit) || unit === u,
|
||||
);
|
||||
|
||||
return universalUnit || (unit as UniversalYAxisUnit) || null;
|
||||
};
|
||||
|
||||
export const getUniversalNameFromMetricUnit = (
|
||||
unit: string | undefined,
|
||||
): string => {
|
||||
if (!unit) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const universalUnit = mapMetricUnitToUniversalUnit(unit);
|
||||
if (!universalUnit) {
|
||||
return unit;
|
||||
}
|
||||
|
||||
const universalName = Y_AXIS_UNIT_NAMES[universalUnit];
|
||||
|
||||
return universalName || unit || '-';
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
export const OPERATORS = {
|
||||
IN: 'IN',
|
||||
LIKE: 'LIKE',
|
||||
@@ -21,6 +23,44 @@ export const QUERY_BUILDER_FUNCTIONS = {
|
||||
HASALL: 'hasAll',
|
||||
};
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
export enum DEPRECATED_OPERATORS {
|
||||
REGEX = 'regex',
|
||||
NIN = 'nin',
|
||||
NREGEX = 'nregex',
|
||||
NLIKE = 'nlike',
|
||||
NILIKE = 'nilike',
|
||||
NEXTISTS = 'nexists',
|
||||
NCONTAINS = 'ncontains',
|
||||
NHAS = 'nhas',
|
||||
NHASANY = 'nhasany',
|
||||
NHASALL = 'nhasall',
|
||||
}
|
||||
|
||||
export const DEPRECATED_OPERATORS_MAP = {
|
||||
[DEPRECATED_OPERATORS.REGEX]: OPERATORS.REGEXP,
|
||||
[DEPRECATED_OPERATORS.NIN]: negateOperator(OPERATORS.IN),
|
||||
[DEPRECATED_OPERATORS.NREGEX]: negateOperator(OPERATORS.REGEXP),
|
||||
[DEPRECATED_OPERATORS.NLIKE]: negateOperator(OPERATORS.LIKE),
|
||||
[DEPRECATED_OPERATORS.NILIKE]: negateOperator(OPERATORS.ILIKE),
|
||||
[DEPRECATED_OPERATORS.NEXTISTS]: negateOperator(OPERATORS.EXISTS),
|
||||
[DEPRECATED_OPERATORS.NCONTAINS]: negateOperator(OPERATORS.CONTAINS),
|
||||
[DEPRECATED_OPERATORS.NHAS]: negateOperator(QUERY_BUILDER_FUNCTIONS.HAS),
|
||||
[DEPRECATED_OPERATORS.NHASANY]: negateOperator(QUERY_BUILDER_FUNCTIONS.HASANY),
|
||||
[DEPRECATED_OPERATORS.NHASALL]: negateOperator(QUERY_BUILDER_FUNCTIONS.HASALL),
|
||||
};
|
||||
|
||||
export const NON_VALUE_OPERATORS = [OPERATORS.EXISTS];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -82,15 +122,3 @@ 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}`;
|
||||
}
|
||||
|
||||
@@ -49,4 +49,5 @@ export enum QueryParams {
|
||||
tab = 'tab',
|
||||
thresholds = 'thresholds',
|
||||
selectedExplorerView = 'selectedExplorerView',
|
||||
variableConfigs = 'variableConfigs',
|
||||
}
|
||||
|
||||
@@ -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: '/meter',
|
||||
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', {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,7 +13,6 @@ import { isEqual } from 'lodash-es';
|
||||
import isEmpty from 'lodash-es/isEmpty';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -62,14 +61,12 @@ function GridCardGraph({
|
||||
const {
|
||||
toScrollWidgetId,
|
||||
setToScrollWidgetId,
|
||||
variablesToGetUpdated,
|
||||
setDashboardQueryRangeCalled,
|
||||
} = useDashboard();
|
||||
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleBackNavigation = (): void => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
@@ -120,11 +117,7 @@ function GridCardGraph({
|
||||
const isEmptyWidget =
|
||||
widget?.id === PANEL_TYPES.EMPTY_WIDGET || isEmpty(widget);
|
||||
|
||||
const queryEnabledCondition =
|
||||
isVisible &&
|
||||
!isEmptyWidget &&
|
||||
isQueryEnabled &&
|
||||
isEmpty(variablesToGetUpdated);
|
||||
const queryEnabledCondition = isVisible && !isEmptyWidget && isQueryEnabled;
|
||||
|
||||
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
|
||||
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
||||
@@ -163,22 +156,24 @@ function GridCardGraph({
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (variablesToGetUpdated.length > 0) {
|
||||
queryClient.cancelQueries([
|
||||
maxTime,
|
||||
minTime,
|
||||
globalSelectedInterval,
|
||||
variables,
|
||||
widget?.query,
|
||||
widget?.panelTypes,
|
||||
widget.timePreferance,
|
||||
widget.fillSpans,
|
||||
requestData,
|
||||
]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [variablesToGetUpdated]);
|
||||
// TODO [vikrantgupta25] remove this useEffect with refactor as this is prone to race condition
|
||||
// this is added to tackle the case of async communication between VariableItem.tsx and GridCard.tsx
|
||||
// useEffect(() => {
|
||||
// if (variablesToGetUpdated.length > 0) {
|
||||
// queryClient.cancelQueries([
|
||||
// maxTime,
|
||||
// minTime,
|
||||
// globalSelectedInterval,
|
||||
// variables,
|
||||
// widget?.query,
|
||||
// widget?.panelTypes,
|
||||
// widget.timePreferance,
|
||||
// widget.fillSpans,
|
||||
// requestData,
|
||||
// ]);
|
||||
// }
|
||||
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// }, [variablesToGetUpdated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEqual(updatedQuery, requestData.query)) {
|
||||
@@ -224,6 +219,15 @@ function GridCardGraph({
|
||||
widget.timePreferance,
|
||||
widget.fillSpans,
|
||||
requestData,
|
||||
variables
|
||||
? Object.entries(variables).reduce(
|
||||
(acc, [id, variable]) => ({
|
||||
...acc,
|
||||
[id]: variable.selectedValue,
|
||||
}),
|
||||
{},
|
||||
)
|
||||
: {},
|
||||
...(customTimeRange && customTimeRange.startTime && customTimeRange.endTime
|
||||
? [customTimeRange.startTime, customTimeRange.endTime]
|
||||
: []),
|
||||
|
||||
@@ -520,12 +520,6 @@ function ClusterDetails({
|
||||
>
|
||||
Cluster Name
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
className="entity-details-metadata-label"
|
||||
>
|
||||
Cluster Name
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="values-row">
|
||||
<Typography.Text className="entity-details-metadata-value">
|
||||
@@ -533,9 +527,6 @@ function ClusterDetails({
|
||||
{cluster.meta.k8s_cluster_name}
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
<Typography.Text className="entity-details-metadata-value">
|
||||
<Tooltip title="Cluster name">{cluster.meta.k8s_cluster_name}</Tooltip>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { K8sCategory } from 'container/InfraMonitoringK8s/constants';
|
||||
import { Time } from 'container/TopNav/DateTimeSelectionV2/config';
|
||||
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import * as appContextHooks from 'providers/App/App';
|
||||
import { LicenseEvent } from 'types/api/licensesV3/getActive';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import EntityEvents from '../EntityEvents';
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="date-time-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockUseQuery = jest.fn();
|
||||
jest.mock('react-query', () => ({
|
||||
useQuery: (queryKey: any, queryFn: any, options: any): any =>
|
||||
mockUseQuery(queryKey, queryFn, options),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}/`,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
|
||||
user: {
|
||||
role: 'admin',
|
||||
},
|
||||
activeLicenseV3: {
|
||||
event_queue: {
|
||||
created_at: '0',
|
||||
event: LicenseEvent.NO_EVENT,
|
||||
scheduled_at: '0',
|
||||
status: '',
|
||||
updated_at: '0',
|
||||
},
|
||||
license: {
|
||||
license_key: 'test-license-key',
|
||||
license_type: 'trial',
|
||||
org_id: 'test-org-id',
|
||||
plan_id: 'test-plan-id',
|
||||
plan_name: 'test-plan-name',
|
||||
plan_type: 'trial',
|
||||
plan_version: 'test-plan-version',
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const mockUseQueryBuilderData = {
|
||||
handleRunQuery: jest.fn(),
|
||||
stagedQuery: initialQueriesMap[DataSource.METRICS],
|
||||
updateAllQueriesOperators: jest.fn(),
|
||||
currentQuery: initialQueriesMap[DataSource.METRICS],
|
||||
resetQuery: jest.fn(),
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
isStagedQueryUpdated: jest.fn(),
|
||||
handleSetQueryData: jest.fn(),
|
||||
handleSetFormulaData: jest.fn(),
|
||||
handleSetQueryItemData: jest.fn(),
|
||||
handleSetConfig: jest.fn(),
|
||||
removeQueryBuilderEntityByIndex: jest.fn(),
|
||||
removeQueryTypeItemByIndex: jest.fn(),
|
||||
isDefaultQuery: jest.fn(),
|
||||
};
|
||||
|
||||
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
|
||||
...mockUseQueryBuilderData,
|
||||
} as any);
|
||||
|
||||
const timeRange = {
|
||||
startTime: 1718236800,
|
||||
endTime: 1718236800,
|
||||
};
|
||||
|
||||
const mockHandleChangeEventFilters = jest.fn();
|
||||
|
||||
const mockFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'pod-name',
|
||||
key: {
|
||||
id: 'pod-name',
|
||||
dataType: DataTypes.String,
|
||||
isColumn: true,
|
||||
key: 'pod-name',
|
||||
type: 'tag',
|
||||
isJSON: false,
|
||||
isIndexed: false,
|
||||
},
|
||||
op: '=',
|
||||
value: 'pod-1',
|
||||
},
|
||||
],
|
||||
op: 'and',
|
||||
};
|
||||
|
||||
const isModalTimeSelection = false;
|
||||
const mockHandleTimeChange = jest.fn();
|
||||
const selectedInterval: Time = '1m';
|
||||
const category = K8sCategory.PODS;
|
||||
const queryKey = 'pod-events';
|
||||
|
||||
const mockEventsData = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: [
|
||||
{
|
||||
timestamp: '2024-01-15T10:00:00Z',
|
||||
data: {
|
||||
id: 'event-1',
|
||||
severity_text: 'INFO',
|
||||
body: 'Test event 1',
|
||||
resources_string: { 'pod.name': 'test-pod-1' },
|
||||
attributes_string: { service: 'test-service' },
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamp: '2024-01-15T10:01:00Z',
|
||||
data: {
|
||||
id: 'event-2',
|
||||
severity_text: 'WARN',
|
||||
body: 'Test event 2',
|
||||
resources_string: { 'pod.name': 'test-pod-2' },
|
||||
attributes_string: { service: 'test-service' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockEmptyEventsData = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const createMockEvent = (
|
||||
id: string,
|
||||
severity: string,
|
||||
body: string,
|
||||
podName: string,
|
||||
): any => ({
|
||||
timestamp: `2024-01-15T10:${id.padStart(2, '0')}:00Z`,
|
||||
data: {
|
||||
id: `event-${id}`,
|
||||
severity_text: severity,
|
||||
body,
|
||||
resources_string: { 'pod.name': podName },
|
||||
attributes_string: { service: 'test-service' },
|
||||
},
|
||||
});
|
||||
|
||||
const createMockMoreEventsData = (): any => ({
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: Array.from({ length: 11 }, (_, i) =>
|
||||
createMockEvent(
|
||||
String(i + 1),
|
||||
['INFO', 'WARN', 'ERROR', 'DEBUG'][i % 4],
|
||||
`Test event ${i + 1}`,
|
||||
`test-pod-${i + 1}`,
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderEntityEvents = (overrides = {}): any => {
|
||||
const defaultProps = {
|
||||
timeRange,
|
||||
handleChangeEventFilters: mockHandleChangeEventFilters,
|
||||
filters: mockFilters,
|
||||
isModalTimeSelection,
|
||||
handleTimeChange: mockHandleTimeChange,
|
||||
selectedInterval,
|
||||
category,
|
||||
queryKey,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return render(
|
||||
<EntityEvents
|
||||
timeRange={defaultProps.timeRange}
|
||||
handleChangeEventFilters={defaultProps.handleChangeEventFilters}
|
||||
filters={defaultProps.filters}
|
||||
isModalTimeSelection={defaultProps.isModalTimeSelection}
|
||||
handleTimeChange={defaultProps.handleTimeChange}
|
||||
selectedInterval={defaultProps.selectedInterval}
|
||||
category={defaultProps.category}
|
||||
queryKey={defaultProps.queryKey}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('EntityEvents', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: mockEventsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render events list with data', () => {
|
||||
renderEntityEvents();
|
||||
expect(screen.getByText('Prev')).toBeInTheDocument();
|
||||
expect(screen.getByText('Next')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test event 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test event 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('INFO')).toBeInTheDocument();
|
||||
expect(screen.getByText('WARN')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no events are found', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: mockEmptyEventsData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
renderEntityEvents();
|
||||
expect(screen.getByText(/No events found for this pods/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loader when fetching events', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
isFetching: true,
|
||||
});
|
||||
|
||||
renderEntityEvents();
|
||||
expect(screen.getByTestId('loader')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows pagination controls when events are present', () => {
|
||||
renderEntityEvents();
|
||||
expect(screen.getByText('Prev')).toBeInTheDocument();
|
||||
expect(screen.getByText('Next')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables Prev button on first page', () => {
|
||||
renderEntityEvents();
|
||||
const prevButton = screen.getByText('Prev').closest('button');
|
||||
expect(prevButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables Next button when more events are available', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: createMockMoreEventsData(),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
renderEntityEvents();
|
||||
const nextButton = screen.getByText('Next').closest('button');
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('navigates to next page when Next button is clicked', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: createMockMoreEventsData(),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
renderEntityEvents();
|
||||
|
||||
const nextButton = screen.getByText('Next').closest('button');
|
||||
expect(nextButton).not.toBeNull();
|
||||
fireEvent.click(nextButton as Element);
|
||||
|
||||
const { calls } = mockUseQuery.mock;
|
||||
const hasPage2Call = calls.some((call) => {
|
||||
const { queryKey: callQueryKey } = call[0] || {};
|
||||
return Array.isArray(callQueryKey) && callQueryKey.includes(2);
|
||||
});
|
||||
expect(hasPage2Call).toBe(true);
|
||||
});
|
||||
|
||||
it('navigates to previous page when Prev button is clicked', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: createMockMoreEventsData(),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
renderEntityEvents();
|
||||
|
||||
const nextButton = screen.getByText('Next').closest('button');
|
||||
expect(nextButton).not.toBeNull();
|
||||
fireEvent.click(nextButton as Element);
|
||||
|
||||
const prevButton = screen.getByText('Prev').closest('button');
|
||||
expect(prevButton).not.toBeNull();
|
||||
fireEvent.click(prevButton as Element);
|
||||
|
||||
const { calls } = mockUseQuery.mock;
|
||||
const hasPage1Call = calls.some((call) => {
|
||||
const { queryKey: callQueryKey } = call[0] || {};
|
||||
return Array.isArray(callQueryKey) && callQueryKey.includes(1);
|
||||
});
|
||||
expect(hasPage1Call).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { K8sCategory } from 'container/InfraMonitoringK8s/constants';
|
||||
import { Time } from 'container/TopNav/DateTimeSelectionV2/config';
|
||||
import * as appContextHooks from 'providers/App/App';
|
||||
import { LicenseEvent } from 'types/api/licensesV3/getActive';
|
||||
|
||||
import EntityMetrics from '../EntityMetrics';
|
||||
|
||||
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
|
||||
getUPlotChartOptions: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
jest.mock('lib/uPlotLib/utils/getUplotChartData', () => ({
|
||||
getUPlotChartData: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="date-time-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('components/Uplot', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="uplot-chart">Uplot Chart</div>,
|
||||
}));
|
||||
|
||||
jest.mock('container/InfraMonitoringK8s/commonUtils', () => ({
|
||||
__esModule: true,
|
||||
getMetricsTableData: jest.fn().mockReturnValue([
|
||||
{
|
||||
rows: [
|
||||
{ data: { timestamp: '2024-01-15T10:00:00Z', value: '42.5' } },
|
||||
{ data: { timestamp: '2024-01-15T10:01:00Z', value: '43.2' } },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'timestamp', label: 'Timestamp', isValueColumn: false },
|
||||
{ key: 'value', label: 'Value', isValueColumn: true },
|
||||
],
|
||||
},
|
||||
]),
|
||||
MetricsTable: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(): JSX.Element => <div data-testid="metrics-table">Metrics Table</div>,
|
||||
),
|
||||
}));
|
||||
|
||||
const mockUseQueries = jest.fn();
|
||||
jest.mock('react-query', () => ({
|
||||
useQueries: (queryConfigs: any[]): any[] => mockUseQueries(queryConfigs),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDimensions', () => ({
|
||||
useResizeObserver: (): { width: number; height: number } => ({
|
||||
width: 800,
|
||||
height: 600,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useMultiIntersectionObserver', () => ({
|
||||
useMultiIntersectionObserver: (count: number): any => ({
|
||||
visibilities: new Array(count).fill(true),
|
||||
setElement: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
|
||||
user: {
|
||||
role: 'admin',
|
||||
},
|
||||
activeLicenseV3: {
|
||||
event_queue: {
|
||||
created_at: '0',
|
||||
event: LicenseEvent.NO_EVENT,
|
||||
scheduled_at: '0',
|
||||
status: '',
|
||||
updated_at: '0',
|
||||
},
|
||||
license: {
|
||||
license_key: 'test-license-key',
|
||||
license_type: 'trial',
|
||||
org_id: 'test-org-id',
|
||||
plan_id: 'test-plan-id',
|
||||
plan_name: 'test-plan-name',
|
||||
plan_type: 'trial',
|
||||
plan_version: 'test-plan-version',
|
||||
},
|
||||
},
|
||||
featureFlags: [
|
||||
{
|
||||
name: 'DOT_METRICS_ENABLED',
|
||||
active: false,
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
const mockEntity = {
|
||||
id: 'test-entity-1',
|
||||
name: 'test-entity',
|
||||
type: 'pod',
|
||||
};
|
||||
|
||||
const mockEntityWidgetInfo = [
|
||||
{
|
||||
title: 'CPU Usage',
|
||||
yAxisUnit: 'percentage',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage',
|
||||
yAxisUnit: 'bytes',
|
||||
},
|
||||
];
|
||||
|
||||
const mockGetEntityQueryPayload = jest.fn().mockReturnValue([
|
||||
{
|
||||
query: 'cpu_usage',
|
||||
start: 1705315200,
|
||||
end: 1705318800,
|
||||
},
|
||||
{
|
||||
query: 'memory_usage',
|
||||
start: 1705315200,
|
||||
end: 1705318800,
|
||||
},
|
||||
]);
|
||||
|
||||
const mockTimeRange = {
|
||||
startTime: 1705315200,
|
||||
endTime: 1705318800,
|
||||
};
|
||||
|
||||
const mockHandleTimeChange = jest.fn();
|
||||
|
||||
const mockQueries = [
|
||||
{
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
table: {
|
||||
rows: [
|
||||
{ data: { timestamp: '2024-01-15T10:00:00Z', value: '42.5' } },
|
||||
{ data: { timestamp: '2024-01-15T10:01:00Z', value: '43.2' } },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'timestamp', label: 'Timestamp', isValueColumn: false },
|
||||
{ key: 'value', label: 'Value', isValueColumn: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
params: {
|
||||
compositeQuery: {
|
||||
panelType: 'time_series',
|
||||
},
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
table: {
|
||||
rows: [
|
||||
{ data: { timestamp: '2024-01-15T10:00:00Z', value: '1024' } },
|
||||
{ data: { timestamp: '2024-01-15T10:01:00Z', value: '1028' } },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'timestamp', label: 'Timestamp', isValueColumn: false },
|
||||
{ key: 'value', label: 'Value', isValueColumn: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
params: {
|
||||
compositeQuery: {
|
||||
panelType: 'table',
|
||||
},
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockLoadingQueries = [
|
||||
{
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockErrorQueries = [
|
||||
{
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error('API Error'),
|
||||
},
|
||||
{
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error('Network Error'),
|
||||
},
|
||||
];
|
||||
|
||||
const mockEmptyQueries = [
|
||||
{
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
result: [],
|
||||
},
|
||||
},
|
||||
params: {
|
||||
compositeQuery: {
|
||||
panelType: 'time_series',
|
||||
},
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
result: [],
|
||||
},
|
||||
},
|
||||
params: {
|
||||
compositeQuery: {
|
||||
panelType: 'table',
|
||||
},
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
|
||||
const renderEntityMetrics = (overrides = {}): any => {
|
||||
const defaultProps = {
|
||||
timeRange: mockTimeRange,
|
||||
isModalTimeSelection: false,
|
||||
handleTimeChange: mockHandleTimeChange,
|
||||
selectedInterval: '5m' as Time,
|
||||
entity: mockEntity,
|
||||
entityWidgetInfo: mockEntityWidgetInfo,
|
||||
getEntityQueryPayload: mockGetEntityQueryPayload,
|
||||
queryKey: 'test-query-key',
|
||||
category: K8sCategory.PODS,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return render(
|
||||
<EntityMetrics
|
||||
timeRange={defaultProps.timeRange}
|
||||
isModalTimeSelection={defaultProps.isModalTimeSelection}
|
||||
handleTimeChange={defaultProps.handleTimeChange}
|
||||
selectedInterval={defaultProps.selectedInterval}
|
||||
entity={defaultProps.entity}
|
||||
entityWidgetInfo={defaultProps.entityWidgetInfo}
|
||||
getEntityQueryPayload={defaultProps.getEntityQueryPayload}
|
||||
queryKey={defaultProps.queryKey}
|
||||
category={defaultProps.category}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('EntityMetrics', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseQueries.mockReturnValue(mockQueries);
|
||||
});
|
||||
|
||||
it('should render metrics with data', () => {
|
||||
renderEntityMetrics();
|
||||
expect(screen.getByText('CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Usage')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('date-time-selection')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('uplot-chart')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('metrics-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loading state when fetching metrics', () => {
|
||||
mockUseQueries.mockReturnValue(mockLoadingQueries);
|
||||
renderEntityMetrics();
|
||||
expect(screen.getAllByText('CPU Usage')).toHaveLength(1);
|
||||
expect(screen.getAllByText('Memory Usage')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders error state when query fails', () => {
|
||||
mockUseQueries.mockReturnValue(mockErrorQueries);
|
||||
renderEntityMetrics();
|
||||
expect(screen.getByText('API Error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Network Error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no metrics data', () => {
|
||||
mockUseQueries.mockReturnValue(mockEmptyQueries);
|
||||
renderEntityMetrics();
|
||||
expect(screen.getByTestId('uplot-chart')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('metrics-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls handleTimeChange when datetime selection changes', () => {
|
||||
renderEntityMetrics();
|
||||
expect(screen.getByTestId('date-time-selection')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple metric widgets', () => {
|
||||
renderEntityMetrics();
|
||||
expect(screen.getByText('CPU Usage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Memory Usage')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles different panel types correctly', () => {
|
||||
renderEntityMetrics();
|
||||
expect(screen.getByTestId('uplot-chart')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('metrics-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies intersection observer for visibility', () => {
|
||||
renderEntityMetrics();
|
||||
expect(mockUseQueries).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates correct query payloads', () => {
|
||||
renderEntityMetrics();
|
||||
expect(mockGetEntityQueryPayload).toHaveBeenCalledWith(
|
||||
mockEntity,
|
||||
mockTimeRange.startTime,
|
||||
mockTimeRange.endTime,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { K8sCategory } from 'container/InfraMonitoringK8s/constants';
|
||||
import { Time } from 'container/TopNav/DateTimeSelectionV2/config';
|
||||
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import * as appContextHooks from 'providers/App/App';
|
||||
import { LicenseEvent } from 'types/api/licensesV3/getActive';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import EntityTraces from '../EntityTraces';
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="date-time-selection">Date Time</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockUseQuery = jest.fn();
|
||||
jest.mock('react-query', () => ({
|
||||
useQuery: (queryKey: any, queryFn: any, options: any): any =>
|
||||
mockUseQuery(queryKey, queryFn, options),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: '/test-path',
|
||||
}),
|
||||
useNavigate: (): jest.Mock => jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
|
||||
user: {
|
||||
role: 'admin',
|
||||
},
|
||||
activeLicenseV3: {
|
||||
event_queue: {
|
||||
created_at: '0',
|
||||
event: LicenseEvent.NO_EVENT,
|
||||
scheduled_at: '0',
|
||||
status: '',
|
||||
updated_at: '0',
|
||||
},
|
||||
license: {
|
||||
license_key: 'test-license-key',
|
||||
license_type: 'trial',
|
||||
org_id: 'test-org-id',
|
||||
plan_id: 'test-plan-id',
|
||||
plan_name: 'test-plan-name',
|
||||
plan_type: 'trial',
|
||||
plan_version: 'test-plan-version',
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const mockUseQueryBuilderData = {
|
||||
handleRunQuery: jest.fn(),
|
||||
stagedQuery: initialQueriesMap[DataSource.METRICS],
|
||||
updateAllQueriesOperators: jest.fn(),
|
||||
currentQuery: initialQueriesMap[DataSource.METRICS],
|
||||
resetQuery: jest.fn(),
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
isStagedQueryUpdated: jest.fn(),
|
||||
handleSetQueryData: jest.fn(),
|
||||
handleSetFormulaData: jest.fn(),
|
||||
handleSetQueryItemData: jest.fn(),
|
||||
handleSetConfig: jest.fn(),
|
||||
removeQueryBuilderEntityByIndex: jest.fn(),
|
||||
removeQueryTypeItemByIndex: jest.fn(),
|
||||
isDefaultQuery: jest.fn(),
|
||||
};
|
||||
|
||||
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
|
||||
...mockUseQueryBuilderData,
|
||||
} as any);
|
||||
|
||||
const timeRange = {
|
||||
startTime: 1718236800,
|
||||
endTime: 1718236800,
|
||||
};
|
||||
|
||||
const mockHandleChangeTracesFilters = jest.fn();
|
||||
|
||||
const mockTracesFilters: IBuilderQuery['filters'] = {
|
||||
items: [
|
||||
{
|
||||
id: 'service-name',
|
||||
key: {
|
||||
id: 'service-name',
|
||||
dataType: DataTypes.String,
|
||||
isColumn: true,
|
||||
key: 'service.name',
|
||||
type: 'tag',
|
||||
isJSON: false,
|
||||
isIndexed: false,
|
||||
},
|
||||
op: '=',
|
||||
value: 'test-service',
|
||||
},
|
||||
],
|
||||
op: 'and',
|
||||
};
|
||||
|
||||
const isModalTimeSelection = false;
|
||||
const mockHandleTimeChange = jest.fn();
|
||||
const selectedInterval: Time = '5m';
|
||||
const category = K8sCategory.PODS;
|
||||
const queryKey = 'pod-traces';
|
||||
const queryKeyFilters = ['service.name'];
|
||||
|
||||
const mockTracesData = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: [
|
||||
{
|
||||
timestamp: '2024-01-15T10:00:00Z',
|
||||
data: {
|
||||
trace_id: 'trace-1',
|
||||
span_id: 'span-1',
|
||||
service_name: 'test-service-1',
|
||||
operation_name: 'test-operation-1',
|
||||
duration: 100,
|
||||
status_code: 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamp: '2024-01-15T10:01:00Z',
|
||||
data: {
|
||||
trace_id: 'trace-2',
|
||||
span_id: 'span-2',
|
||||
service_name: 'test-service-2',
|
||||
operation_name: 'test-operation-2',
|
||||
duration: 150,
|
||||
status_code: 500,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockEmptyTracesData = {
|
||||
payload: {
|
||||
data: {
|
||||
newResult: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
list: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const renderEntityTraces = (overrides = {}): any => {
|
||||
const defaultProps = {
|
||||
timeRange,
|
||||
isModalTimeSelection,
|
||||
handleTimeChange: mockHandleTimeChange,
|
||||
handleChangeTracesFilters: mockHandleChangeTracesFilters,
|
||||
tracesFilters: mockTracesFilters,
|
||||
selectedInterval,
|
||||
queryKey,
|
||||
category,
|
||||
queryKeyFilters,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return render(
|
||||
<EntityTraces
|
||||
timeRange={defaultProps.timeRange}
|
||||
isModalTimeSelection={defaultProps.isModalTimeSelection}
|
||||
handleTimeChange={defaultProps.handleTimeChange}
|
||||
handleChangeTracesFilters={defaultProps.handleChangeTracesFilters}
|
||||
tracesFilters={defaultProps.tracesFilters}
|
||||
selectedInterval={defaultProps.selectedInterval}
|
||||
queryKey={defaultProps.queryKey}
|
||||
category={defaultProps.category}
|
||||
queryKeyFilters={defaultProps.queryKeyFilters}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('EntityTraces', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: mockTracesData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render traces list with data', () => {
|
||||
renderEntityTraces();
|
||||
expect(screen.getByText('Previous')).toBeInTheDocument();
|
||||
expect(screen.getByText('Next')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Search Filter : select options from suggested values/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('date-time-selection')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no traces are found', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: mockEmptyTracesData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
renderEntityTraces();
|
||||
expect(screen.getByText(/No traces yet./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loader when fetching traces', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
isFetching: true,
|
||||
});
|
||||
|
||||
renderEntityTraces();
|
||||
expect(screen.getByText('pending_data_placeholder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error state when query fails', () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: { error: 'API Error' },
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
renderEntityTraces();
|
||||
expect(screen.getByText('API Error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls handleChangeTracesFilters when query builder search changes', () => {
|
||||
renderEntityTraces();
|
||||
expect(
|
||||
screen.getByText(/Search Filter : select options from suggested values/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls handleTimeChange when datetime selection changes', () => {
|
||||
renderEntityTraces();
|
||||
expect(screen.getByTestId('date-time-selection')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows pagination controls when traces are present', () => {
|
||||
renderEntityTraces();
|
||||
expect(screen.getByText('Previous')).toBeInTheDocument();
|
||||
expect(screen.getByText('Next')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables pagination buttons when no more data', () => {
|
||||
renderEntityTraces();
|
||||
const prevButton = screen.getByText('Previous').closest('button');
|
||||
const nextButton = screen.getByText('Next').closest('button');
|
||||
expect(prevButton).toBeDisabled();
|
||||
expect(nextButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { Skeleton } from 'antd';
|
||||
|
||||
function LoadingContainer(): JSX.Element {
|
||||
return (
|
||||
<div className="k8s-list-loading-state">
|
||||
<div className="k8s-list-loading-state" data-testid="loader">
|
||||
<Skeleton.Input
|
||||
className="k8s-list-loading-state-item"
|
||||
size="large"
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/* eslint-disable import/first */
|
||||
// eslint-disable-next-line import/order
|
||||
import setupCommonMocks from '../../commonMocks';
|
||||
|
||||
setupCommonMocks();
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import ClusterDetails from 'container/InfraMonitoringK8s/Clusters/ClusterDetails/ClusterDetails';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
describe('ClusterDetails', () => {
|
||||
const mockCluster = {
|
||||
meta: {
|
||||
k8s_cluster_name: 'test-cluster',
|
||||
},
|
||||
} as any;
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
it('should render modal with relevant metadata', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<ClusterDetails
|
||||
cluster={mockCluster}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const clusterNameElements = screen.getAllByText('test-cluster');
|
||||
expect(clusterNameElements.length).toBeGreaterThan(0);
|
||||
expect(clusterNameElements[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render modal with 4 tabs', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<ClusterDetails
|
||||
cluster={mockCluster}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByText('Metrics');
|
||||
expect(metricsTab).toBeInTheDocument();
|
||||
|
||||
const eventsTab = screen.getByText('Events');
|
||||
expect(eventsTab).toBeInTheDocument();
|
||||
|
||||
const logsTab = screen.getByText('Logs');
|
||||
expect(logsTab).toBeInTheDocument();
|
||||
|
||||
const tracesTab = screen.getByText('Traces');
|
||||
expect(tracesTab).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('default tab should be metrics', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<ClusterDetails
|
||||
cluster={mockCluster}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
|
||||
expect(metricsTab).toBeChecked();
|
||||
});
|
||||
|
||||
it('should switch to events tab when events tab is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<ClusterDetails
|
||||
cluster={mockCluster}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const eventsTab = screen.getByRole('radio', { name: 'Events' });
|
||||
expect(eventsTab).not.toBeChecked();
|
||||
fireEvent.click(eventsTab);
|
||||
expect(eventsTab).toBeChecked();
|
||||
});
|
||||
|
||||
it('should close modal when close button is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<ClusterDetails
|
||||
cluster={mockCluster}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'Close' });
|
||||
fireEvent.click(closeButton);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/* eslint-disable import/first */
|
||||
// eslint-disable-next-line import/order
|
||||
import setupCommonMocks from '../../commonMocks';
|
||||
|
||||
setupCommonMocks();
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import DaemonSetDetails from 'container/InfraMonitoringK8s/DaemonSets/DaemonSetDetails/DaemonSetDetails';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
describe('DaemonSetDetails', () => {
|
||||
const mockDaemonSet = {
|
||||
meta: {
|
||||
k8s_daemonset_name: 'test-daemon-set',
|
||||
k8s_cluster_name: 'test-cluster',
|
||||
k8s_namespace_name: 'test-namespace',
|
||||
},
|
||||
} as any;
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
it('should render modal with relevant metadata', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<DaemonSetDetails
|
||||
daemonSet={mockDaemonSet}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const daemonSetNameElements = screen.getAllByText('test-daemon-set');
|
||||
expect(daemonSetNameElements.length).toBeGreaterThan(0);
|
||||
expect(daemonSetNameElements[0]).toBeInTheDocument();
|
||||
|
||||
const clusterNameElements = screen.getAllByText('test-cluster');
|
||||
expect(clusterNameElements.length).toBeGreaterThan(0);
|
||||
expect(clusterNameElements[0]).toBeInTheDocument();
|
||||
|
||||
const namespaceNameElements = screen.getAllByText('test-namespace');
|
||||
expect(namespaceNameElements.length).toBeGreaterThan(0);
|
||||
expect(namespaceNameElements[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render modal with 4 tabs', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<DaemonSetDetails
|
||||
daemonSet={mockDaemonSet}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByText('Metrics');
|
||||
expect(metricsTab).toBeInTheDocument();
|
||||
|
||||
const eventsTab = screen.getByText('Events');
|
||||
expect(eventsTab).toBeInTheDocument();
|
||||
|
||||
const logsTab = screen.getByText('Logs');
|
||||
expect(logsTab).toBeInTheDocument();
|
||||
|
||||
const tracesTab = screen.getByText('Traces');
|
||||
expect(tracesTab).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('default tab should be metrics', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<DaemonSetDetails
|
||||
daemonSet={mockDaemonSet}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
|
||||
expect(metricsTab).toBeChecked();
|
||||
});
|
||||
|
||||
it('should switch to events tab when events tab is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<DaemonSetDetails
|
||||
daemonSet={mockDaemonSet}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const eventsTab = screen.getByRole('radio', { name: 'Events' });
|
||||
expect(eventsTab).not.toBeChecked();
|
||||
fireEvent.click(eventsTab);
|
||||
expect(eventsTab).toBeChecked();
|
||||
});
|
||||
|
||||
it('should close modal when close button is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<DaemonSetDetails
|
||||
daemonSet={mockDaemonSet}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'Close' });
|
||||
fireEvent.click(closeButton);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/* eslint-disable import/first */
|
||||
// eslint-disable-next-line import/order
|
||||
import setupCommonMocks from '../../commonMocks';
|
||||
|
||||
setupCommonMocks();
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import DeploymentDetails from 'container/InfraMonitoringK8s/Deployments/DeploymentDetails/DeploymentDetails';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
describe('DeploymentDetails', () => {
|
||||
const mockDeployment = {
|
||||
meta: {
|
||||
k8s_deployment_name: 'test-deployment',
|
||||
k8s_cluster_name: 'test-cluster',
|
||||
k8s_namespace_name: 'test-namespace',
|
||||
},
|
||||
} as any;
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
it('should render modal with relevant metadata', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<DeploymentDetails
|
||||
deployment={mockDeployment}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const deploymentNameElements = screen.getAllByText('test-deployment');
|
||||
expect(deploymentNameElements.length).toBeGreaterThan(0);
|
||||
expect(deploymentNameElements[0]).toBeInTheDocument();
|
||||
|
||||
const clusterNameElements = screen.getAllByText('test-cluster');
|
||||
expect(clusterNameElements.length).toBeGreaterThan(0);
|
||||
expect(clusterNameElements[0]).toBeInTheDocument();
|
||||
|
||||
const namespaceNameElements = screen.getAllByText('test-namespace');
|
||||
expect(namespaceNameElements.length).toBeGreaterThan(0);
|
||||
expect(namespaceNameElements[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render modal with 4 tabs', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<DeploymentDetails
|
||||
deployment={mockDeployment}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByText('Metrics');
|
||||
expect(metricsTab).toBeInTheDocument();
|
||||
|
||||
const eventsTab = screen.getByText('Events');
|
||||
expect(eventsTab).toBeInTheDocument();
|
||||
|
||||
const logsTab = screen.getByText('Logs');
|
||||
expect(logsTab).toBeInTheDocument();
|
||||
|
||||
const tracesTab = screen.getByText('Traces');
|
||||
expect(tracesTab).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('default tab should be metrics', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<DeploymentDetails
|
||||
deployment={mockDeployment}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
|
||||
expect(metricsTab).toBeChecked();
|
||||
});
|
||||
|
||||
it('should switch to events tab when events tab is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<DeploymentDetails
|
||||
deployment={mockDeployment}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const eventsTab = screen.getByRole('radio', { name: 'Events' });
|
||||
expect(eventsTab).not.toBeChecked();
|
||||
fireEvent.click(eventsTab);
|
||||
expect(eventsTab).toBeChecked();
|
||||
});
|
||||
|
||||
it('should close modal when close button is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<DeploymentDetails
|
||||
deployment={mockDeployment}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'Close' });
|
||||
fireEvent.click(closeButton);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/* eslint-disable import/first */
|
||||
// eslint-disable-next-line import/order
|
||||
import setupCommonMocks from '../../commonMocks';
|
||||
|
||||
setupCommonMocks();
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import JobDetails from 'container/InfraMonitoringK8s/Jobs/JobDetails/JobDetails';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
describe('JobDetails', () => {
|
||||
const mockJob = {
|
||||
meta: {
|
||||
k8s_job_name: 'test-job',
|
||||
k8s_namespace_name: 'test-namespace',
|
||||
},
|
||||
} as any;
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
it('should render modal with relevant metadata', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const jobNameElements = screen.getAllByText('test-job');
|
||||
expect(jobNameElements.length).toBeGreaterThan(0);
|
||||
expect(jobNameElements[0]).toBeInTheDocument();
|
||||
|
||||
const namespaceNameElements = screen.getAllByText('test-namespace');
|
||||
expect(namespaceNameElements.length).toBeGreaterThan(0);
|
||||
expect(namespaceNameElements[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render modal with 4 tabs', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByText('Metrics');
|
||||
expect(metricsTab).toBeInTheDocument();
|
||||
|
||||
const eventsTab = screen.getByText('Events');
|
||||
expect(eventsTab).toBeInTheDocument();
|
||||
|
||||
const logsTab = screen.getByText('Logs');
|
||||
expect(logsTab).toBeInTheDocument();
|
||||
|
||||
const tracesTab = screen.getByText('Traces');
|
||||
expect(tracesTab).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('default tab should be metrics', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
|
||||
expect(metricsTab).toBeChecked();
|
||||
});
|
||||
|
||||
it('should switch to events tab when events tab is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const eventsTab = screen.getByRole('radio', { name: 'Events' });
|
||||
expect(eventsTab).not.toBeChecked();
|
||||
fireEvent.click(eventsTab);
|
||||
expect(eventsTab).toBeChecked();
|
||||
});
|
||||
|
||||
it('should close modal when close button is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'Close' });
|
||||
fireEvent.click(closeButton);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/* eslint-disable import/first */
|
||||
// eslint-disable-next-line import/order
|
||||
import setupCommonMocks from '../../commonMocks';
|
||||
|
||||
setupCommonMocks();
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import NamespaceDetails from 'container/InfraMonitoringK8s/Namespaces/NamespaceDetails/NamespaceDetails';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
describe('NamespaceDetails', () => {
|
||||
const mockNamespace = {
|
||||
namespaceName: 'test-namespace',
|
||||
meta: {
|
||||
k8s_cluster_name: 'test-cluster',
|
||||
},
|
||||
} as any;
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
it('should render modal with relevant metadata', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<NamespaceDetails
|
||||
namespace={mockNamespace}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const namespaceNameElements = screen.getAllByText('test-namespace');
|
||||
expect(namespaceNameElements.length).toBeGreaterThan(0);
|
||||
expect(namespaceNameElements[0]).toBeInTheDocument();
|
||||
|
||||
const clusterNameElements = screen.getAllByText('test-cluster');
|
||||
expect(clusterNameElements.length).toBeGreaterThan(0);
|
||||
expect(clusterNameElements[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render modal with 4 tabs', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<NamespaceDetails
|
||||
namespace={mockNamespace}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByText('Metrics');
|
||||
expect(metricsTab).toBeInTheDocument();
|
||||
|
||||
const eventsTab = screen.getByText('Events');
|
||||
expect(eventsTab).toBeInTheDocument();
|
||||
|
||||
const logsTab = screen.getByText('Logs');
|
||||
expect(logsTab).toBeInTheDocument();
|
||||
|
||||
const tracesTab = screen.getByText('Traces');
|
||||
expect(tracesTab).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('default tab should be metrics', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<NamespaceDetails
|
||||
namespace={mockNamespace}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
|
||||
expect(metricsTab).toBeChecked();
|
||||
});
|
||||
|
||||
it('should switch to events tab when events tab is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<NamespaceDetails
|
||||
namespace={mockNamespace}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const eventsTab = screen.getByRole('radio', { name: 'Events' });
|
||||
expect(eventsTab).not.toBeChecked();
|
||||
fireEvent.click(eventsTab);
|
||||
expect(eventsTab).toBeChecked();
|
||||
});
|
||||
|
||||
it('should close modal when close button is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<NamespaceDetails
|
||||
namespace={mockNamespace}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'Close' });
|
||||
fireEvent.click(closeButton);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/* eslint-disable import/first */
|
||||
// eslint-disable-next-line import/order
|
||||
import setupCommonMocks from '../../commonMocks';
|
||||
|
||||
setupCommonMocks();
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import NodeDetails from 'container/InfraMonitoringK8s/Nodes/NodeDetails/NodeDetails';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
describe('NodeDetails', () => {
|
||||
const mockNode = {
|
||||
meta: {
|
||||
k8s_node_name: 'test-node',
|
||||
k8s_cluster_name: 'test-cluster',
|
||||
},
|
||||
} as any;
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
it('should render modal with relevant metadata', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<NodeDetails node={mockNode} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const nodeNameElements = screen.getAllByText('test-node');
|
||||
expect(nodeNameElements.length).toBeGreaterThan(0);
|
||||
expect(nodeNameElements[0]).toBeInTheDocument();
|
||||
|
||||
const clusterNameElements = screen.getAllByText('test-cluster');
|
||||
expect(clusterNameElements.length).toBeGreaterThan(0);
|
||||
expect(clusterNameElements[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render modal with 4 tabs', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<NodeDetails node={mockNode} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByText('Metrics');
|
||||
expect(metricsTab).toBeInTheDocument();
|
||||
|
||||
const eventsTab = screen.getByText('Events');
|
||||
expect(eventsTab).toBeInTheDocument();
|
||||
|
||||
const logsTab = screen.getByText('Logs');
|
||||
expect(logsTab).toBeInTheDocument();
|
||||
|
||||
const tracesTab = screen.getByText('Traces');
|
||||
expect(tracesTab).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('default tab should be metrics', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<NodeDetails node={mockNode} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
|
||||
expect(metricsTab).toBeChecked();
|
||||
});
|
||||
|
||||
it('should switch to events tab when events tab is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<NodeDetails node={mockNode} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const eventsTab = screen.getByRole('radio', { name: 'Events' });
|
||||
expect(eventsTab).not.toBeChecked();
|
||||
fireEvent.click(eventsTab);
|
||||
expect(eventsTab).toBeChecked();
|
||||
});
|
||||
|
||||
it('should close modal when close button is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<NodeDetails node={mockNode} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'Close' });
|
||||
fireEvent.click(closeButton);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
122
frontend/src/container/InfraMonitoringK8s/__tests__/Pods/PodDetails/PodDetails.test.tsx
generated
Normal file
122
frontend/src/container/InfraMonitoringK8s/__tests__/Pods/PodDetails/PodDetails.test.tsx
generated
Normal file
@@ -0,0 +1,122 @@
|
||||
/* eslint-disable import/first */
|
||||
// eslint-disable-next-line import/order
|
||||
import setupCommonMocks from '../../commonMocks';
|
||||
|
||||
setupCommonMocks();
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import PodDetails from 'container/InfraMonitoringK8s/Pods/PodDetails/PodDetails';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
describe('PodDetails', () => {
|
||||
const mockPod = {
|
||||
podName: 'test-pod',
|
||||
meta: {
|
||||
k8s_cluster_name: 'test-cluster',
|
||||
k8s_namespace_name: 'test-namespace',
|
||||
k8s_node_name: 'test-node',
|
||||
},
|
||||
} as any;
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
it('should render modal with relevant metadata', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<PodDetails pod={mockPod} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const clusterNameElements = screen.getAllByText('test-cluster');
|
||||
expect(clusterNameElements.length).toBeGreaterThan(0);
|
||||
expect(clusterNameElements[0]).toBeInTheDocument();
|
||||
|
||||
const namespaceNameElements = screen.getAllByText('test-namespace');
|
||||
expect(namespaceNameElements.length).toBeGreaterThan(0);
|
||||
expect(namespaceNameElements[0]).toBeInTheDocument();
|
||||
|
||||
const nodeNameElements = screen.getAllByText('test-node');
|
||||
expect(nodeNameElements.length).toBeGreaterThan(0);
|
||||
expect(nodeNameElements[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render modal with 4 tabs', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<PodDetails pod={mockPod} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByText('Metrics');
|
||||
expect(metricsTab).toBeInTheDocument();
|
||||
|
||||
const eventsTab = screen.getByText('Events');
|
||||
expect(eventsTab).toBeInTheDocument();
|
||||
|
||||
const logsTab = screen.getByText('Logs');
|
||||
expect(logsTab).toBeInTheDocument();
|
||||
|
||||
const tracesTab = screen.getByText('Traces');
|
||||
expect(tracesTab).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('default tab should be metrics', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<PodDetails pod={mockPod} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
|
||||
expect(metricsTab).toBeChecked();
|
||||
});
|
||||
|
||||
it('should switch to events tab when events tab is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<PodDetails pod={mockPod} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const eventsTab = screen.getByRole('radio', { name: 'Events' });
|
||||
expect(eventsTab).not.toBeChecked();
|
||||
fireEvent.click(eventsTab);
|
||||
expect(eventsTab).toBeChecked();
|
||||
});
|
||||
|
||||
it('should close modal when close button is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<PodDetails pod={mockPod} isModalTimeSelection onClose={mockOnClose} />
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'Close' });
|
||||
fireEvent.click(closeButton);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/* eslint-disable import/first */
|
||||
// eslint-disable-next-line import/order
|
||||
import setupCommonMocks from '../../commonMocks';
|
||||
|
||||
setupCommonMocks();
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import StatefulSetDetails from 'container/InfraMonitoringK8s/StatefulSets/StatefulSetDetails/StatefulSetDetails';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
describe('StatefulSetDetails', () => {
|
||||
const mockStatefulSet = {
|
||||
meta: {
|
||||
k8s_namespace_name: 'test-namespace',
|
||||
k8s_statefulset_name: 'test-stateful-set',
|
||||
},
|
||||
} as any;
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
it('should render modal with relevant metadata', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<StatefulSetDetails
|
||||
statefulSet={mockStatefulSet}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const statefulSetNameElements = screen.getAllByText('test-stateful-set');
|
||||
expect(statefulSetNameElements.length).toBeGreaterThan(0);
|
||||
expect(statefulSetNameElements[0]).toBeInTheDocument();
|
||||
|
||||
const namespaceNameElements = screen.getAllByText('test-namespace');
|
||||
expect(namespaceNameElements.length).toBeGreaterThan(0);
|
||||
expect(namespaceNameElements[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render modal with 4 tabs', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<StatefulSetDetails
|
||||
statefulSet={mockStatefulSet}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByText('Metrics');
|
||||
expect(metricsTab).toBeInTheDocument();
|
||||
|
||||
const eventsTab = screen.getByText('Events');
|
||||
expect(eventsTab).toBeInTheDocument();
|
||||
|
||||
const logsTab = screen.getByText('Logs');
|
||||
expect(logsTab).toBeInTheDocument();
|
||||
|
||||
const tracesTab = screen.getByText('Traces');
|
||||
expect(tracesTab).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('default tab should be metrics', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<StatefulSetDetails
|
||||
statefulSet={mockStatefulSet}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
|
||||
expect(metricsTab).toBeChecked();
|
||||
});
|
||||
|
||||
it('should switch to events tab when events tab is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<StatefulSetDetails
|
||||
statefulSet={mockStatefulSet}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const eventsTab = screen.getByRole('radio', { name: 'Events' });
|
||||
expect(eventsTab).not.toBeChecked();
|
||||
fireEvent.click(eventsTab);
|
||||
expect(eventsTab).toBeChecked();
|
||||
});
|
||||
|
||||
it('should close modal when close button is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<StatefulSetDetails
|
||||
statefulSet={mockStatefulSet}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'Close' });
|
||||
fireEvent.click(closeButton);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/* eslint-disable import/first */
|
||||
// eslint-disable-next-line import/order
|
||||
import setupCommonMocks from '../../commonMocks';
|
||||
|
||||
setupCommonMocks();
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import VolumeDetails from 'container/InfraMonitoringK8s/Volumes/VolumeDetails/VolumeDetails';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import store from 'store';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
describe('VolumeDetails', () => {
|
||||
const mockVolume = {
|
||||
persistentVolumeClaimName: 'test-volume',
|
||||
meta: {
|
||||
k8s_cluster_name: 'test-cluster',
|
||||
k8s_namespace_name: 'test-namespace',
|
||||
},
|
||||
} as any;
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
it('should render modal with relevant metadata', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<VolumeDetails
|
||||
volume={mockVolume}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const volumeNameElements = screen.getAllByText('test-volume');
|
||||
expect(volumeNameElements.length).toBeGreaterThan(0);
|
||||
expect(volumeNameElements[0]).toBeInTheDocument();
|
||||
|
||||
const clusterNameElements = screen.getAllByText('test-cluster');
|
||||
expect(clusterNameElements.length).toBeGreaterThan(0);
|
||||
expect(clusterNameElements[0]).toBeInTheDocument();
|
||||
|
||||
const namespaceNameElements = screen.getAllByText('test-namespace');
|
||||
expect(namespaceNameElements.length).toBeGreaterThan(0);
|
||||
expect(namespaceNameElements[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should close modal when close button is clicked', () => {
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<VolumeDetails
|
||||
volume={mockVolume}
|
||||
isModalTimeSelection
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'Close' });
|
||||
fireEvent.click(closeButton);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import * as appContextHooks from 'providers/App/App';
|
||||
import * as timezoneHooks from 'providers/Timezone';
|
||||
import { LicenseEvent } from 'types/api/licensesV3/getActive';
|
||||
|
||||
const setupCommonMocks = (): void => {
|
||||
const createMockObserver = (): {
|
||||
observe: jest.Mock;
|
||||
unobserve: jest.Mock;
|
||||
disconnect: jest.Mock;
|
||||
} => ({
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
});
|
||||
|
||||
global.IntersectionObserver = jest.fn().mockImplementation(createMockObserver);
|
||||
global.ResizeObserver = jest.fn().mockImplementation(createMockObserver);
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn(() => ({
|
||||
globalTime: {
|
||||
selectedTime: {
|
||||
startTime: 1713734400000,
|
||||
endTime: 1713738000000,
|
||||
},
|
||||
maxTime: 1713738000000,
|
||||
minTime: 1713734400000,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('uplot', () => ({
|
||||
paths: {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
},
|
||||
default: jest.fn(() => ({
|
||||
paths: {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom-v5-compat', () => ({
|
||||
...jest.requireActual('react-router-dom-v5-compat'),
|
||||
useSearchParams: jest.fn().mockReturnValue([
|
||||
{
|
||||
get: jest.fn(),
|
||||
entries: jest.fn(() => []),
|
||||
set: jest.fn(),
|
||||
},
|
||||
jest.fn(),
|
||||
]),
|
||||
useNavigationType: (): any => 'PUSH',
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
set: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
get: jest.fn(),
|
||||
has: jest.fn(),
|
||||
entries: jest.fn(() => []),
|
||||
append: jest.fn(),
|
||||
toString: jest.fn(() => ''),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('lib/getMinMax', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(() => ({
|
||||
minTime: 1713734400000,
|
||||
maxTime: 1713738000000,
|
||||
})),
|
||||
isValidTimeFormat: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
|
||||
user: {
|
||||
role: 'admin',
|
||||
},
|
||||
activeLicenseV3: {
|
||||
event_queue: {
|
||||
created_at: '0',
|
||||
event: LicenseEvent.NO_EVENT,
|
||||
scheduled_at: '0',
|
||||
status: '',
|
||||
updated_at: '0',
|
||||
},
|
||||
license: {
|
||||
license_key: 'test-license-key',
|
||||
license_type: 'trial',
|
||||
org_id: 'test-org-id',
|
||||
plan_id: 'test-plan-id',
|
||||
plan_name: 'test-plan-name',
|
||||
plan_type: 'trial',
|
||||
plan_version: 'test-plan-version',
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
jest.spyOn(timezoneHooks, 'useTimezone').mockReturnValue({
|
||||
timezone: {
|
||||
offset: 0,
|
||||
},
|
||||
browserTimezone: {
|
||||
offset: 0,
|
||||
},
|
||||
} as any);
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
export default setupCommonMocks;
|
||||
@@ -256,7 +256,6 @@ function LogsExplorerViewsContainer({
|
||||
} = useGetExplorerQueryRange(
|
||||
listChartQuery,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
// ENTITY_VERSION_V4,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
enabled:
|
||||
@@ -279,7 +278,6 @@ function LogsExplorerViewsContainer({
|
||||
} = useGetExplorerQueryRange(
|
||||
requestData,
|
||||
panelType,
|
||||
// ENTITY_VERSION_V4,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
keepPreviousData: true,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
.meter-explorer-breakdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.meter-explorer-date-time {
|
||||
display: flex;
|
||||
min-height: 30px;
|
||||
justify-content: end;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.meter-explorer-graphs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
gap: 36px;
|
||||
|
||||
.meter-column-graph {
|
||||
.row-card {
|
||||
background-color: var(--bg-ink-400);
|
||||
padding-left: 10px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.section-title {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.graph-description {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meter-page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
.meter-graph {
|
||||
height: 400px;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.total {
|
||||
.meter-column-graph {
|
||||
.meter-page-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
.meter-graph {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.meter-explorer-breakdown {
|
||||
.meter-explorer-date-time {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.meter-explorer-graphs {
|
||||
.meter-column-graph {
|
||||
.row-card {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
|
||||
.section-title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
200
frontend/src/container/MeterExplorer/Breakdown/BreakDown.tsx
Normal file
200
frontend/src/container/MeterExplorer/Breakdown/BreakDown.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import './BreakDown.styles.scss';
|
||||
|
||||
import { Typography } from 'antd';
|
||||
// import useFilterConfig from 'components/QuickFilters/hooks/useFilterConfig';
|
||||
// import { SignalType } from 'components/QuickFilters/types';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import GridCard from 'container/GridCardLayout/GridCard';
|
||||
import { Card, CardContainer } from 'container/GridCardLayout/styles';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
// import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
// import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
getLogCountWidgetData,
|
||||
getLogSizeWidgetData,
|
||||
getMetricCountWidgetData,
|
||||
getSpanCountWidgetData,
|
||||
getSpanSizeWidgetData,
|
||||
getTotalLogSizeWidgetData,
|
||||
getTotalMetricDatapointCountWidgetData,
|
||||
getTotalTraceSizeWidgetData,
|
||||
} from './graphs';
|
||||
|
||||
type MetricSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
graphs: Widgets[];
|
||||
};
|
||||
|
||||
const sections: MetricSection[] = [
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Total',
|
||||
graphs: [
|
||||
getTotalLogSizeWidgetData(),
|
||||
getTotalTraceSizeWidgetData(),
|
||||
getTotalMetricDatapointCountWidgetData(),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Logs',
|
||||
graphs: [getLogCountWidgetData(), getLogSizeWidgetData()],
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Traces',
|
||||
graphs: [getSpanCountWidgetData(), getSpanSizeWidgetData()],
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
title: 'Metrics',
|
||||
graphs: [getMetricCountWidgetData()],
|
||||
},
|
||||
];
|
||||
|
||||
function Section(section: MetricSection): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { title, graphs } = section;
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number) => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
|
||||
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
|
||||
const generatedUrl = `${pathname}?${urlQuery.toString()}`;
|
||||
history.push(generatedUrl);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
},
|
||||
[dispatch, history, pathname, urlQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="meter-column-graph">
|
||||
<CardContainer className="row-card" isDarkMode={isDarkMode}>
|
||||
<Typography.Text className="section-title">{title}</Typography.Text>
|
||||
</CardContainer>
|
||||
<div className="meter-page-grid">
|
||||
{graphs.map((widget) => (
|
||||
<Card
|
||||
key={widget?.id}
|
||||
isDarkMode={isDarkMode}
|
||||
$panelType={PANEL_TYPES.BAR}
|
||||
className="meter-graph"
|
||||
>
|
||||
<GridCard widget={widget} onDragSelect={onDragSelect} version="v5" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// function FilterDropdown({ attrKey }: { attrKey: string }): JSX.Element {
|
||||
// const {
|
||||
// data: keyValueSuggestions,
|
||||
// isLoading: isLoadingKeyValueSuggestions,
|
||||
// } = useGetQueryKeyValueSuggestions({
|
||||
// key: attrKey,
|
||||
// signal: DataSource.METRICS,
|
||||
// signalSource: 'meter',
|
||||
// options: {
|
||||
// keepPreviousData: true,
|
||||
// },
|
||||
// });
|
||||
|
||||
// const responseData = keyValueSuggestions?.data as any;
|
||||
// const values = responseData?.data?.values || {};
|
||||
// const stringValues = values.stringValues || [];
|
||||
// const numberValues = values.numberValues || [];
|
||||
|
||||
// const stringOptions = stringValues.filter(
|
||||
// (value: string | null | undefined): value is string =>
|
||||
// value !== null && value !== undefined && value !== '',
|
||||
// );
|
||||
|
||||
// const numberOptions = numberValues
|
||||
// .filter(
|
||||
// (value: number | null | undefined): value is number =>
|
||||
// value !== null && value !== undefined,
|
||||
// )
|
||||
// .map((value: number) => value.toString());
|
||||
|
||||
// const vals = [...stringOptions, ...numberOptions];
|
||||
|
||||
// return (
|
||||
// <div className="filter-dropdown">
|
||||
// <Typography.Text>{attrKey}</Typography.Text>
|
||||
// <Select
|
||||
// loading={isLoadingKeyValueSuggestions}
|
||||
// options={vals?.map((suggestion: any) => ({
|
||||
// label: suggestion,
|
||||
// value: suggestion,
|
||||
// }))}
|
||||
// placeholder={`Select ${attrKey}`}
|
||||
// />
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
function BreakDown(): JSX.Element {
|
||||
// const { customFilters } = useFilterConfig({
|
||||
// signal: SignalType.METER_EXPLORER,
|
||||
// config: [],
|
||||
// });
|
||||
|
||||
return (
|
||||
<div className="meter-explorer-breakdown">
|
||||
<section className="meter-explorer-date-time">
|
||||
{/* {customFilters.map((filter) => (
|
||||
<FilterDropdown key={filter.key} attrKey={filter.key} />
|
||||
))} */}
|
||||
<DateTimeSelectionV2 showAutoRefresh={false} />
|
||||
</section>
|
||||
<section className="meter-explorer-graphs">
|
||||
<section className="total">
|
||||
<Section
|
||||
id={sections[0].id}
|
||||
title={sections[0].title}
|
||||
graphs={sections[0].graphs}
|
||||
/>
|
||||
</section>
|
||||
{sections.map((section, idx) => {
|
||||
if (idx === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
key={section.id}
|
||||
id={section.id}
|
||||
title={section.title}
|
||||
graphs={section.graphs}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BreakDown;
|
||||
390
frontend/src/container/MeterExplorer/Breakdown/graphs.ts
Normal file
390
frontend/src/container/MeterExplorer/Breakdown/graphs.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetWidgetQueryBuilderProps } from 'container/MetricsApplication/types';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
interface GetWidgetQueryProps {
|
||||
title: string;
|
||||
description: string;
|
||||
queryData: IBuilderQuery[];
|
||||
queryFormulas?: IBuilderFormula[];
|
||||
panelTypes?: PANEL_TYPES;
|
||||
yAxisUnit?: string;
|
||||
columnUnits?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface GetWidgetQueryPropsReturn extends GetWidgetQueryBuilderProps {
|
||||
description?: string;
|
||||
nullZeroValues: string;
|
||||
columnUnits?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const getWidgetQueryBuilder = ({
|
||||
query,
|
||||
title = '',
|
||||
panelTypes,
|
||||
yAxisUnit = '',
|
||||
fillSpans = false,
|
||||
id,
|
||||
nullZeroValues,
|
||||
description,
|
||||
}: GetWidgetQueryPropsReturn): Widgets => ({
|
||||
description: description || '',
|
||||
id: id || uuid(),
|
||||
isStacked: false,
|
||||
nullZeroValues: nullZeroValues || '',
|
||||
opacity: '1',
|
||||
panelTypes,
|
||||
query,
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
title,
|
||||
yAxisUnit,
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
selectedLogFields: [],
|
||||
selectedTracesFields: [],
|
||||
fillSpans,
|
||||
});
|
||||
|
||||
export function getWidgetQuery(
|
||||
props: GetWidgetQueryProps,
|
||||
): GetWidgetQueryPropsReturn {
|
||||
const { title, description, panelTypes, yAxisUnit, columnUnits } = props;
|
||||
return {
|
||||
title,
|
||||
yAxisUnit: yAxisUnit || 'none',
|
||||
panelTypes: panelTypes || PANEL_TYPES.TIME_SERIES,
|
||||
fillSpans: false,
|
||||
description,
|
||||
nullZeroValues: 'zero',
|
||||
columnUnits,
|
||||
query: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
promql: [],
|
||||
builder: {
|
||||
queryData: props.queryData,
|
||||
queryFormulas: (props.queryFormulas as IBuilderFormula[]) || [],
|
||||
},
|
||||
clickhouse_sql: [],
|
||||
id: uuid(),
|
||||
},
|
||||
};
|
||||
}
|
||||
export const getTotalLogSizeWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.log.size',
|
||||
id: 'signoz.meter.log.size--float64--Sum--true',
|
||||
isColumn: true,
|
||||
isJSON: false,
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: 'sum',
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Total size of log records ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.VALUE,
|
||||
yAxisUnit: 'bytes',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getTotalTraceSizeWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.span.size',
|
||||
id: 'signoz.meter.span.size--float64--Sum--true',
|
||||
isColumn: true,
|
||||
isJSON: false,
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: 'sum',
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Total size of spans ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.VALUE,
|
||||
yAxisUnit: 'bytes',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getTotalMetricDatapointCountWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.metric.datapoint.count',
|
||||
id: 'signoz.meter.metric.datapoint.count--float64--Sum--true',
|
||||
isColumn: true,
|
||||
isJSON: false,
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: 'sum',
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Total metric datapoints ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.VALUE,
|
||||
yAxisUnit: 'short',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getLogCountWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.log.count',
|
||||
id: 'signoz.meter.log.count--float64--Sum--true',
|
||||
isColumn: true,
|
||||
isJSON: false,
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Count of log records ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'short',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getLogSizeWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.log.size',
|
||||
id: 'signoz.meter.log.size--float64--Sum--true',
|
||||
isColumn: true,
|
||||
isJSON: false,
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'size',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Size of log records ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'bytes',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getSpanCountWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.span.count',
|
||||
id: 'signoz.meter.span.count--float64--Sum--true',
|
||||
isColumn: true,
|
||||
isJSON: false,
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Count of spans ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'short',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getSpanSizeWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.span.size',
|
||||
id: 'signoz.meter.span.size--float64--Sum--true',
|
||||
isColumn: true,
|
||||
isJSON: false,
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'size',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Size of spans ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'bytes',
|
||||
}),
|
||||
);
|
||||
|
||||
export const getMetricCountWidgetData = (): Widgets =>
|
||||
getWidgetQueryBuilder(
|
||||
getWidgetQuery({
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
key: 'signoz.meter.metric.datapoint.count',
|
||||
id: 'signoz.meter.metric.datapoint.count--float64--Sum--true',
|
||||
isColumn: true,
|
||||
isJSON: false,
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'increase',
|
||||
dataSource: DataSource.METRICS,
|
||||
source: 'meter',
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: 'count',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'increase',
|
||||
},
|
||||
],
|
||||
title: 'Count of metric datapoints ingested',
|
||||
description: '',
|
||||
panelTypes: PANEL_TYPES.BAR,
|
||||
yAxisUnit: 'short',
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,208 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.meter-explorer-content-section {
|
||||
.explore-content {
|
||||
.time-series-view-panel {
|
||||
background: var(--bg-vanilla-100);
|
||||
border-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.meter-explorer-quick-filters-section {
|
||||
border-right: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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.BAR,
|
||||
DataSource.METRICS,
|
||||
'meter' as 'meter' | '',
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
currentQuery || initialQueryMeterWithType,
|
||||
PANEL_TYPES.BAR,
|
||||
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.BAR,
|
||||
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;
|
||||
143
frontend/src/container/MeterExplorer/Explorer/TimeSeries.tsx
Normal file
143
frontend/src/container/MeterExplorer/Explorer/TimeSeries.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
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.BAR,
|
||||
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}
|
||||
panelType={PANEL_TYPES.BAR}
|
||||
/>
|
||||
</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',
|
||||
}
|
||||
@@ -189,7 +189,7 @@ function Explorer(): JSX.Element {
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.METRICS}
|
||||
onExport={handleExport}
|
||||
isOneChartPerQuery={showOneChartPerQuery}
|
||||
isOneChartPerQuery={false}
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import './MySettings.styles.scss';
|
||||
|
||||
import { Radio, RadioChangeEvent, Switch, Tag } from 'antd';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import { AxiosError } from 'axios';
|
||||
@@ -109,6 +110,9 @@ function MySettings(): JSX.Element {
|
||||
// Optimistically update the UI
|
||||
setSideNavPinned(checked);
|
||||
|
||||
// Save to localStorage immediately for instant feedback
|
||||
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, checked.toString());
|
||||
|
||||
// Update the context immediately
|
||||
const save = {
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
@@ -130,6 +134,8 @@ function MySettings(): JSX.Element {
|
||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||
value: !checked,
|
||||
} as UserPreference);
|
||||
// Also revert localStorage
|
||||
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, (!checked).toString());
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
.dynamic-variable-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 32px 200px;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 24px 0;
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.dynamic-variable-from-text {
|
||||
font-family: 'Space Mono';
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.dynamic-variable-container {
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import './DynamicVariable.styles.scss';
|
||||
|
||||
import { Select, Typography } from 'antd';
|
||||
import CustomSelect from 'components/NewSelect/CustomSelect';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { FieldKey } from 'types/api/dynamicVariables/getFieldKeys';
|
||||
|
||||
enum AttributeSource {
|
||||
ALL_SOURCES = 'All Sources',
|
||||
LOGS = 'Logs',
|
||||
METRICS = 'Metrics',
|
||||
TRACES = 'Traces',
|
||||
}
|
||||
|
||||
function DynamicVariable({
|
||||
setDynamicVariablesSelectedValue,
|
||||
dynamicVariablesSelectedValue,
|
||||
}: {
|
||||
setDynamicVariablesSelectedValue: Dispatch<
|
||||
SetStateAction<
|
||||
| {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
| undefined
|
||||
>
|
||||
>;
|
||||
dynamicVariablesSelectedValue:
|
||||
| {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
| undefined;
|
||||
}): JSX.Element {
|
||||
const sources = [
|
||||
AttributeSource.ALL_SOURCES,
|
||||
AttributeSource.LOGS,
|
||||
AttributeSource.TRACES,
|
||||
AttributeSource.METRICS,
|
||||
];
|
||||
|
||||
const [attributeSource, setAttributeSource] = useState<AttributeSource>();
|
||||
const [attributes, setAttributes] = useState<Record<string, FieldKey[]>>({});
|
||||
const [selectedAttribute, setSelectedAttribute] = useState<string>();
|
||||
const [apiSearchText, setApiSearchText] = useState<string>('');
|
||||
|
||||
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
|
||||
|
||||
const [filteredAttributes, setFilteredAttributes] = useState<
|
||||
Record<string, FieldKey[]>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (dynamicVariablesSelectedValue?.name) {
|
||||
setSelectedAttribute(dynamicVariablesSelectedValue.name);
|
||||
}
|
||||
|
||||
if (dynamicVariablesSelectedValue?.value) {
|
||||
setAttributeSource(dynamicVariablesSelectedValue.value as AttributeSource);
|
||||
}
|
||||
}, [
|
||||
dynamicVariablesSelectedValue?.name,
|
||||
dynamicVariablesSelectedValue?.value,
|
||||
]);
|
||||
|
||||
const { data, error, isLoading, refetch } = useGetFieldKeys({
|
||||
signal:
|
||||
attributeSource === AttributeSource.ALL_SOURCES
|
||||
? undefined
|
||||
: (attributeSource?.toLowerCase() as 'traces' | 'logs' | 'metrics'),
|
||||
name: debouncedApiSearchText,
|
||||
});
|
||||
|
||||
const isComplete = useMemo(() => data?.payload?.complete === true, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const newAttributes = data.payload?.keys ?? {};
|
||||
setAttributes(newAttributes);
|
||||
setFilteredAttributes(newAttributes);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// refetch when attributeSource changes
|
||||
useEffect(() => {
|
||||
if (attributeSource) {
|
||||
refetch();
|
||||
}
|
||||
}, [attributeSource, refetch, debouncedApiSearchText]);
|
||||
|
||||
// Handle search based on whether we have complete data or not
|
||||
const handleSearch = useCallback(
|
||||
(text: string) => {
|
||||
if (isComplete) {
|
||||
// If complete is true, do client-side filtering
|
||||
if (!text) {
|
||||
setFilteredAttributes(attributes);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered: Record<string, FieldKey[]> = {};
|
||||
Object.keys(attributes).forEach((key) => {
|
||||
if (key.toLowerCase().includes(text.toLowerCase())) {
|
||||
filtered[key] = attributes[key];
|
||||
}
|
||||
});
|
||||
setFilteredAttributes(filtered);
|
||||
} else {
|
||||
// If complete is false, debounce the API call
|
||||
setApiSearchText(text);
|
||||
}
|
||||
},
|
||||
[attributes, isComplete],
|
||||
);
|
||||
|
||||
// update setDynamicVariablesSelectedValue with debounce when attribute and source is selected
|
||||
useEffect(() => {
|
||||
if (selectedAttribute || attributeSource) {
|
||||
setDynamicVariablesSelectedValue({
|
||||
name: selectedAttribute || dynamicVariablesSelectedValue?.name || '',
|
||||
value:
|
||||
attributeSource ||
|
||||
dynamicVariablesSelectedValue?.value ||
|
||||
AttributeSource.ALL_SOURCES,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
selectedAttribute,
|
||||
attributeSource,
|
||||
setDynamicVariablesSelectedValue,
|
||||
dynamicVariablesSelectedValue?.name,
|
||||
dynamicVariablesSelectedValue?.value,
|
||||
]);
|
||||
|
||||
const errorMessage = (error as any)?.message;
|
||||
return (
|
||||
<div className="dynamic-variable-container">
|
||||
<CustomSelect
|
||||
placeholder="Select an Attribute"
|
||||
options={Object.keys(filteredAttributes).map((key) => ({
|
||||
label: key,
|
||||
value: key,
|
||||
}))}
|
||||
loading={isLoading}
|
||||
status={errorMessage ? 'error' : undefined}
|
||||
onChange={(value): void => {
|
||||
setSelectedAttribute(value);
|
||||
}}
|
||||
showSearch
|
||||
errorMessage={errorMessage as any}
|
||||
value={selectedAttribute || dynamicVariablesSelectedValue?.name}
|
||||
onSearch={handleSearch}
|
||||
onRetry={(): void => {
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
<Typography className="dynamic-variable-from-text">from</Typography>
|
||||
<Select
|
||||
placeholder="Source"
|
||||
defaultValue={AttributeSource.ALL_SOURCES}
|
||||
options={sources.map((source) => ({ label: source, value: source }))}
|
||||
onChange={(value): void => setAttributeSource(value as AttributeSource)}
|
||||
value={attributeSource || dynamicVariablesSelectedValue?.value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicVariable;
|
||||
@@ -0,0 +1,376 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
|
||||
|
||||
import DynamicVariable from '../DynamicVariable';
|
||||
|
||||
// Mock scrollIntoView since it's not available in JSDOM
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('hooks/dynamicVariables/useGetFieldKeys', () => ({
|
||||
useGetFieldKeys: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDebounce', () => ({
|
||||
__esModule: true,
|
||||
default: (value: any): any => value, // Return the same value without debouncing for testing
|
||||
}));
|
||||
|
||||
describe('DynamicVariable Component', () => {
|
||||
const mockSetDynamicVariablesSelectedValue = jest.fn();
|
||||
const ATTRIBUTE_PLACEHOLDER = 'Select an Attribute';
|
||||
const LOADING_TEXT = 'We are updating the values...';
|
||||
const DEFAULT_PROPS = {
|
||||
setDynamicVariablesSelectedValue: mockSetDynamicVariablesSelectedValue,
|
||||
dynamicVariablesSelectedValue: undefined,
|
||||
};
|
||||
|
||||
const mockFieldKeysResponse = {
|
||||
payload: {
|
||||
keys: {
|
||||
'service.name': [],
|
||||
'http.status_code': [],
|
||||
duration: [],
|
||||
},
|
||||
complete: true,
|
||||
},
|
||||
statusCode: 200,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Default mock implementation
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: mockFieldKeysResponse,
|
||||
error: null,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to get the attribute select element
|
||||
const getAttributeSelect = (): HTMLElement =>
|
||||
screen.getAllByRole('combobox')[0];
|
||||
|
||||
// Helper function to get the source select element
|
||||
const getSourceSelect = (): HTMLElement => screen.getAllByRole('combobox')[1];
|
||||
|
||||
it('renders with default state', () => {
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Check for main components
|
||||
expect(screen.getByText(ATTRIBUTE_PLACEHOLDER)).toBeInTheDocument();
|
||||
expect(screen.getByText('All Sources')).toBeInTheDocument();
|
||||
expect(screen.getByText('from')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses existing values from dynamicVariablesSelectedValue prop', () => {
|
||||
const selectedValue = {
|
||||
name: 'service.name',
|
||||
value: 'Logs',
|
||||
};
|
||||
|
||||
render(
|
||||
<DynamicVariable
|
||||
setDynamicVariablesSelectedValue={mockSetDynamicVariablesSelectedValue}
|
||||
dynamicVariablesSelectedValue={selectedValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify values are set
|
||||
expect(screen.getByText('service.name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Logs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state when fetching data', () => {
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: null,
|
||||
error: null,
|
||||
isLoading: true,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the CustomSelect dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Should show loading state
|
||||
expect(screen.getByText(LOADING_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error message when API fails', () => {
|
||||
const errorMessage = 'Failed to fetch field keys';
|
||||
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: null,
|
||||
error: { message: errorMessage },
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the CustomSelect dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Should show error message
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates filteredAttributes when data is loaded', async () => {
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the CustomSelect dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Wait for options to appear in the dropdown
|
||||
await waitFor(() => {
|
||||
// Looking for option-content elements inside the CustomSelect dropdown
|
||||
const options = document.querySelectorAll('.option-content');
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
|
||||
// Check if all expected options are present
|
||||
let foundServiceName = false;
|
||||
let foundHttpStatusCode = false;
|
||||
let foundDuration = false;
|
||||
|
||||
options.forEach((option) => {
|
||||
const text = option.textContent?.trim();
|
||||
if (text === 'service.name') foundServiceName = true;
|
||||
if (text === 'http.status_code') foundHttpStatusCode = true;
|
||||
if (text === 'duration') foundDuration = true;
|
||||
});
|
||||
|
||||
expect(foundServiceName).toBe(true);
|
||||
expect(foundHttpStatusCode).toBe(true);
|
||||
expect(foundDuration).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setDynamicVariablesSelectedValue when attribute is selected', async () => {
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the attribute dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Wait for options to appear, then click on service.name
|
||||
await waitFor(() => {
|
||||
// Need to find the option-item containing service.name
|
||||
const serviceNameOption = screen.getByText('service.name');
|
||||
expect(serviceNameOption).not.toBeNull();
|
||||
expect(serviceNameOption?.textContent).toBe('service.name');
|
||||
|
||||
// Click on the option-item that contains service.name
|
||||
const optionElement = serviceNameOption?.closest('.option-item');
|
||||
if (optionElement) {
|
||||
fireEvent.click(optionElement);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if the setter was called with the correct value
|
||||
expect(mockSetDynamicVariablesSelectedValue).toHaveBeenCalledWith({
|
||||
name: 'service.name',
|
||||
value: 'All Sources',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls setDynamicVariablesSelectedValue when source is selected', () => {
|
||||
const mockRefetch = jest.fn();
|
||||
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: mockFieldKeysResponse,
|
||||
error: null,
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Get the Select component
|
||||
const select = screen
|
||||
.getByText('All Sources')
|
||||
.closest('div[class*="ant-select"]');
|
||||
expect(select).toBeInTheDocument();
|
||||
|
||||
// Directly call the onChange handler by simulating the Select's onChange
|
||||
// Find the props.onChange of the Select component and call it directly
|
||||
fireEvent.mouseDown(select as HTMLElement);
|
||||
|
||||
// Use a more specific selector to find the "Logs" option
|
||||
const optionsContainer = document.querySelector(
|
||||
'.rc-virtual-list-holder-inner',
|
||||
);
|
||||
expect(optionsContainer).not.toBeNull();
|
||||
|
||||
// Find the option with Logs text content
|
||||
const logsOption = Array.from(
|
||||
optionsContainer?.querySelectorAll('.ant-select-item-option-content') || [],
|
||||
)
|
||||
.find((element) => element.textContent === 'Logs')
|
||||
?.closest('.ant-select-item-option');
|
||||
|
||||
expect(logsOption).not.toBeNull();
|
||||
|
||||
// Click on it
|
||||
if (logsOption) {
|
||||
fireEvent.click(logsOption);
|
||||
}
|
||||
|
||||
// Check if the setter was called with the correct value
|
||||
expect(mockSetDynamicVariablesSelectedValue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: 'Logs',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters attributes locally when complete is true', async () => {
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the attribute dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Mock the filter function behavior
|
||||
const attributeKeys = Object.keys(mockFieldKeysResponse.payload.keys);
|
||||
|
||||
// Only "http.status_code" should match the filter
|
||||
const expectedFilteredKeys = attributeKeys.filter((key) =>
|
||||
key.includes('http'),
|
||||
);
|
||||
|
||||
// Verify our expected filtering logic
|
||||
expect(expectedFilteredKeys).toContain('http.status_code');
|
||||
expect(expectedFilteredKeys).not.toContain('service.name');
|
||||
expect(expectedFilteredKeys).not.toContain('duration');
|
||||
|
||||
// Now verify the component's filtering ability by inputting the search text
|
||||
const inputElement = screen
|
||||
.getAllByRole('combobox')[0]
|
||||
.querySelector('input');
|
||||
if (inputElement) {
|
||||
fireEvent.change(inputElement, { target: { value: 'http' } });
|
||||
}
|
||||
});
|
||||
|
||||
it('triggers API call when complete is false and search text changes', async () => {
|
||||
const mockRefetch = jest.fn();
|
||||
|
||||
// Set up the mock to indicate that data is not complete
|
||||
// and needs to be fetched from the server
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
payload: {
|
||||
keys: {
|
||||
'http.status_code': [],
|
||||
},
|
||||
complete: false, // This indicates server-side filtering is needed
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
// Render with Logs as the initial source
|
||||
render(
|
||||
<DynamicVariable
|
||||
{...DEFAULT_PROPS}
|
||||
dynamicVariablesSelectedValue={{
|
||||
name: '',
|
||||
value: 'Logs',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Clear any initial calls
|
||||
mockRefetch.mockClear();
|
||||
|
||||
// Now test the search functionality
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Find the input element and simulate typing
|
||||
const inputElement = document.querySelector(
|
||||
'.ant-select-selection-search-input',
|
||||
);
|
||||
|
||||
if (inputElement) {
|
||||
// Simulate typing in the search input
|
||||
fireEvent.change(inputElement, { target: { value: 'http' } });
|
||||
|
||||
// Verify that the input has the correct value
|
||||
expect((inputElement as HTMLInputElement).value).toBe('http');
|
||||
|
||||
// Wait for the effect to run and verify refetch was called
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mockRefetch).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
); // Increase timeout to give more time for the effect to run
|
||||
}
|
||||
});
|
||||
|
||||
it('triggers refetch when attributeSource changes', async () => {
|
||||
const mockRefetch = jest.fn();
|
||||
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: mockFieldKeysResponse,
|
||||
error: null,
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Clear any initial calls
|
||||
mockRefetch.mockClear();
|
||||
|
||||
// Find and click on the source select to open dropdown
|
||||
const sourceSelectElement = getSourceSelect();
|
||||
fireEvent.mouseDown(sourceSelectElement);
|
||||
|
||||
// Find and click on the "Metrics" option
|
||||
const metricsOption = screen.getByText('Metrics');
|
||||
fireEvent.click(metricsOption);
|
||||
|
||||
// Wait for the effect to run
|
||||
await waitFor(() => {
|
||||
// Verify that refetch was called after source selection
|
||||
expect(mockRefetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows retry button when error occurs', () => {
|
||||
const mockRefetch = jest.fn();
|
||||
|
||||
(useGetFieldKeys as jest.Mock).mockReturnValue({
|
||||
data: null,
|
||||
error: { message: 'Failed to fetch field keys' },
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
|
||||
render(<DynamicVariable {...DEFAULT_PROPS} />);
|
||||
|
||||
// Open the attribute dropdown
|
||||
const attributeSelectElement = getAttributeSelect();
|
||||
fireEvent.mouseDown(attributeSelectElement);
|
||||
|
||||
// Find and click reload icon (retry button)
|
||||
const reloadIcon = screen.getByLabelText('reload');
|
||||
fireEvent.click(reloadIcon);
|
||||
|
||||
// Should trigger refetch
|
||||
expect(mockRefetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -100,7 +100,6 @@
|
||||
|
||||
.variable-type-btn-group {
|
||||
display: flex;
|
||||
width: 342px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
@@ -199,6 +198,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
.default-value-section {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
|
||||
.default-value-description {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
}
|
||||
|
||||
.variable-textbox-section {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user