mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-20 15:20:31 +01:00
Compare commits
121 Commits
feat/times
...
feat/drill
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8b16e1034 | ||
|
|
749dff2200 | ||
|
|
de05394859 | ||
|
|
a6a9bf5bad | ||
|
|
e767c229aa | ||
|
|
b9cf516201 | ||
|
|
f87e80a0f5 | ||
|
|
f114d0249d | ||
|
|
b4fbd7c673 | ||
|
|
e25d625c4b | ||
|
|
372372694e | ||
|
|
8e5b1be106 | ||
|
|
301d9ca4dd | ||
|
|
f350b0e2f0 | ||
|
|
9ca0cc90b0 | ||
|
|
90758dbd32 | ||
|
|
9b559d6251 | ||
|
|
bdfb712395 | ||
|
|
0d2a4b397a | ||
|
|
2c9a51c2ac | ||
|
|
fb43f12a76 | ||
|
|
60e0e84237 | ||
|
|
54d46a1d03 | ||
|
|
73a7246a11 | ||
|
|
163d59bf71 | ||
|
|
fb672eda11 | ||
|
|
43a432b22b | ||
|
|
8107946cb1 | ||
|
|
38ee4aae30 | ||
|
|
001d9ed9fb | ||
|
|
e1abae91a3 | ||
|
|
a9ac3b7e15 | ||
|
|
4a98c54e78 | ||
|
|
9ed4a09caf | ||
|
|
132a31852f | ||
|
|
5686697b6c | ||
|
|
5f4fc12031 | ||
|
|
fe2c42de90 | ||
|
|
d8f2cf1c0e | ||
|
|
a7e8f31561 | ||
|
|
d9d6e7b4f1 | ||
|
|
f8f1a26a43 | ||
|
|
79dfd6f17f | ||
|
|
f386662e00 | ||
|
|
b2de302262 | ||
|
|
6f63076b8e | ||
|
|
8007f954e5 | ||
|
|
b39b24c46f | ||
|
|
70472c587d | ||
|
|
06e89b7199 | ||
|
|
d60ac0d0e1 | ||
|
|
1e4c213df4 | ||
|
|
9bf112cfcf | ||
|
|
a611b8f429 | ||
|
|
872230169c | ||
|
|
4a28954074 | ||
|
|
0df2d9e6da | ||
|
|
67f412477c | ||
|
|
43dc060950 | ||
|
|
a21ae43a1f | ||
|
|
331a8b386f | ||
|
|
ca6c7afa5c | ||
|
|
dc8e5d6df9 | ||
|
|
c68f352aeb | ||
|
|
7863877a49 | ||
|
|
76384c2430 | ||
|
|
4e06d7757b | ||
|
|
5c06429ebe | ||
|
|
aefc7940a7 | ||
|
|
0deae0c73b | ||
|
|
a4c16e5847 | ||
|
|
efb741cf35 | ||
|
|
153f64067c | ||
|
|
c83ae1a485 | ||
|
|
bfd74fb906 | ||
|
|
5d56f05fab | ||
|
|
57ca53c74c | ||
|
|
bde078472b | ||
|
|
6deb75ff46 | ||
|
|
424fd0362d | ||
|
|
1bc51102f6 | ||
|
|
c1b70c05f1 | ||
|
|
8fce0ab1af | ||
|
|
df1923a7c6 | ||
|
|
1e37ae2fd0 | ||
|
|
7b3ea5cc45 | ||
|
|
167ddc6c56 | ||
|
|
dbc1e1fc45 | ||
|
|
01e798f3c1 | ||
|
|
d9010fb3fc | ||
|
|
06363f2e5b | ||
|
|
f1853a6bca | ||
|
|
97e9f5dc8d | ||
|
|
3b959bd2f6 | ||
|
|
9662e43418 | ||
|
|
736bb2ebfb | ||
|
|
879700ea7a | ||
|
|
438ffe45f2 | ||
|
|
723b6b6b79 | ||
|
|
d2df098bb3 | ||
|
|
196ae10f00 | ||
|
|
00eba89e20 | ||
|
|
1739a9e27b | ||
|
|
cfdf714ffa | ||
|
|
49e78b6998 | ||
|
|
762c658c10 | ||
|
|
48e7e33dea | ||
|
|
dc4996c127 | ||
|
|
d95f7b976c | ||
|
|
9a47883064 | ||
|
|
39a90fd33c | ||
|
|
722c3482d2 | ||
|
|
60e84e6681 | ||
|
|
8d1fa84e6a | ||
|
|
6c22197bf4 | ||
|
|
f6c426d0cc | ||
|
|
e21757b2bd | ||
|
|
a87fbabbe7 | ||
|
|
b2847cb05b | ||
|
|
0b575b41a1 | ||
|
|
0a3fd7a7dc |
@@ -1,44 +0,0 @@
|
||||
# SigNoz OTel Collector Development Environment
|
||||
|
||||
This directory contains the Docker Compose setup for running the SigNoz OpenTelemetry Collector locally during development.
|
||||
|
||||
## What it does
|
||||
|
||||
- Starts the SigNoz OTel Collector container
|
||||
- Exposes OTLP endpoints for receiving telemetry data:
|
||||
- **gRPC**: `localhost:4317`
|
||||
- **HTTP**: `localhost:4318`
|
||||
- Connects to ClickHouse running on the host machine
|
||||
- Processes and forwards telemetry data to ClickHouse
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Start the OTel Collector
|
||||
make devenv-otel-collector
|
||||
|
||||
# Or start both ClickHouse and OTel Collector together
|
||||
make devenv-up
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- ClickHouse must be running (use `make devenv-clickhouse`)
|
||||
- Docker must be installed and running
|
||||
|
||||
## Testing
|
||||
|
||||
Send a test trace to verify everything is working:
|
||||
|
||||
```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"}]}]}]}'
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- `compose.yaml`: Docker Compose configuration for the OTel Collector
|
||||
- `otel-collector-config.yaml`: OpenTelemetry Collector configuration file
|
||||
|
||||
The configuration is set up to connect to ClickHouse via `host.docker.internal:9000`, which allows the containerized collector to reach ClickHouse running on the host machine.
|
||||
@@ -1,29 +0,0 @@
|
||||
services:
|
||||
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"
|
||||
@@ -1,96 +0,0 @@
|
||||
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]
|
||||
19
Makefile
19
Makefile
@@ -61,25 +61,6 @@ devenv-postgres: ## Run postgres in devenv
|
||||
@cd .devenv/docker/postgres; \
|
||||
docker compose -f compose.yaml up -d
|
||||
|
||||
.PHONY: devenv-otel-collector
|
||||
devenv-otel-collector: ## Run otel-collector in devenv (requires clickhouse to be running)
|
||||
@cd .devenv/docker/otel-collector; \
|
||||
docker compose -f compose.yaml up -d
|
||||
|
||||
.PHONY: devenv-up
|
||||
devenv-up: ## Start both clickhouse and otel-collector for local development
|
||||
@echo "Starting ClickHouse..."
|
||||
@cd .devenv/docker/clickhouse; \
|
||||
docker compose -f compose.yaml up -d
|
||||
@echo "Waiting for ClickHouse to be ready..."
|
||||
@sleep 10
|
||||
@echo "Starting OTel Collector..."
|
||||
@cd .devenv/docker/otel-collector; \
|
||||
docker compose -f compose.yaml up -d
|
||||
@echo "✅ Development environment is ready!"
|
||||
@echo " - ClickHouse: http://localhost:8123"
|
||||
@echo " - OTel Collector: grpc://localhost:4317, http://localhost:4318"
|
||||
|
||||
##############################################################
|
||||
# go commands
|
||||
##############################################################
|
||||
|
||||
@@ -44,35 +44,20 @@ 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. Setting up OpenTelemetry Collector
|
||||
|
||||
Next, start the OpenTelemetry Collector to receive telemetry data:
|
||||
|
||||
```bash
|
||||
make devenv-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
|
||||
### 2. Starting the Backend
|
||||
|
||||
1. Run the backend server:
|
||||
```bash
|
||||
@@ -88,7 +73,7 @@ This command:
|
||||
|
||||
> 💡 **Tip**: The API server runs at `http://localhost:8080/` by default
|
||||
|
||||
### 4. Setting up the Frontend
|
||||
### 3. Setting up the Frontend
|
||||
|
||||
1. Navigate to the frontend directory:
|
||||
```bash
|
||||
@@ -113,26 +98,3 @@ 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! 🎉
|
||||
|
||||
## Testing 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
|
||||
|
||||
## Sending Test Data
|
||||
|
||||
You can now send telemetry data to your local SigNoz instance:
|
||||
|
||||
- **OTLP gRPC**: `localhost:4317`
|
||||
- **OTLP HTTP**: `localhost:4318`
|
||||
|
||||
For example, using `curl` to send a test trace:
|
||||
```bash
|
||||
curl -X POST http://localhost:4318/v1/traces \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"test-service"}}]},"scopeSpans":[{"spans":[{"traceId":"12345678901234567890123456789012","spanId":"1234567890123456","name":"test-span","startTimeUnixNano":"1609459200000000000","endTimeUnixNano":"1609459201000000000"}]}]}]}'
|
||||
```
|
||||
|
||||
@@ -50,19 +50,14 @@ func (p *BaseSeasonalProvider) getQueryParams(req *AnomaliesRequest) *anomalyQue
|
||||
|
||||
func (p *BaseSeasonalProvider) toTSResults(ctx context.Context, resp *qbtypes.QueryRangeResponse) []*qbtypes.TimeSeriesData {
|
||||
|
||||
if resp == nil || resp.Data == nil {
|
||||
tsData := []*qbtypes.TimeSeriesData{}
|
||||
|
||||
if resp == nil {
|
||||
p.logger.InfoContext(ctx, "nil response from query range")
|
||||
return tsData
|
||||
}
|
||||
|
||||
data, ok := resp.Data.(struct {
|
||||
Results []any `json:"results"`
|
||||
Warnings []string `json:"warnings"`
|
||||
})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
tsData := []*qbtypes.TimeSeriesData{}
|
||||
for _, item := range data.Results {
|
||||
for _, item := range resp.Data.Results {
|
||||
if resultData, ok := item.(*qbtypes.TimeSeriesData); ok {
|
||||
tsData = append(tsData, resultData)
|
||||
}
|
||||
@@ -395,6 +390,11 @@ func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, orgID valuer.UU
|
||||
continue
|
||||
}
|
||||
|
||||
// no data;
|
||||
if len(result.Aggregations) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
aggOfInterest := result.Aggregations[0]
|
||||
|
||||
for _, series := range aggOfInterest.Series {
|
||||
|
||||
@@ -113,6 +113,8 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
// v5
|
||||
router.HandleFunc("/api/v5/query_range", am.ViewAccess(ah.queryRangeV5)).Methods(http.MethodPost)
|
||||
|
||||
router.HandleFunc("/api/v5/substitute_vars", am.ViewAccess(ah.QuerierAPI.ReplaceVariables)).Methods(http.MethodPost)
|
||||
|
||||
// Gateway
|
||||
router.PathPrefix(gateway.RoutePrefix).HandlerFunc(am.EditAccess(ah.ServeGatewayHTTP))
|
||||
|
||||
|
||||
@@ -260,11 +260,9 @@ func (aH *APIHandler) queryRangeV5(rw http.ResponseWriter, req *http.Request) {
|
||||
finalResp := &qbtypes.QueryRangeResponse{
|
||||
Type: queryRangeRequest.RequestType,
|
||||
Data: struct {
|
||||
Results []any `json:"results"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Results []any `json:"results"`
|
||||
}{
|
||||
Results: results,
|
||||
Warnings: make([]string, 0), // TODO(srikanthccv): will there be any warnings here?
|
||||
Results: results,
|
||||
},
|
||||
Meta: struct {
|
||||
RowsScanned uint64 `json:"rowsScanned"`
|
||||
|
||||
@@ -211,7 +211,8 @@ func (r *AnomalyRule) prepareQueryRangeV5(ctx context.Context, ts time.Time) (*q
|
||||
},
|
||||
NoCache: true,
|
||||
}
|
||||
copy(r.Condition().CompositeQuery.Queries, req.CompositeQuery.Queries)
|
||||
req.CompositeQuery.Queries = make([]qbtypes.QueryEnvelope, len(r.Condition().CompositeQuery.Queries))
|
||||
copy(req.CompositeQuery.Queries, r.Condition().CompositeQuery.Queries)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ export const ORG_PREFERENCES = {
|
||||
'welcome_checklist_setup_alerts_skipped',
|
||||
WELCOME_CHECKLIST_SETUP_SAVED_VIEW_SKIPPED:
|
||||
'welcome_checklist_setup_saved_view_skipped',
|
||||
WELCOME_CHECKLIST_SEND_INFRA_METRICS_SKIPPED:
|
||||
'welcome_checklist_send_infra_metrics_skipped',
|
||||
WELCOME_CHECKLIST_SEND_METRICS_SKIPPED:
|
||||
'welcome_checklist_send_metrics_skipped',
|
||||
WELCOME_CHECKLIST_SETUP_DASHBOARDS_SKIPPED:
|
||||
'welcome_checklist_setup_dashboards_skipped',
|
||||
WELCOME_CHECKLIST_SETUP_WORKSPACE_SKIPPED:
|
||||
|
||||
@@ -46,6 +46,7 @@ export enum QueryParams {
|
||||
msgSystem = 'msgSystem',
|
||||
destination = 'destination',
|
||||
kindString = 'kindString',
|
||||
summaryFilters = 'summaryFilters',
|
||||
tab = 'tab',
|
||||
thresholds = 'thresholds',
|
||||
selectedExplorerView = 'selectedExplorerView',
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
overflow-y: hidden;
|
||||
|
||||
.full-view-header-container {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import './WidgetFullView.styles.scss';
|
||||
|
||||
import {
|
||||
@@ -8,18 +9,23 @@ import {
|
||||
import { Button, Input, Spin } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import Spinner from 'components/Spinner';
|
||||
import TimePreference from 'components/TimePreferenceDropDown';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import useDrilldown from 'container/GridCardLayout/GridCard/FullView/useDrilldown';
|
||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||
import {
|
||||
timeItems,
|
||||
timePreferance,
|
||||
} from 'container/NewWidget/RightContainer/timeItems';
|
||||
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useChartMutable } from 'hooks/useChartMutable';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
@@ -52,6 +58,7 @@ function FullView({
|
||||
onClickHandler,
|
||||
customOnDragSelect,
|
||||
setCurrentGraphRef,
|
||||
enableDrillDown = false,
|
||||
}: FullViewProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { selectedTime: globalSelectedTime } = useSelector<
|
||||
@@ -63,6 +70,7 @@ function FullView({
|
||||
const location = useLocation();
|
||||
|
||||
const fullViewRef = useRef<HTMLDivElement>(null);
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentGraphRef(fullViewRef);
|
||||
@@ -114,6 +122,13 @@ function FullView({
|
||||
};
|
||||
});
|
||||
|
||||
const { dashboardEditView, handleResetQuery, showResetQuery } = useDrilldown({
|
||||
enableDrillDown,
|
||||
widget,
|
||||
setRequestData,
|
||||
selectedDashboard,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setRequestData((prev) => ({
|
||||
...prev,
|
||||
@@ -204,71 +219,115 @@ function FullView({
|
||||
|
||||
return (
|
||||
<div className="full-view-container">
|
||||
<div className="full-view-header-container">
|
||||
{fullViewOptions && (
|
||||
<TimeContainer $panelType={widget.panelTypes}>
|
||||
{response.isFetching && (
|
||||
<Spin spinning indicator={<LoadingOutlined spin />} />
|
||||
<OverlayScrollbar>
|
||||
<>
|
||||
<div className="full-view-header-container">
|
||||
{fullViewOptions && (
|
||||
<TimeContainer $panelType={widget.panelTypes}>
|
||||
{enableDrillDown && (
|
||||
<div className="drildown-options-container">
|
||||
{showResetQuery && (
|
||||
<Button type="link" onClick={handleResetQuery}>
|
||||
Reset Query
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="switch-edit-btn"
|
||||
disabled={response.isFetching || response.isLoading}
|
||||
onClick={(): void => {
|
||||
if (dashboardEditView) {
|
||||
safeNavigate(dashboardEditView);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Switch to Edit Mode
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="time-container">
|
||||
{response.isFetching && (
|
||||
<Spin spinning indicator={<LoadingOutlined spin />} />
|
||||
)}
|
||||
<TimePreference
|
||||
selectedTime={selectedTime}
|
||||
setSelectedTime={setSelectedTime}
|
||||
/>
|
||||
<Button
|
||||
style={{
|
||||
marginLeft: '4px',
|
||||
}}
|
||||
onClick={(): void => {
|
||||
response.refetch();
|
||||
}}
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
/>
|
||||
</div>
|
||||
</TimeContainer>
|
||||
)}
|
||||
<TimePreference
|
||||
selectedTime={selectedTime}
|
||||
setSelectedTime={setSelectedTime}
|
||||
/>
|
||||
<Button
|
||||
style={{
|
||||
marginLeft: '4px',
|
||||
}}
|
||||
onClick={(): void => {
|
||||
response.refetch();
|
||||
}}
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
/>
|
||||
</TimeContainer>
|
||||
)}
|
||||
</div>
|
||||
{enableDrillDown && (
|
||||
<>
|
||||
<QueryBuilderV2
|
||||
panelType={widget.panelTypes}
|
||||
version={selectedDashboard?.data?.version || 'v3'}
|
||||
isListViewPanel={widget.panelTypes === PANEL_TYPES.LIST}
|
||||
// filterConfigs={filterConfigs}
|
||||
// queryComponents={queryComponents}
|
||||
/>
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => {
|
||||
handleRunQuery(true, true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cx('graph-container', {
|
||||
disabled: isDashboardLocked,
|
||||
'height-widget': widget?.mergeAllActiveQueries || widget?.stackedBarChart,
|
||||
'list-graph-container': isListView,
|
||||
})}
|
||||
ref={fullViewRef}
|
||||
>
|
||||
<GraphContainer
|
||||
style={{
|
||||
height: isListView ? '100%' : '90%',
|
||||
}}
|
||||
isGraphLegendToggleAvailable={canModifyChart}
|
||||
>
|
||||
{isTablePanel && (
|
||||
<Input
|
||||
addonBefore={<SearchOutlined size={14} />}
|
||||
className="global-search"
|
||||
placeholder="Search..."
|
||||
allowClear
|
||||
key={widget.id}
|
||||
onChange={(e): void => {
|
||||
setSearchTerm(e.target.value || '');
|
||||
<div
|
||||
className={cx('graph-container', {
|
||||
disabled: isDashboardLocked,
|
||||
'height-widget':
|
||||
widget?.mergeAllActiveQueries || widget?.stackedBarChart,
|
||||
'list-graph-container': isListView,
|
||||
})}
|
||||
ref={fullViewRef}
|
||||
>
|
||||
<GraphContainer
|
||||
style={{
|
||||
height: isListView ? '100%' : '90%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<PanelWrapper
|
||||
queryResponse={response}
|
||||
widget={widget}
|
||||
setRequestData={setRequestData}
|
||||
isFullViewMode
|
||||
onToggleModelHandler={onToggleModelHandler}
|
||||
setGraphVisibility={setGraphsVisibilityStates}
|
||||
graphVisibility={graphsVisibilityStates}
|
||||
onDragSelect={customOnDragSelect ?? onDragSelect}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
searchTerm={searchTerm}
|
||||
onClickHandler={onClickHandler}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</div>
|
||||
isGraphLegendToggleAvailable={canModifyChart}
|
||||
>
|
||||
{isTablePanel && (
|
||||
<Input
|
||||
addonBefore={<SearchOutlined size={14} />}
|
||||
className="global-search"
|
||||
placeholder="Search..."
|
||||
allowClear
|
||||
key={widget.id}
|
||||
onChange={(e): void => {
|
||||
setSearchTerm(e.target.value || '');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<PanelWrapper
|
||||
queryResponse={response}
|
||||
widget={widget}
|
||||
setRequestData={setRequestData}
|
||||
isFullViewMode
|
||||
onToggleModelHandler={onToggleModelHandler}
|
||||
setGraphVisibility={setGraphsVisibilityStates}
|
||||
graphVisibility={graphsVisibilityStates}
|
||||
onDragSelect={customOnDragSelect ?? onDragSelect}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
searchTerm={searchTerm}
|
||||
onClickHandler={onClickHandler}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</GraphContainer>
|
||||
</div>
|
||||
</>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const NotFoundContainer = styled.div`
|
||||
export const TimeContainer = styled.div<Props>`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
${({ $panelType }): FlattenSimpleInterpolation =>
|
||||
$panelType === PANEL_TYPES.TABLE
|
||||
@@ -25,6 +26,10 @@ export const TimeContainer = styled.div<Props>`
|
||||
margin-bottom: 1rem;
|
||||
`
|
||||
: css``}
|
||||
|
||||
.time-container {
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
|
||||
export const GraphContainer = styled.div<GraphContainerProps>`
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface FullViewProps {
|
||||
isDependedDataLoaded?: boolean;
|
||||
onToggleModelHandler?: GraphManagerProps['onToggleModelHandler'];
|
||||
setCurrentGraphRef: Dispatch<SetStateAction<RefObject<HTMLDivElement> | null>>;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
export interface GraphManagerProps extends UplotProps {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
|
||||
export interface DrilldownQueryProps {
|
||||
widget: Widgets;
|
||||
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
|
||||
enableDrillDown: boolean;
|
||||
selectedDashboard: Dashboard | undefined;
|
||||
}
|
||||
|
||||
export interface UseDrilldownReturn {
|
||||
dashboardEditView: string;
|
||||
handleResetQuery: () => void;
|
||||
showResetQuery: boolean;
|
||||
}
|
||||
|
||||
const useDrilldown = ({
|
||||
enableDrillDown,
|
||||
widget,
|
||||
setRequestData,
|
||||
selectedDashboard,
|
||||
}: DrilldownQueryProps): UseDrilldownReturn => {
|
||||
const isMounted = useRef(false);
|
||||
const { redirectWithQueryBuilderData, currentQuery } = useQueryBuilder();
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
|
||||
useEffect(() => {
|
||||
if (enableDrillDown && !!compositeQuery) {
|
||||
setRequestData((prev) => ({
|
||||
...prev,
|
||||
query: compositeQuery,
|
||||
}));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentQuery, compositeQuery]);
|
||||
|
||||
// update composite query with widget query if composite query is not present in url.
|
||||
// Composite query should be in the url if switch to edit mode is clicked or drilldown happens from dashboard.
|
||||
useEffect(() => {
|
||||
if (enableDrillDown && !isMounted.current) {
|
||||
redirectWithQueryBuilderData(compositeQuery || widget.query);
|
||||
}
|
||||
isMounted.current = true;
|
||||
}, [widget, enableDrillDown, compositeQuery, redirectWithQueryBuilderData]);
|
||||
|
||||
const dashboardEditView = selectedDashboard?.id
|
||||
? generateExportToDashboardLink({
|
||||
query: currentQuery,
|
||||
panelType: widget.panelTypes,
|
||||
dashboardId: selectedDashboard?.id || '',
|
||||
widgetId: widget.id,
|
||||
})
|
||||
: '';
|
||||
|
||||
const showResetQuery = useMemo(
|
||||
() =>
|
||||
JSON.stringify(widget.query?.builder) !==
|
||||
JSON.stringify(compositeQuery?.builder),
|
||||
[widget.query, compositeQuery],
|
||||
);
|
||||
|
||||
const handleResetQuery = useCallback((): void => {
|
||||
redirectWithQueryBuilderData(widget.query);
|
||||
}, [redirectWithQueryBuilderData, widget.query]);
|
||||
|
||||
return {
|
||||
dashboardEditView,
|
||||
handleResetQuery,
|
||||
showResetQuery,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDrilldown;
|
||||
@@ -62,6 +62,7 @@ function WidgetGraphComponent({
|
||||
customErrorMessage,
|
||||
customOnRowClick,
|
||||
customTimeRangeWindowForCoRelation,
|
||||
enableDrillDown,
|
||||
}: WidgetGraphComponentProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
@@ -226,6 +227,7 @@ function WidgetGraphComponent({
|
||||
const onToggleModelHandler = (): void => {
|
||||
const existingSearchParams = new URLSearchParams(search);
|
||||
existingSearchParams.delete(QueryParams.expandedWidgetId);
|
||||
existingSearchParams.delete(QueryParams.compositeQuery);
|
||||
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
|
||||
if (queryResponse.data?.payload) {
|
||||
const {
|
||||
@@ -354,6 +356,7 @@ function WidgetGraphComponent({
|
||||
onClickHandler={onClickHandler ?? graphClickHandler}
|
||||
customOnDragSelect={customOnDragSelect}
|
||||
setCurrentGraphRef={setCurrentGraphRef}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -405,6 +408,7 @@ function WidgetGraphComponent({
|
||||
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
||||
customSeries={customSeries}
|
||||
customOnRowClick={customOnRowClick}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -417,6 +421,7 @@ WidgetGraphComponent.defaultProps = {
|
||||
setLayout: undefined,
|
||||
onClickHandler: undefined,
|
||||
customTimeRangeWindowForCoRelation: undefined,
|
||||
enableDrillDown: false,
|
||||
};
|
||||
|
||||
export default WidgetGraphComponent;
|
||||
|
||||
@@ -53,6 +53,7 @@ function GridCardGraph({
|
||||
customTimeRange,
|
||||
customOnRowClick,
|
||||
customTimeRangeWindowForCoRelation,
|
||||
enableDrillDown,
|
||||
}: GridCardGraphProps): JSX.Element {
|
||||
const dispatch = useDispatch();
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
@@ -318,6 +319,7 @@ function GridCardGraph({
|
||||
customErrorMessage={isInternalServerError ? customErrorMessage : undefined}
|
||||
customOnRowClick={customOnRowClick}
|
||||
customTimeRangeWindowForCoRelation={customTimeRangeWindowForCoRelation}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -333,6 +335,7 @@ GridCardGraph.defaultProps = {
|
||||
version: 'v3',
|
||||
analyticsEvent: undefined,
|
||||
customTimeRangeWindowForCoRelation: undefined,
|
||||
enableDrillDown: false,
|
||||
};
|
||||
|
||||
export default memo(GridCardGraph);
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface WidgetGraphComponentProps {
|
||||
customErrorMessage?: string;
|
||||
customOnRowClick?: (record: RowData) => void;
|
||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
export interface GridCardGraphProps {
|
||||
@@ -69,6 +70,7 @@ export interface GridCardGraphProps {
|
||||
};
|
||||
customOnRowClick?: (record: RowData) => void;
|
||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
export interface GetGraphVisibilityStateOnLegendClickProps {
|
||||
|
||||
@@ -53,11 +53,12 @@ import { WidgetRowHeader } from './WidgetRow';
|
||||
|
||||
interface GraphLayoutProps {
|
||||
handle: FullScreenHandle;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
const { handle } = props;
|
||||
const { handle, enableDrillDown = false } = props;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const {
|
||||
selectedDashboard,
|
||||
@@ -584,6 +585,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
version={ENTITY_VERSION_V5}
|
||||
onDragSelect={onDragSelect}
|
||||
dataAvailable={checkIfDataExists}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</Card>
|
||||
</CardContainer>
|
||||
@@ -670,3 +672,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
|
||||
}
|
||||
|
||||
export default GraphLayout;
|
||||
|
||||
GraphLayout.defaultProps = {
|
||||
enableDrillDown: false,
|
||||
};
|
||||
|
||||
@@ -4,10 +4,17 @@ import GraphLayoutContainer from './GridCardLayout';
|
||||
|
||||
interface GridGraphProps {
|
||||
handle: FullScreenHandle;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
function GridGraph(props: GridGraphProps): JSX.Element {
|
||||
const { handle } = props;
|
||||
return <GraphLayoutContainer handle={handle} />;
|
||||
const { handle, enableDrillDown = false } = props;
|
||||
return (
|
||||
<GraphLayoutContainer handle={handle} enableDrillDown={enableDrillDown} />
|
||||
);
|
||||
}
|
||||
|
||||
export default GridGraph;
|
||||
|
||||
GridGraph.defaultProps = {
|
||||
enableDrillDown: false,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ export type GridTableComponentProps = {
|
||||
widgetId?: string;
|
||||
renderColumnCell?: QueryTableProps['renderColumnCell'];
|
||||
customColTitles?: Record<string, string>;
|
||||
enableDrillDown?: boolean;
|
||||
} & Pick<LogsExplorerTableProps, 'data'> &
|
||||
Omit<TableProps<RowData>, 'columns' | 'dataSource'>;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { ColumnsType, ColumnType } from 'antd/es/table';
|
||||
import { ColumnType } from 'antd/es/table';
|
||||
import { convertUnit } from 'container/NewWidget/RightContainer/dataFormatCategories';
|
||||
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
|
||||
import { QUERY_TABLE_CONFIG } from 'container/QueryTable/config';
|
||||
@@ -9,6 +9,12 @@ import { isEmpty, isNaN } from 'lodash-es';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
// Custom column type that extends ColumnType to include isValueColumn
|
||||
export interface CustomDataColumnType<T> extends ColumnType<T> {
|
||||
isValueColumn?: boolean;
|
||||
queryName?: string;
|
||||
}
|
||||
|
||||
// Helper function to evaluate the condition based on the operator
|
||||
function evaluateCondition(
|
||||
operator: string | undefined,
|
||||
@@ -180,9 +186,9 @@ export function createColumnsAndDataSource(
|
||||
data: TableData,
|
||||
currentQuery: Query,
|
||||
renderColumnCell?: QueryTableProps['renderColumnCell'],
|
||||
): { columns: ColumnsType<RowData>; dataSource: RowData[] } {
|
||||
const columns: ColumnsType<RowData> =
|
||||
data.columns?.reduce<ColumnsType<RowData>>((acc, item) => {
|
||||
): { columns: CustomDataColumnType<RowData>[]; dataSource: RowData[] } {
|
||||
const columns: CustomDataColumnType<RowData>[] =
|
||||
data.columns?.reduce<CustomDataColumnType<RowData>[]>((acc, item) => {
|
||||
// is the column is the value column then we need to check for the available legend
|
||||
const legend = item.isValueColumn
|
||||
? getQueryLegend(currentQuery, item.queryName)
|
||||
@@ -193,11 +199,13 @@ export function createColumnsAndDataSource(
|
||||
(query) => query.queryName === item.queryName,
|
||||
)?.aggregations?.length || 0;
|
||||
|
||||
const column: ColumnType<RowData> = {
|
||||
const column: CustomDataColumnType<RowData> = {
|
||||
dataIndex: item.id || item.name,
|
||||
// if no legend present then rely on the column name value
|
||||
title: !isNewAggregation && !isEmpty(legend) ? legend : item.name,
|
||||
width: QUERY_TABLE_CONFIG.width,
|
||||
isValueColumn: item.isValueColumn,
|
||||
queryName: item.queryName,
|
||||
render: renderColumnCell && renderColumnCell[item.name],
|
||||
sorter: (a: RowData, b: RowData): number => sortFunction(a, b, item),
|
||||
};
|
||||
|
||||
@@ -4,21 +4,17 @@ import './Home.styles.scss';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { HostListPayload } from 'api/infraMonitoring/getHostLists';
|
||||
import { K8sPodsListPayload } from 'api/infraMonitoring/getK8sPodsList';
|
||||
import listUserPreferences from 'api/v1/user/preferences/list';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import Header from 'components/Header/Header';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { ORG_PREFERENCES } from 'constants/orgPreferences';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getHostListsQuery } from 'container/InfraMonitoringHosts/utils';
|
||||
import { useGetHostList } from 'hooks/infraMonitoring/useGetHostList';
|
||||
import { useGetK8sPodsList } from 'hooks/infraMonitoring/useGetK8sPodsList';
|
||||
import { getMetricsListQuery } from 'container/MetricsExplorer/Summary/utils';
|
||||
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import history from 'lib/history';
|
||||
@@ -132,9 +128,9 @@ export default function Home(): JSX.Element {
|
||||
},
|
||||
);
|
||||
|
||||
// Detect Infra Metrics - Hosts
|
||||
// Detect Metrics
|
||||
const query = useMemo(() => {
|
||||
const baseQuery = getHostListsQuery();
|
||||
const baseQuery = getMetricsListQuery();
|
||||
|
||||
let queryStartTime = startTime;
|
||||
let queryEndTime = endTime;
|
||||
@@ -161,26 +157,11 @@ export default function Home(): JSX.Element {
|
||||
};
|
||||
}, [startTime, endTime]);
|
||||
|
||||
const { data: hostData } = useGetHostList(query as HostListPayload, {
|
||||
queryKey: ['hostList', query],
|
||||
const { data: metricsData } = useGetMetricsList(query, {
|
||||
enabled: !!query,
|
||||
queryKey: ['metricsList', query],
|
||||
});
|
||||
|
||||
const { featureFlags } = useAppContext();
|
||||
const dotMetricsEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
|
||||
?.active || false;
|
||||
|
||||
const { data: k8sPodsData } = useGetK8sPodsList(
|
||||
query as K8sPodsListPayload,
|
||||
{
|
||||
queryKey: ['K8sPodsList', query],
|
||||
enabled: !!query,
|
||||
},
|
||||
undefined,
|
||||
dotMetricsEnabled,
|
||||
);
|
||||
|
||||
const [isLogsIngestionActive, setIsLogsIngestionActive] = useState(false);
|
||||
const [isTracesIngestionActive, setIsTracesIngestionActive] = useState(false);
|
||||
const [isMetricsIngestionActive, setIsMetricsIngestionActive] = useState(
|
||||
@@ -305,15 +286,14 @@ export default function Home(): JSX.Element {
|
||||
}, [tracesData, handleUpdateChecklistDoneItem]);
|
||||
|
||||
useEffect(() => {
|
||||
const hostDataTotal = hostData?.payload?.data?.total ?? 0;
|
||||
const k8sPodsDataTotal = k8sPodsData?.payload?.data?.total ?? 0;
|
||||
const metricsDataTotal = metricsData?.payload?.data?.total ?? 0;
|
||||
|
||||
if (hostDataTotal > 0 || k8sPodsDataTotal > 0) {
|
||||
if (metricsDataTotal > 0) {
|
||||
setIsMetricsIngestionActive(true);
|
||||
handleUpdateChecklistDoneItem('ADD_DATA_SOURCE');
|
||||
handleUpdateChecklistDoneItem('SEND_INFRA_METRICS');
|
||||
handleUpdateChecklistDoneItem('SEND_METRICS');
|
||||
}
|
||||
}, [hostData, k8sPodsData, handleUpdateChecklistDoneItem]);
|
||||
}, [metricsData, handleUpdateChecklistDoneItem]);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent('Homepage: Visited', {});
|
||||
@@ -520,19 +500,19 @@ export default function Home(): JSX.Element {
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Metrics',
|
||||
});
|
||||
history.push(ROUTES.INFRASTRUCTURE_MONITORING_HOSTS);
|
||||
history.push(ROUTES.METRICS_EXPLORER);
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
logEvent('Homepage: Ingestion Active Explore clicked', {
|
||||
source: 'Metrics',
|
||||
});
|
||||
history.push(ROUTES.INFRASTRUCTURE_MONITORING_HOSTS);
|
||||
history.push(ROUTES.METRICS_EXPLORER);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CompassIcon size={12} />
|
||||
Explore Infra Metrics
|
||||
Explore Metrics
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
@@ -593,6 +573,20 @@ export default function Home(): JSX.Element {
|
||||
>
|
||||
Open Traces Explorer
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn secondary"
|
||||
icon={<Wrench size={14} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Homepage: Explore clicked', {
|
||||
source: 'Metrics',
|
||||
});
|
||||
history.push(ROUTES.METRICS_EXPLORER_EXPLORER);
|
||||
}}
|
||||
>
|
||||
Open Metrics Explorer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpRight,
|
||||
BarChart,
|
||||
CompassIcon,
|
||||
DraftingCompass,
|
||||
} from 'lucide-react';
|
||||
@@ -42,6 +43,12 @@ export default function SavedViews({
|
||||
isError: tracesViewsError,
|
||||
} = useGetAllViews(DataSource.TRACES);
|
||||
|
||||
const {
|
||||
data: metricsViewsData,
|
||||
isLoading: metricsViewsLoading,
|
||||
isError: metricsViewsError,
|
||||
} = useGetAllViews(DataSource.METRICS);
|
||||
|
||||
const logsViews = useMemo(() => [...(logsViewsData?.data.data || [])], [
|
||||
logsViewsData,
|
||||
]);
|
||||
@@ -50,14 +57,25 @@ export default function SavedViews({
|
||||
tracesViewsData,
|
||||
]);
|
||||
|
||||
const metricsViews = useMemo(() => [...(metricsViewsData?.data.data || [])], [
|
||||
metricsViewsData,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedEntityViews(selectedEntity === 'logs' ? logsViews : tracesViews);
|
||||
}, [selectedEntity, logsViews, tracesViews]);
|
||||
if (selectedEntity === 'logs') {
|
||||
setSelectedEntityViews(logsViews);
|
||||
} else if (selectedEntity === 'traces') {
|
||||
setSelectedEntityViews(tracesViews);
|
||||
} else if (selectedEntity === 'metrics') {
|
||||
setSelectedEntityViews(metricsViews);
|
||||
}
|
||||
}, [selectedEntity, logsViews, tracesViews, metricsViews]);
|
||||
|
||||
const hasTracesViews = tracesViews.length > 0;
|
||||
const hasLogsViews = logsViews.length > 0;
|
||||
const hasMetricsViews = metricsViews.length > 0;
|
||||
|
||||
const hasSavedViews = hasTracesViews || hasLogsViews;
|
||||
const hasSavedViews = hasTracesViews || hasLogsViews || hasMetricsViews;
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
@@ -68,10 +86,16 @@ export default function SavedViews({
|
||||
entity: selectedEntity,
|
||||
});
|
||||
|
||||
const currentViewDetails = getViewDetailsUsingViewKey(
|
||||
view.id,
|
||||
selectedEntity === 'logs' ? logsViews : tracesViews,
|
||||
);
|
||||
let currentViews: ViewProps[] = [];
|
||||
if (selectedEntity === 'logs') {
|
||||
currentViews = logsViews;
|
||||
} else if (selectedEntity === 'traces') {
|
||||
currentViews = tracesViews;
|
||||
} else if (selectedEntity === 'metrics') {
|
||||
currentViews = metricsViews;
|
||||
}
|
||||
|
||||
const currentViewDetails = getViewDetailsUsingViewKey(view.id, currentViews);
|
||||
if (!currentViewDetails) return;
|
||||
const { query, name, id, panelType: currentPanelType } = currentViewDetails;
|
||||
|
||||
@@ -94,6 +118,32 @@ export default function SavedViews({
|
||||
}
|
||||
}, [hasSavedViews, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
|
||||
const footerLink = useMemo(() => {
|
||||
if (selectedEntity === 'logs') {
|
||||
return ROUTES.LOGS_SAVE_VIEWS;
|
||||
}
|
||||
if (selectedEntity === 'traces') {
|
||||
return ROUTES.TRACES_SAVE_VIEWS;
|
||||
}
|
||||
if (selectedEntity === 'metrics') {
|
||||
return ROUTES.METRICS_EXPLORER_VIEWS;
|
||||
}
|
||||
return '';
|
||||
}, [selectedEntity]);
|
||||
|
||||
const getStartedLink = useMemo(() => {
|
||||
if (selectedEntity === 'logs') {
|
||||
return ROUTES.LOGS_EXPLORER;
|
||||
}
|
||||
if (selectedEntity === 'traces') {
|
||||
return ROUTES.TRACES_EXPLORER;
|
||||
}
|
||||
if (selectedEntity === 'metrics') {
|
||||
return ROUTES.METRICS_EXPLORER_EXPLORER;
|
||||
}
|
||||
return '';
|
||||
}, [selectedEntity]);
|
||||
|
||||
const emptyStateCard = (): JSX.Element => (
|
||||
<div className="empty-state-container">
|
||||
<div className="empty-state-content-container">
|
||||
@@ -115,13 +165,7 @@ export default function SavedViews({
|
||||
|
||||
{user?.role !== USER_ROLES.VIEWER && (
|
||||
<div className="empty-actions-container">
|
||||
<Link
|
||||
to={
|
||||
selectedEntity === 'logs'
|
||||
? ROUTES.LOGS_EXPLORER
|
||||
: ROUTES.TRACES_EXPLORER
|
||||
}
|
||||
>
|
||||
<Link to={getStartedLink}>
|
||||
<Button
|
||||
type="default"
|
||||
className="periscope-btn secondary"
|
||||
@@ -238,6 +282,14 @@ export default function SavedViews({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEntity === 'metrics' && metricsViewsError && (
|
||||
<div className="metrics-saved-views-error-container">
|
||||
<div className="metrics-saved-views-error-message">
|
||||
Oops, something went wrong while loading your saved views.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -246,11 +298,19 @@ export default function SavedViews({
|
||||
logEvent('Homepage: Saved views switched', {
|
||||
tab,
|
||||
});
|
||||
setSelectedEntityViews(tab === 'logs' ? logsViews : tracesViews);
|
||||
let currentViews: ViewProps[] = [];
|
||||
if (tab === 'logs') {
|
||||
currentViews = logsViews;
|
||||
} else if (tab === 'traces') {
|
||||
currentViews = tracesViews;
|
||||
} else if (tab === 'metrics') {
|
||||
currentViews = metricsViews;
|
||||
}
|
||||
setSelectedEntityViews(currentViews);
|
||||
setSelectedEntity(tab);
|
||||
};
|
||||
|
||||
if (logsViewsLoading || tracesViewsLoading) {
|
||||
if (logsViewsLoading || tracesViewsLoading || metricsViewsLoading) {
|
||||
return (
|
||||
<Card className="saved-views-list-card home-data-card loading-card">
|
||||
<Card.Content>
|
||||
@@ -260,7 +320,7 @@ export default function SavedViews({
|
||||
);
|
||||
}
|
||||
|
||||
if (logsViewsError || tracesViewsError) {
|
||||
if (logsViewsError || tracesViewsError || metricsViewsError) {
|
||||
return (
|
||||
<Card className="saved-views-list-card home-data-card error-card">
|
||||
<Card.Content>
|
||||
@@ -299,6 +359,16 @@ export default function SavedViews({
|
||||
>
|
||||
<DraftingCompass size={14} /> Traces
|
||||
</Button>
|
||||
<Button
|
||||
value="metrics"
|
||||
className={
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
selectedEntity === 'metrics' ? 'selected tab' : 'tab'
|
||||
}
|
||||
onClick={(): void => handleTabChange('metrics')}
|
||||
>
|
||||
<BarChart size={14} /> Metrics
|
||||
</Button>
|
||||
</Button.Group>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,13 +382,7 @@ export default function SavedViews({
|
||||
{selectedEntityViews.length > 0 && (
|
||||
<Card.Footer>
|
||||
<div className="services-footer home-data-card-footer">
|
||||
<Link
|
||||
to={
|
||||
selectedEntity === 'logs'
|
||||
? ROUTES.LOGS_SAVE_VIEWS
|
||||
: ROUTES.TRACES_SAVE_VIEWS
|
||||
}
|
||||
>
|
||||
<Link to={footerLink}>
|
||||
<Button
|
||||
type="link"
|
||||
className="periscope-btn link learn-more-link"
|
||||
|
||||
@@ -7,8 +7,7 @@ export const checkListStepToPreferenceKeyMap = {
|
||||
WILL_DO_LATER: ORG_PREFERENCES.WELCOME_CHECKLIST_DO_LATER,
|
||||
SEND_LOGS: ORG_PREFERENCES.WELCOME_CHECKLIST_SEND_LOGS_SKIPPED,
|
||||
SEND_TRACES: ORG_PREFERENCES.WELCOME_CHECKLIST_SEND_TRACES_SKIPPED,
|
||||
SEND_INFRA_METRICS:
|
||||
ORG_PREFERENCES.WELCOME_CHECKLIST_SEND_INFRA_METRICS_SKIPPED,
|
||||
SEND_METRICS: ORG_PREFERENCES.WELCOME_CHECKLIST_SEND_METRICS_SKIPPED,
|
||||
SETUP_DASHBOARDS: ORG_PREFERENCES.WELCOME_CHECKLIST_SETUP_DASHBOARDS_SKIPPED,
|
||||
SETUP_ALERTS: ORG_PREFERENCES.WELCOME_CHECKLIST_SETUP_ALERTS_SKIPPED,
|
||||
SETUP_SAVED_VIEWS: ORG_PREFERENCES.WELCOME_CHECKLIST_SETUP_SAVED_VIEW_SKIPPED,
|
||||
@@ -20,8 +19,7 @@ export const DOCS_LINKS = {
|
||||
ADD_DATA_SOURCE: 'https://signoz.io/docs/instrumentation/overview/',
|
||||
SEND_LOGS: 'https://signoz.io/docs/userguide/logs/',
|
||||
SEND_TRACES: 'https://signoz.io/docs/userguide/traces/',
|
||||
SEND_INFRA_METRICS:
|
||||
'https://signoz.io/docs/infrastructure-monitoring/overview/',
|
||||
SEND_METRICS: 'https://signoz.io/docs/metrics-management/metrics-explorer/',
|
||||
SETUP_ALERTS: 'https://signoz.io/docs/userguide/alerts-management/',
|
||||
SETUP_SAVED_VIEWS:
|
||||
'https://signoz.io/docs/product-features/saved-view/#step-2-save-your-view',
|
||||
@@ -74,16 +72,16 @@ export const defaultChecklistItemsState: ChecklistItem[] = [
|
||||
docsLink: DOCS_LINKS.SEND_TRACES,
|
||||
},
|
||||
{
|
||||
id: 'SEND_INFRA_METRICS',
|
||||
title: 'Send your infra metrics',
|
||||
id: 'SEND_METRICS',
|
||||
title: 'Send your metrics',
|
||||
description:
|
||||
'Send your infra metrics to SigNoz to get more visibility into your infrastructure.',
|
||||
'Send your metrics to SigNoz to get more visibility into how your resources interact.',
|
||||
completed: false,
|
||||
isSkipped: false,
|
||||
isSkippable: true,
|
||||
skippedPreferenceKey: checkListStepToPreferenceKeyMap.SEND_INFRA_METRICS,
|
||||
skippedPreferenceKey: checkListStepToPreferenceKeyMap.SEND_METRICS,
|
||||
toRoute: ROUTES.GET_STARTED_WITH_CLOUD,
|
||||
docsLink: DOCS_LINKS.SEND_INFRA_METRICS,
|
||||
docsLink: DOCS_LINKS.SEND_METRICS,
|
||||
},
|
||||
{
|
||||
id: 'SETUP_ALERTS',
|
||||
|
||||
@@ -48,7 +48,6 @@ function LogsExplorerList({
|
||||
isFilterApplied,
|
||||
}: LogsExplorerListProps): JSX.Element {
|
||||
const ref = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const { activeLogId } = useCopyLogLink();
|
||||
|
||||
const {
|
||||
|
||||
@@ -11,7 +11,7 @@ function GridGraphs(props: GridGraphsProps): JSX.Element {
|
||||
const { handle } = props;
|
||||
return (
|
||||
<GridComponentSliderContainer>
|
||||
<GridGraphLayout handle={handle} />
|
||||
<GridGraphLayout handle={handle} enableDrillDown />
|
||||
</GridComponentSliderContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ function WidgetGraphContainer({
|
||||
setRequestData,
|
||||
selectedWidget,
|
||||
isLoadingPanelData,
|
||||
enableDrillDown = false,
|
||||
}: WidgetGraphContainerProps): JSX.Element {
|
||||
if (queryResponse.data && selectedGraph === PANEL_TYPES.BAR) {
|
||||
const sortedSeriesData = getSortedSeriesData(
|
||||
@@ -84,6 +85,7 @@ function WidgetGraphContainer({
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
selectedGraph={selectedGraph}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ function WidgetGraph({
|
||||
queryResponse,
|
||||
setRequestData,
|
||||
selectedGraph,
|
||||
enableDrillDown = false,
|
||||
}: WidgetGraphProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const lineChartRef = useRef<ToggleGraphProps>();
|
||||
@@ -188,6 +189,7 @@ function WidgetGraph({
|
||||
onClickHandler={graphClickHandler}
|
||||
graphVisibility={graphVisibility}
|
||||
setGraphVisibility={setGraphVisibility}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -201,6 +203,11 @@ interface WidgetGraphProps {
|
||||
>;
|
||||
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
|
||||
selectedGraph: PANEL_TYPES;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
export default WidgetGraph;
|
||||
|
||||
WidgetGraph.defaultProps = {
|
||||
enableDrillDown: false,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ function WidgetGraph({
|
||||
setRequestData,
|
||||
selectedWidget,
|
||||
isLoadingPanelData,
|
||||
enableDrillDown = false,
|
||||
}: WidgetGraphContainerProps): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
@@ -49,6 +50,7 @@ function WidgetGraph({
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
selectedWidget={selectedWidget}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ function LeftContainer({
|
||||
setRequestData,
|
||||
isLoadingPanelData,
|
||||
setQueryResponse,
|
||||
enableDrillDown = false,
|
||||
}: WidgetGraphProps): JSX.Element {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
// const { selectedDashboard } = useDashboard();
|
||||
@@ -64,6 +65,7 @@ function LeftContainer({
|
||||
setRequestData={setRequestData}
|
||||
selectedWidget={selectedWidget}
|
||||
isLoadingPanelData={isLoadingPanelData}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
<QueryContainer className="query-section-left-container">
|
||||
<QuerySection selectedGraph={selectedGraph} queryResponse={queryResponse} />
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.right-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { cloneDeep, defaultTo, isEmpty, isUndefined } from 'lodash-es';
|
||||
@@ -72,7 +73,10 @@ import {
|
||||
placeWidgetBetweenRows,
|
||||
} from './utils';
|
||||
|
||||
function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
function NewWidget({
|
||||
selectedGraph,
|
||||
enableDrillDown = false,
|
||||
}: NewWidgetProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const {
|
||||
selectedDashboard,
|
||||
@@ -690,6 +694,26 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
}
|
||||
}, [selectedLogFields, selectedTracesFields, currentQuery, selectedGraph]);
|
||||
|
||||
const showSwitchToViewModeButton =
|
||||
enableDrillDown && !isNewDashboard && !!query.get('widgetId');
|
||||
|
||||
const handleSwitchToViewMode = useCallback(() => {
|
||||
if (!query.get('widgetId')) return;
|
||||
const widgetId = query.get('widgetId') || '';
|
||||
const queryParams = {
|
||||
[QueryParams.expandedWidgetId]: widgetId,
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(
|
||||
JSON.stringify(currentQuery),
|
||||
),
|
||||
};
|
||||
|
||||
const updatedSearch = createQueryParams(queryParams);
|
||||
safeNavigate({
|
||||
pathname: generatePath(ROUTES.DASHBOARD, { dashboardId }),
|
||||
search: updatedSearch,
|
||||
});
|
||||
}, [query, safeNavigate, dashboardId, currentQuery]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="edit-header">
|
||||
@@ -706,31 +730,42 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</div>
|
||||
{isSaveDisabled && (
|
||||
<Button
|
||||
type="primary"
|
||||
data-testid="new-widget-save"
|
||||
loading={updateDashboardMutation.isLoading}
|
||||
disabled={isSaveDisabled}
|
||||
onClick={onSaveDashboard}
|
||||
className="save-btn"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
{!isSaveDisabled && (
|
||||
<Button
|
||||
type="primary"
|
||||
data-testid="new-widget-save"
|
||||
loading={updateDashboardMutation.isLoading}
|
||||
disabled={isSaveDisabled}
|
||||
onClick={onSaveDashboard}
|
||||
icon={<Check size={14} />}
|
||||
className="save-btn"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
<div className="right-header">
|
||||
{showSwitchToViewModeButton && (
|
||||
<Button
|
||||
data-testid="switch-to-view-mode"
|
||||
disabled={isSaveDisabled || !currentQuery}
|
||||
onClick={handleSwitchToViewMode}
|
||||
>
|
||||
Switch to View Mode
|
||||
</Button>
|
||||
)}
|
||||
{isSaveDisabled && (
|
||||
<Button
|
||||
type="primary"
|
||||
data-testid="new-widget-save"
|
||||
loading={updateDashboardMutation.isLoading}
|
||||
disabled={isSaveDisabled}
|
||||
onClick={onSaveDashboard}
|
||||
className="save-btn"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
{!isSaveDisabled && (
|
||||
<Button
|
||||
type="primary"
|
||||
data-testid="new-widget-save"
|
||||
loading={updateDashboardMutation.isLoading}
|
||||
disabled={isSaveDisabled}
|
||||
onClick={onSaveDashboard}
|
||||
icon={<Check size={14} />}
|
||||
className="save-btn"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PanelContainer>
|
||||
@@ -749,6 +784,7 @@ function NewWidget({ selectedGraph }: NewWidgetProps): JSX.Element {
|
||||
setRequestData={setRequestData}
|
||||
isLoadingPanelData={isLoadingPanelData}
|
||||
setQueryResponse={setQueryResponse}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
)}
|
||||
</OverlayScrollbar>
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface NewWidgetProps {
|
||||
selectedGraph: PANEL_TYPES;
|
||||
yAxisUnit: Widgets['yAxisUnit'];
|
||||
fillSpans: Widgets['fillSpans'];
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
export interface WidgetGraphProps {
|
||||
@@ -32,6 +33,7 @@ export interface WidgetGraphProps {
|
||||
UseQueryResult<SuccessResponse<MetricRangePayloadProps, unknown>, Error>
|
||||
>
|
||||
>;
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
export type WidgetGraphContainerProps = {
|
||||
@@ -43,4 +45,5 @@ export type WidgetGraphContainerProps = {
|
||||
selectedGraph: PANEL_TYPES;
|
||||
selectedWidget: Widgets;
|
||||
isLoadingPanelData: boolean;
|
||||
enableDrillDown?: boolean;
|
||||
};
|
||||
|
||||
@@ -2,12 +2,15 @@ import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import Uplot from 'components/Uplot';
|
||||
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { getUplotHistogramChartOptions } from 'lib/uPlotLib/getUplotHistogramChartOptions';
|
||||
import _noop from 'lodash-es/noop';
|
||||
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { buildHistogramData } from './histogram';
|
||||
import { PanelWrapperProps } from './panelWrapper.types';
|
||||
@@ -20,11 +23,58 @@ function HistogramPanelWrapper({
|
||||
isFullViewMode,
|
||||
onToggleModelHandler,
|
||||
onClickHandler,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
clickedData,
|
||||
onClose,
|
||||
onClick,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
} = useCoordinates();
|
||||
const { menuItemsConfig } = useGraphContextMenu({
|
||||
widgetId: widget.id || '',
|
||||
query: widget.query,
|
||||
graphData: clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
});
|
||||
|
||||
const clickHandlerWithContextMenu = useCallback(
|
||||
(...args: any[]) => {
|
||||
const [
|
||||
,
|
||||
,
|
||||
,
|
||||
,
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
,
|
||||
focusedSeries,
|
||||
] = args;
|
||||
const data = getUplotClickData({
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
focusedSeries,
|
||||
});
|
||||
if (data && data?.record?.queryName) {
|
||||
onClick(data.coord, { ...data.record, label: data.label });
|
||||
}
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
const histogramData = buildHistogramData(
|
||||
queryResponse.data?.payload.data.result,
|
||||
@@ -73,7 +123,9 @@ function HistogramPanelWrapper({
|
||||
setGraphsVisibilityStates: setGraphVisibility,
|
||||
graphsVisibilityStates: graphVisibility,
|
||||
mergeAllQueries: widget.mergeAllActiveQueries,
|
||||
onClickHandler: onClickHandler || _noop,
|
||||
onClickHandler: enableDrillDown
|
||||
? clickHandlerWithContextMenu
|
||||
: onClickHandler ?? _noop,
|
||||
}),
|
||||
[
|
||||
containerDimensions,
|
||||
@@ -85,6 +137,8 @@ function HistogramPanelWrapper({
|
||||
widget.id,
|
||||
widget.mergeAllActiveQueries,
|
||||
widget.panelTypes,
|
||||
clickHandlerWithContextMenu,
|
||||
enableDrillDown,
|
||||
onClickHandler,
|
||||
],
|
||||
);
|
||||
@@ -92,6 +146,13 @@ function HistogramPanelWrapper({
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot options={histogramOptions} data={histogramData} ref={lineChartRef} />
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
title={menuItemsConfig.header as string}
|
||||
items={menuItemsConfig.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{isFullViewMode && setGraphVisibility && !widget.mergeAllActiveQueries && (
|
||||
<GraphManager
|
||||
data={histogramData}
|
||||
|
||||
@@ -21,6 +21,7 @@ function PanelWrapper({
|
||||
onOpenTraceBtnClick,
|
||||
customSeries,
|
||||
customOnRowClick,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const Component = PanelTypeVsPanelWrapper[
|
||||
selectedGraph || widget.panelTypes
|
||||
@@ -49,6 +50,7 @@ function PanelWrapper({
|
||||
onOpenTraceBtnClick={onOpenTraceBtnClick}
|
||||
customOnRowClick={customOnRowClick}
|
||||
customSeries={customSeries}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ import { Pie } from '@visx/shape';
|
||||
import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { getPieChartClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { isNaN } from 'lodash-es';
|
||||
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { PanelWrapperProps, TooltipData } from './panelWrapper.types';
|
||||
@@ -19,6 +22,7 @@ import { lightenColor, tooltipStyles } from './utils';
|
||||
function PiePanelWrapper({
|
||||
queryResponse,
|
||||
widget,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const [active, setActive] = useState<{
|
||||
label: string;
|
||||
@@ -48,6 +52,7 @@ function PiePanelWrapper({
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
record: any;
|
||||
}[] = [].concat(
|
||||
...(panelData
|
||||
.map((d) => {
|
||||
@@ -55,6 +60,7 @@ function PiePanelWrapper({
|
||||
return {
|
||||
label,
|
||||
value: d?.values?.[0]?.[1],
|
||||
record: d,
|
||||
color:
|
||||
widget?.customLegendColors?.[label] ||
|
||||
generateColor(
|
||||
@@ -142,6 +148,26 @@ function PiePanelWrapper({
|
||||
return active.color === color ? color : lightenedColor;
|
||||
};
|
||||
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
clickedData,
|
||||
onClose,
|
||||
onClick,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
} = useCoordinates();
|
||||
|
||||
const { menuItemsConfig } = useGraphContextMenu({
|
||||
widgetId: widget.id || '',
|
||||
query: widget.query,
|
||||
graphData: clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="piechart-wrapper">
|
||||
{!pieChartData.length && <div className="piechart-no-data">No data</div>}
|
||||
@@ -165,7 +191,7 @@ function PiePanelWrapper({
|
||||
height={size}
|
||||
>
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, sonarjs/cognitive-complexity
|
||||
(pie) =>
|
||||
pie.arcs.map((arc) => {
|
||||
const { label } = arc.data;
|
||||
@@ -226,6 +252,17 @@ function PiePanelWrapper({
|
||||
hideTooltip();
|
||||
setActive(null);
|
||||
}}
|
||||
onClick={(e): void => {
|
||||
if (enableDrillDown) {
|
||||
const data = getPieChartClickData(arc);
|
||||
if (data && data?.queryName) {
|
||||
onClick(
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
{ ...data, label: data.label },
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<path d={arcPath || ''} fill={getFillColor(arcFill)} />
|
||||
|
||||
@@ -284,6 +321,13 @@ function PiePanelWrapper({
|
||||
})
|
||||
}
|
||||
</Pie>
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
title={menuItemsConfig.header as string}
|
||||
items={menuItemsConfig.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
{/* Add total value in the center */}
|
||||
<text
|
||||
|
||||
@@ -12,6 +12,7 @@ function TablePanelWrapper({
|
||||
openTracesButton,
|
||||
onOpenTraceBtnClick,
|
||||
customOnRowClick,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const panelData =
|
||||
(queryResponse.data?.payload?.data?.result?.[0] as any)?.table || [];
|
||||
@@ -31,6 +32,7 @@ function TablePanelWrapper({
|
||||
widgetId={widget.id}
|
||||
renderColumnCell={widget.renderColumnCell}
|
||||
customColTitles={widget.customColTitles}
|
||||
enableDrillDown={enableDrillDown}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...GRID_TABLE_CONFIG}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,8 @@ import Uplot from 'components/Uplot';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -13,14 +15,16 @@ import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { cloneDeep, isEqual, isUndefined } from 'lodash-es';
|
||||
import _noop from 'lodash-es/noop';
|
||||
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import { PanelWrapperProps } from './panelWrapper.types';
|
||||
import { getTimeRangeFromUplotAxis } from './utils';
|
||||
|
||||
function UplotPanelWrapper({
|
||||
queryResponse,
|
||||
@@ -34,6 +38,7 @@ function UplotPanelWrapper({
|
||||
selectedGraph,
|
||||
customTooltipElement,
|
||||
customSeries,
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
@@ -65,6 +70,25 @@ function UplotPanelWrapper({
|
||||
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
clickedData,
|
||||
onClose,
|
||||
onClick,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
} = useCoordinates();
|
||||
const { menuItemsConfig } = useGraphContextMenu({
|
||||
widgetId: widget.id || '',
|
||||
query: widget.query,
|
||||
graphData: clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const {
|
||||
graphVisibilityStates: localStoredVisibilityState,
|
||||
@@ -114,6 +138,42 @@ function UplotPanelWrapper({
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const clickHandlerWithContextMenu = useCallback(
|
||||
(...args: any[]) => {
|
||||
const [
|
||||
xValue,
|
||||
,
|
||||
,
|
||||
,
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
axesData,
|
||||
focusedSeries,
|
||||
] = args;
|
||||
const data = getUplotClickData({
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
focusedSeries,
|
||||
});
|
||||
console.log('onClickData: ', data);
|
||||
// Compute time range if needed and if axes data is available
|
||||
let timeRange;
|
||||
if (axesData) {
|
||||
const { xAxis } = axesData;
|
||||
timeRange = getTimeRangeFromUplotAxis(xAxis, xValue);
|
||||
}
|
||||
|
||||
if (data && data?.record?.queryName) {
|
||||
onClick(data.coord, { ...data.record, label: data.label, timeRange });
|
||||
}
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
getUPlotChartOptions({
|
||||
@@ -123,7 +183,9 @@ function UplotPanelWrapper({
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
yAxisUnit: widget?.yAxisUnit,
|
||||
onClickHandler: onClickHandler || _noop,
|
||||
onClickHandler: enableDrillDown
|
||||
? clickHandlerWithContextMenu
|
||||
: onClickHandler ?? _noop,
|
||||
thresholds: widget.thresholds,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
@@ -152,7 +214,7 @@ function UplotPanelWrapper({
|
||||
containerDimensions,
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
onClickHandler,
|
||||
clickHandlerWithContextMenu,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
graphVisibility,
|
||||
@@ -163,6 +225,8 @@ function UplotPanelWrapper({
|
||||
customTooltipElement,
|
||||
timezone.value,
|
||||
customSeries,
|
||||
enableDrillDown,
|
||||
onClickHandler,
|
||||
widget,
|
||||
],
|
||||
);
|
||||
@@ -170,6 +234,13 @@ function UplotPanelWrapper({
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot options={options} data={chartData} ref={lineChartRef} />
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
title={menuItemsConfig.header as string}
|
||||
items={menuItemsConfig.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{widget?.stackedBarChart && isFullViewMode && (
|
||||
<Alert
|
||||
message="Selecting multiple legends is currently not supported in case of stacked bar charts"
|
||||
|
||||
@@ -266,22 +266,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
demo-app
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
demo-app
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
4.35 s
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
4.35 s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -292,22 +304,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
customer
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
customer
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
431 ms
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
431 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -318,22 +342,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
mysql
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
mysql
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
431 ms
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
431 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -344,22 +380,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
frontend
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
frontend
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
287 ms
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
287 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -370,22 +418,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
driver
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
driver
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
230 ms
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
230 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -396,22 +456,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
route
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
route
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
66.4 ms
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
66.4 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -422,22 +494,34 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
redis
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
redis
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="ant-table-cell"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
31.3 ms
|
||||
<div
|
||||
class=""
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
31.3 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -30,6 +30,7 @@ export type PanelWrapperProps = {
|
||||
onOpenTraceBtnClick?: (record: RowData) => void;
|
||||
customOnRowClick?: (record: RowData) => void;
|
||||
customSeries?: (data: QueryData[]) => uPlot.Series[];
|
||||
enableDrillDown?: boolean;
|
||||
};
|
||||
|
||||
export type TooltipData = {
|
||||
|
||||
@@ -71,3 +71,21 @@ export const lightenColor = (color: string, opacity: number): string => {
|
||||
// Create a new RGBA color string with the specified opacity
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
};
|
||||
|
||||
export const getTimeRangeFromUplotAxis = (
|
||||
axis: any,
|
||||
xValue: number,
|
||||
): { startTime: number; endTime: number } => {
|
||||
// Use splits if available, otherwise fallback to 10 minutes (600000 milliseconds)
|
||||
let gap =
|
||||
(axis as any)._splits && (axis as any)._splits.length > 1
|
||||
? (axis as any)._splits[1] - (axis as any)._splits[0]
|
||||
: 600000; // 10 minutes in milliseconds
|
||||
|
||||
gap = Math.max(gap, 600000); // Minimum gap of 10 minutes in milliseconds
|
||||
|
||||
const startTime = xValue - gap;
|
||||
const endTime = xValue + gap;
|
||||
|
||||
return { startTime, endTime };
|
||||
};
|
||||
|
||||
113
frontend/src/container/QueryTable/Drilldown/BreakoutOptions.tsx
Normal file
113
frontend/src/container/QueryTable/Drilldown/BreakoutOptions.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import './Breakoutoptions.styles.scss';
|
||||
|
||||
import { Input, Skeleton } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { useGetAggregateKeys } from 'hooks/infraMonitoring/useGetAggregateKeys';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { ContextMenu } from 'periscope/components/ContextMenu';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { BreakoutOptionsProps } from './contextConfig';
|
||||
|
||||
function OptionsSkeleton(): JSX.Element {
|
||||
return (
|
||||
<div className="breakout-options-skeleton">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BreakoutOptions({
|
||||
queryData,
|
||||
onColumnClick,
|
||||
}: BreakoutOptionsProps): JSX.Element {
|
||||
const { groupBy = [] } = queryData;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const debouncedSearchText = useDebounce(searchText, 400);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const value = e.target.value.trim().toLowerCase();
|
||||
setSearchText(value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// TODO: change the api call to get the keys
|
||||
const { isFetching, data } = useGetAggregateKeys(
|
||||
{
|
||||
aggregateAttribute: queryData.aggregateAttribute?.key || '',
|
||||
dataSource: queryData.dataSource,
|
||||
aggregateOperator: queryData?.aggregateOperator || '',
|
||||
searchText: debouncedSearchText,
|
||||
},
|
||||
{
|
||||
queryKey: [
|
||||
queryData?.aggregateAttribute?.key,
|
||||
queryData.dataSource,
|
||||
queryData.aggregateOperator,
|
||||
debouncedSearchText,
|
||||
],
|
||||
enabled: !!queryData,
|
||||
},
|
||||
);
|
||||
|
||||
const breakoutOptions = useMemo(() => {
|
||||
const groupByKeys = groupBy.map((item: BaseAutocompleteData) => item.key);
|
||||
return data?.payload?.attributeKeys?.filter(
|
||||
(item: BaseAutocompleteData) => !groupByKeys.includes(item.key),
|
||||
);
|
||||
}, [data, groupBy]);
|
||||
|
||||
console.log('>> queryData', queryData);
|
||||
console.log('>> groupBy', groupBy);
|
||||
console.log('>> breakoutOptions', breakoutOptions);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="search" style={{ padding: '8px 0' }}>
|
||||
<Input
|
||||
type="text"
|
||||
value={searchText}
|
||||
placeholder="Search breakout options..."
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</section>
|
||||
<div style={{ height: '200px' }}>
|
||||
<OverlayScrollbar
|
||||
options={{
|
||||
overflow: {
|
||||
x: 'hidden',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line react/jsx-no-useless-fragment */}
|
||||
<>
|
||||
{isFetching ? (
|
||||
<OptionsSkeleton />
|
||||
) : (
|
||||
breakoutOptions?.map((item: BaseAutocompleteData) => (
|
||||
<ContextMenu.Item
|
||||
key={item.key}
|
||||
onClick={(): void => onColumnClick(item)}
|
||||
>
|
||||
{item.key}
|
||||
</ContextMenu.Item>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BreakoutOptions;
|
||||
@@ -0,0 +1,7 @@
|
||||
.breakout-options-skeleton {
|
||||
.ant-skeleton-input {
|
||||
width: 100% !important;
|
||||
height: 20px !important;
|
||||
margin: 8px 5px;
|
||||
}
|
||||
}
|
||||
150
frontend/src/container/QueryTable/Drilldown/contextConfig.tsx
Normal file
150
frontend/src/container/QueryTable/Drilldown/contextConfig.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { QUERY_BUILDER_OPERATORS_BY_TYPES } from 'constants/queryBuilder';
|
||||
import ContextMenu, { ClickedData } from 'periscope/components/ContextMenu';
|
||||
import { ReactNode } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import BreakoutOptions from './BreakoutOptions';
|
||||
import {
|
||||
getAggregateColumnHeader,
|
||||
getBaseMeta,
|
||||
getQueryData,
|
||||
} from './drilldownUtils';
|
||||
import { AGGREGATE_OPTIONS, SUPPORTED_OPERATORS } from './menuOptions';
|
||||
import { getBreakoutQuery } from './tableDrilldownUtils';
|
||||
import { AggregateData } from './useAggregateDrilldown';
|
||||
|
||||
export type ContextMenuItem = ReactNode;
|
||||
|
||||
export enum ConfigType {
|
||||
GROUP = 'group',
|
||||
AGGREGATE = 'aggregate',
|
||||
}
|
||||
|
||||
export interface ContextMenuConfigParams {
|
||||
configType: ConfigType;
|
||||
query: Query;
|
||||
clickedData: ClickedData;
|
||||
panelType?: string;
|
||||
onColumnClick: (key: string, query?: Query) => void;
|
||||
subMenu?: string;
|
||||
}
|
||||
|
||||
export interface GroupContextMenuConfig {
|
||||
header?: string;
|
||||
items?: ContextMenuItem;
|
||||
}
|
||||
|
||||
export interface AggregateContextMenuConfig {
|
||||
header?: string | ReactNode;
|
||||
items?: ContextMenuItem;
|
||||
}
|
||||
|
||||
export interface BreakoutOptionsProps {
|
||||
queryData: IBuilderQuery;
|
||||
onColumnClick: (groupBy: BaseAutocompleteData) => void;
|
||||
}
|
||||
|
||||
export function getGroupContextMenuConfig({
|
||||
query,
|
||||
clickedData,
|
||||
panelType,
|
||||
onColumnClick,
|
||||
}: Omit<ContextMenuConfigParams, 'configType'>): GroupContextMenuConfig {
|
||||
const filterKey = clickedData?.column?.dataIndex;
|
||||
const header = `Filter by ${filterKey}`;
|
||||
|
||||
const filterDataType =
|
||||
getBaseMeta(query, filterKey as string)?.dataType || 'string';
|
||||
|
||||
const operators =
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES[
|
||||
filterDataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
|
||||
];
|
||||
|
||||
const filterOperators = operators.filter(
|
||||
(operator) => SUPPORTED_OPERATORS[operator],
|
||||
);
|
||||
|
||||
if (panelType === 'table' && clickedData?.column) {
|
||||
return {
|
||||
header,
|
||||
items: filterOperators.map((operator) => (
|
||||
<ContextMenu.Item
|
||||
key={operator}
|
||||
icon={SUPPORTED_OPERATORS[operator].icon}
|
||||
onClick={(): void => onColumnClick(SUPPORTED_OPERATORS[operator].value)}
|
||||
>
|
||||
{SUPPORTED_OPERATORS[operator].label}
|
||||
</ContextMenu.Item>
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function getAggregateContextMenuConfig({
|
||||
subMenu,
|
||||
query,
|
||||
onColumnClick,
|
||||
aggregateData,
|
||||
}: {
|
||||
subMenu?: string;
|
||||
query: Query;
|
||||
onColumnClick: (key: string, query?: Query) => void;
|
||||
aggregateData: AggregateData | null;
|
||||
}): AggregateContextMenuConfig {
|
||||
console.log('getAggregateContextMenuConfig', { query, aggregateData });
|
||||
|
||||
if (subMenu === 'breakout') {
|
||||
const queryData = getQueryData(query, aggregateData?.queryName || '');
|
||||
return {
|
||||
header: 'Breakout by',
|
||||
items: (
|
||||
<BreakoutOptions
|
||||
queryData={queryData}
|
||||
onColumnClick={(groupBy: BaseAutocompleteData): void => {
|
||||
// Use aggregateData.filters
|
||||
const filtersToAdd = aggregateData?.filters || [];
|
||||
const breakoutQuery = getBreakoutQuery(
|
||||
query,
|
||||
aggregateData,
|
||||
groupBy,
|
||||
filtersToAdd,
|
||||
);
|
||||
onColumnClick('breakout', breakoutQuery);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Use aggregateData.queryName
|
||||
const queryName = aggregateData?.queryName;
|
||||
const { dataSource, aggregations } = getAggregateColumnHeader(
|
||||
query,
|
||||
queryName as string,
|
||||
);
|
||||
|
||||
console.log('dataSource', dataSource);
|
||||
console.log('aggregations', aggregations);
|
||||
|
||||
return {
|
||||
header: (
|
||||
<div>
|
||||
<div style={{ textTransform: 'capitalize' }}>{dataSource}</div>
|
||||
<div>{aggregations}</div>
|
||||
</div>
|
||||
),
|
||||
items: AGGREGATE_OPTIONS.map(({ key, label, icon }) => (
|
||||
<ContextMenu.Item
|
||||
key={key}
|
||||
icon={icon}
|
||||
onClick={(): void => onColumnClick(key)}
|
||||
>
|
||||
{label}
|
||||
</ContextMenu.Item>
|
||||
)),
|
||||
};
|
||||
}
|
||||
335
frontend/src/container/QueryTable/Drilldown/drilldownUtils.tsx
Normal file
335
frontend/src/container/QueryTable/Drilldown/drilldownUtils.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import { PieArcDatum } from '@visx/shape/lib/shapes/Pie';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
OPERATORS,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export function getBaseMeta(
|
||||
query: Query,
|
||||
filterKey: string,
|
||||
): BaseAutocompleteData | null {
|
||||
const steps = query.builder.queryData;
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const { groupBy } = steps[i];
|
||||
for (let j = 0; j < groupBy.length; j++) {
|
||||
if (groupBy[j].key === filterKey) {
|
||||
return groupBy[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const getRoute = (key: string): string => {
|
||||
switch (key) {
|
||||
case 'view_logs':
|
||||
return ROUTES.LOGS_EXPLORER;
|
||||
case 'view_metrics':
|
||||
return ROUTES.METRICS_EXPLORER;
|
||||
case 'view_traces':
|
||||
return ROUTES.TRACES_EXPLORER;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const isNumberDataType = (dataType: DataTypes | undefined): boolean => {
|
||||
if (!dataType) return false;
|
||||
return dataType === DataTypes.Int64 || dataType === DataTypes.Float64;
|
||||
};
|
||||
|
||||
export interface FilterData {
|
||||
filterKey: string;
|
||||
filterValue: string | number;
|
||||
operator: string;
|
||||
}
|
||||
|
||||
// Helper function to avoid code duplication
|
||||
function addFiltersToQuerySteps(
|
||||
query: Query,
|
||||
filters: FilterData[],
|
||||
queryName?: string,
|
||||
): Query {
|
||||
// 1) clone so we don't mutate the original
|
||||
const q = cloneDeep(query);
|
||||
|
||||
// 2) map over builder.queryData to return a new modified version
|
||||
q.builder.queryData = q.builder.queryData.map((step) => {
|
||||
// Only modify the step that matches the queryName (if provided)
|
||||
if (queryName && step.queryName !== queryName) {
|
||||
return step;
|
||||
}
|
||||
|
||||
// 3) build the new filters array
|
||||
const newFilters = {
|
||||
...step.filters,
|
||||
op: step?.filters?.op || 'AND',
|
||||
items: [...(step?.filters?.items || [])],
|
||||
};
|
||||
|
||||
// Add each filter to the items array
|
||||
filters.forEach(({ filterKey, filterValue, operator }) => {
|
||||
// skip if this step doesn't group by our key
|
||||
const baseMeta = step.groupBy.find((g) => g.key === filterKey);
|
||||
if (!baseMeta) return;
|
||||
|
||||
newFilters.items.push({
|
||||
id: uuid(),
|
||||
key: baseMeta,
|
||||
op: operator,
|
||||
value: filterValue,
|
||||
});
|
||||
});
|
||||
|
||||
const newFilterExpression = convertFiltersToExpression(newFilters);
|
||||
|
||||
console.log('BASE META', { filters, newFilters, ...newFilterExpression });
|
||||
|
||||
// 4) return a new step object with updated filters
|
||||
return {
|
||||
...step,
|
||||
filters: newFilters,
|
||||
filter: newFilterExpression,
|
||||
};
|
||||
});
|
||||
|
||||
return q;
|
||||
}
|
||||
|
||||
export function addFilterToQuery(query: Query, filters: FilterData[]): Query {
|
||||
return addFiltersToQuerySteps(query, filters);
|
||||
}
|
||||
|
||||
export const addFilterToSelectedQuery = (
|
||||
query: Query,
|
||||
filters: FilterData[],
|
||||
queryName: string,
|
||||
): Query => addFiltersToQuerySteps(query, filters, queryName);
|
||||
|
||||
export const getAggregateColumnHeader = (
|
||||
query: Query,
|
||||
queryName: string,
|
||||
): { dataSource: string; aggregations: string } => {
|
||||
// Find the query step with the matching queryName
|
||||
const queryStep = query.builder.queryData.find(
|
||||
(step) => step.queryName === queryName,
|
||||
);
|
||||
|
||||
if (!queryStep) {
|
||||
return { dataSource: '', aggregations: '' };
|
||||
}
|
||||
|
||||
console.log('queryStep', queryStep);
|
||||
const { dataSource, aggregations } = queryStep; // TODO: check if this is correct
|
||||
|
||||
// Extract aggregation expressions based on data source type
|
||||
let aggregationExpressions: string[] = [];
|
||||
|
||||
if (aggregations && aggregations.length > 0) {
|
||||
if (dataSource === 'metrics') {
|
||||
// For metrics, construct expression from spaceAggregation(metricName)
|
||||
aggregationExpressions = aggregations.map((agg: any) => {
|
||||
const { spaceAggregation, metricName } = agg;
|
||||
return `${spaceAggregation}(${metricName})`;
|
||||
});
|
||||
} else {
|
||||
// For traces and logs, use the expression field directly
|
||||
aggregationExpressions = aggregations.map((agg: any) => agg.expression);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
aggregations: aggregationExpressions.join(', '),
|
||||
};
|
||||
};
|
||||
|
||||
const getFiltersFromMetric = (metric: any): FilterData[] =>
|
||||
Object.keys(metric).map((key) => ({
|
||||
filterKey: key,
|
||||
filterValue: metric[key],
|
||||
operator: OPERATORS['='],
|
||||
}));
|
||||
|
||||
export const getUplotClickData = ({
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
focusedSeries,
|
||||
}: {
|
||||
metric?: { [key: string]: string };
|
||||
queryData?: { queryName: string; inFocusOrNot: boolean };
|
||||
absoluteMouseX: number;
|
||||
absoluteMouseY: number;
|
||||
focusedSeries?: {
|
||||
seriesIndex: number;
|
||||
seriesName: string;
|
||||
value: number;
|
||||
color: string;
|
||||
show: boolean;
|
||||
isFocused: boolean;
|
||||
} | null;
|
||||
}): {
|
||||
coord: { x: number; y: number };
|
||||
record: { queryName: string; filters: FilterData[] };
|
||||
label: string | React.ReactNode;
|
||||
} | null => {
|
||||
console.log('on Click', {
|
||||
metric,
|
||||
queryData,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
focusedSeries,
|
||||
});
|
||||
|
||||
if (!queryData?.queryName || !metric) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = {
|
||||
queryName: queryData.queryName,
|
||||
filters: getFiltersFromMetric(metric),
|
||||
};
|
||||
|
||||
// Generate label from focusedSeries data
|
||||
let label: string | React.ReactNode = '';
|
||||
if (focusedSeries && focusedSeries.seriesName) {
|
||||
label = (
|
||||
<span style={{ color: focusedSeries.color }}>
|
||||
{focusedSeries.seriesName}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
console.log('CLICKED DATA: ', record);
|
||||
|
||||
return {
|
||||
coord: {
|
||||
x: absoluteMouseX,
|
||||
y: absoluteMouseY,
|
||||
},
|
||||
record,
|
||||
label,
|
||||
};
|
||||
};
|
||||
|
||||
export const getPieChartClickData = (
|
||||
arc: PieArcDatum<{
|
||||
label: string;
|
||||
value: string;
|
||||
color: string;
|
||||
record: any;
|
||||
}>,
|
||||
): {
|
||||
queryName: string;
|
||||
filters: FilterData[];
|
||||
label: string | React.ReactNode;
|
||||
} | null => {
|
||||
console.log('arc ->', arc.data);
|
||||
const { metric, queryName } = arc.data.record;
|
||||
if (!queryName || !metric) return null;
|
||||
|
||||
const label = <span style={{ color: arc.data.color }}>{arc.data.label}</span>;
|
||||
return {
|
||||
queryName,
|
||||
filters: getFiltersFromMetric(metric), // TODO: add where clause query as well.
|
||||
label,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the query data that matches the aggregate data's queryName
|
||||
*/
|
||||
export const getQueryData = (
|
||||
query: Query,
|
||||
queryName: string,
|
||||
): IBuilderQuery => {
|
||||
const queryData = query?.builder?.queryData?.filter(
|
||||
(item: IBuilderQuery) => item.queryName === queryName,
|
||||
);
|
||||
return queryData[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a query name is valid for drilldown operations
|
||||
* Returns false if queryName is empty or starts with 'F'
|
||||
* Note: Checking if queryName starts with 'F' is a hack to know if it's a Formulae based query
|
||||
*/
|
||||
export const isValidQueryName = (queryName: string): boolean => {
|
||||
if (!queryName || queryName.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
return !queryName.startsWith('F');
|
||||
};
|
||||
|
||||
const VIEW_QUERY_MAP: Record<string, IBuilderQuery> = {
|
||||
view_logs: initialQueryBuilderFormValuesMap.logs,
|
||||
view_metrics: initialQueryBuilderFormValuesMap.metrics,
|
||||
view_traces: initialQueryBuilderFormValuesMap.traces,
|
||||
};
|
||||
|
||||
export const getViewQuery = (
|
||||
query: Query,
|
||||
filtersToAdd: FilterData[],
|
||||
key: string,
|
||||
queryName: string,
|
||||
): Query | null => {
|
||||
const newQuery = cloneDeep(query);
|
||||
|
||||
const queryBuilderData = VIEW_QUERY_MAP[key];
|
||||
|
||||
if (!queryBuilderData) return null;
|
||||
|
||||
let existingFilters: TagFilterItem[] = [];
|
||||
if (queryName) {
|
||||
const queryData = getQueryData(query, queryName);
|
||||
existingFilters = queryData?.filters?.items || [];
|
||||
}
|
||||
|
||||
console.log('existingFilters', { existingFilters, query });
|
||||
|
||||
newQuery.builder.queryData = [queryBuilderData];
|
||||
|
||||
const filters = filtersToAdd.reduce((acc: any[], filter) => {
|
||||
// use existing query to get baseMeta
|
||||
const baseMeta = getBaseMeta(query, filter.filterKey);
|
||||
if (!baseMeta) return acc;
|
||||
|
||||
acc.push({
|
||||
id: uuid(),
|
||||
key: baseMeta,
|
||||
op: filter.operator,
|
||||
value: filter.filterValue,
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const allFilters = [...existingFilters, ...filters];
|
||||
|
||||
newQuery.builder.queryData[0].filters = {
|
||||
items: allFilters,
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
newQuery.builder.queryData[0].filter = convertFiltersToExpression({
|
||||
items: allFilters,
|
||||
op: 'AND',
|
||||
});
|
||||
|
||||
return newQuery;
|
||||
};
|
||||
99
frontend/src/container/QueryTable/Drilldown/menuOptions.tsx
Normal file
99
frontend/src/container/QueryTable/Drilldown/menuOptions.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { ChartBar, DraftingCompass, ScrollText } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Supported operators for filtering with their display properties
|
||||
*/
|
||||
export const SUPPORTED_OPERATORS = {
|
||||
[OPERATORS['=']]: {
|
||||
label: 'Is this',
|
||||
icon: '=',
|
||||
value: '=',
|
||||
},
|
||||
[OPERATORS['!=']]: {
|
||||
label: 'Is not this',
|
||||
icon: '!=',
|
||||
value: '!=',
|
||||
},
|
||||
[OPERATORS['>=']]: {
|
||||
label: 'Is greater than or equal to',
|
||||
icon: '>=',
|
||||
value: '>=',
|
||||
},
|
||||
[OPERATORS['<=']]: {
|
||||
label: 'Is less than or equal to',
|
||||
icon: '<=',
|
||||
value: '<=',
|
||||
},
|
||||
[OPERATORS['<']]: {
|
||||
label: 'Is less than',
|
||||
icon: '<',
|
||||
value: '<',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Aggregate menu options for different views
|
||||
*/
|
||||
// TO REMOVE
|
||||
export const AGGREGATE_OPTIONS = [
|
||||
{
|
||||
key: 'view_logs',
|
||||
icon: <ScrollText size={16} />,
|
||||
label: 'View in Logs',
|
||||
},
|
||||
// {
|
||||
// key: 'view_metrics',
|
||||
// icon: <BarChart2 size={16} />,
|
||||
// label: 'View in Metrics',
|
||||
// },
|
||||
{
|
||||
key: 'view_traces',
|
||||
icon: <DraftingCompass size={16} />,
|
||||
label: 'View in Traces',
|
||||
},
|
||||
{
|
||||
key: 'breakout',
|
||||
icon: <ChartBar size={16} />,
|
||||
label: 'Breakout by ..',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Aggregate menu options for different views
|
||||
*/
|
||||
export const getBaseContextConfig = ({
|
||||
handleBaseDrilldown,
|
||||
}: {
|
||||
handleBaseDrilldown: (key: string) => void;
|
||||
}): {
|
||||
key: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}[] => [
|
||||
{
|
||||
key: 'view_logs',
|
||||
icon: <ScrollText size={16} />,
|
||||
label: 'View in Logs',
|
||||
onClick: (): void => handleBaseDrilldown('view_logs'),
|
||||
},
|
||||
// {
|
||||
// key: 'view_metrics',
|
||||
// icon: <BarChart2 size={16} />,
|
||||
// label: 'View in Metrics',
|
||||
// onClick: () => handleBaseDrilldown('view_metrics'),
|
||||
// },
|
||||
{
|
||||
key: 'view_traces',
|
||||
icon: <DraftingCompass size={16} />,
|
||||
label: 'View in Traces',
|
||||
onClick: (): void => handleBaseDrilldown('view_traces'),
|
||||
},
|
||||
{
|
||||
key: 'breakout',
|
||||
icon: <ChartBar size={16} />,
|
||||
label: 'Breakout by ..',
|
||||
onClick: (): void => handleBaseDrilldown('breakout'),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,84 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { addFilterToSelectedQuery, FilterData } from './drilldownUtils';
|
||||
import { AggregateData } from './useAggregateDrilldown';
|
||||
|
||||
export const isEmptyFilterValue = (value: any): boolean =>
|
||||
value === '' || value === null || value === undefined || value === 'n/a';
|
||||
|
||||
/**
|
||||
* Creates filters to add to the query from table columns for view mode navigation
|
||||
*/
|
||||
export const getFiltersToAddToView = (clickedData: any): FilterData[] => {
|
||||
if (!clickedData) {
|
||||
console.warn('clickedData is null in getFiltersToAddToView');
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
clickedData?.tableColumns
|
||||
?.filter((col: any) => !col.isValueColumn)
|
||||
.reduce((acc: FilterData[], col: any) => {
|
||||
// only add table col which have isValueColumn false. and the filter value suffices the isEmptyFilterValue condition.
|
||||
const { dataIndex } = col;
|
||||
if (!dataIndex || typeof dataIndex !== 'string') return acc;
|
||||
if (
|
||||
clickedData?.column?.isValueColumn &&
|
||||
isEmptyFilterValue(clickedData?.record?.[dataIndex])
|
||||
)
|
||||
return acc;
|
||||
return [
|
||||
...acc,
|
||||
{
|
||||
filterKey: dataIndex,
|
||||
filterValue: clickedData?.record?.[dataIndex] || '',
|
||||
operator: OPERATORS['='],
|
||||
},
|
||||
];
|
||||
}, []) || []
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a breakout query by adding filters and updating the groupBy
|
||||
*/
|
||||
export const getBreakoutQuery = (
|
||||
query: Query,
|
||||
aggregateData: AggregateData | null,
|
||||
groupBy: BaseAutocompleteData,
|
||||
filtersToAdd: FilterData[],
|
||||
): Query => {
|
||||
if (!aggregateData) {
|
||||
console.warn('aggregateData is null in getBreakoutQuery');
|
||||
return query;
|
||||
}
|
||||
|
||||
console.log('>> groupBy', groupBy);
|
||||
console.log('>> aggregateData', aggregateData);
|
||||
console.log('>> query', query);
|
||||
|
||||
const queryWithFilters = addFilterToSelectedQuery(
|
||||
query,
|
||||
filtersToAdd,
|
||||
aggregateData.queryName,
|
||||
);
|
||||
const newQuery = cloneDeep(queryWithFilters);
|
||||
|
||||
newQuery.builder.queryData = newQuery.builder.queryData.map(
|
||||
(item: IBuilderQuery) => {
|
||||
if (item.queryName === aggregateData.queryName) {
|
||||
return {
|
||||
...item,
|
||||
groupBy: [groupBy],
|
||||
};
|
||||
}
|
||||
return item;
|
||||
},
|
||||
);
|
||||
|
||||
console.log('>> breakoutQuery', newQuery);
|
||||
return newQuery;
|
||||
};
|
||||
34
frontend/src/container/QueryTable/Drilldown/types.ts
Normal file
34
frontend/src/container/QueryTable/Drilldown/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export type ContextMenuItem = ReactNode;
|
||||
|
||||
export enum ConfigType {
|
||||
GROUP = 'group',
|
||||
AGGREGATE = 'aggregate',
|
||||
}
|
||||
|
||||
export interface ContextMenuConfigParams {
|
||||
configType: ConfigType;
|
||||
query: any; // Query type
|
||||
clickedData: any;
|
||||
panelType?: string;
|
||||
onColumnClick: (operator: string | any) => void; // Query type
|
||||
subMenu?: string;
|
||||
}
|
||||
|
||||
export interface GroupContextMenuConfig {
|
||||
header?: string;
|
||||
items?: ContextMenuItem;
|
||||
}
|
||||
|
||||
export interface AggregateContextMenuConfig {
|
||||
header?: string;
|
||||
items?: ContextMenuItem;
|
||||
}
|
||||
|
||||
export interface BreakoutOptionsProps {
|
||||
queryData: IBuilderQuery;
|
||||
onColumnClick: (groupBy: BaseAutocompleteData) => void;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { ContextMenuItem } from './contextConfig';
|
||||
import { FilterData } from './drilldownUtils';
|
||||
import useBaseAggregateOptions from './useBaseAggregateOptions';
|
||||
import useBreakout from './useBreakout';
|
||||
|
||||
// Type for aggregate data
|
||||
export interface AggregateData {
|
||||
queryName: string;
|
||||
filters: FilterData[];
|
||||
timeRange?: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
label?: string | React.ReactNode;
|
||||
}
|
||||
|
||||
const useAggregateDrilldown = ({
|
||||
query,
|
||||
widgetId,
|
||||
onClose,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
aggregateData,
|
||||
}: {
|
||||
query: Query;
|
||||
widgetId: string;
|
||||
onClose: () => void;
|
||||
subMenu: string;
|
||||
setSubMenu: (subMenu: string) => void;
|
||||
aggregateData: AggregateData | null;
|
||||
}): {
|
||||
aggregateDrilldownConfig: {
|
||||
header?: string | React.ReactNode;
|
||||
items?: ContextMenuItem;
|
||||
};
|
||||
} => {
|
||||
// const { redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
// const redirectToViewMode = useCallback(
|
||||
// (query: Query): void => {
|
||||
// redirectWithQueryBuilderData(
|
||||
// query,
|
||||
// { [QueryParams.expandedWidgetId]: widgetId }, // add only if view mode
|
||||
// undefined,
|
||||
// true,
|
||||
// );
|
||||
// },
|
||||
// [widgetId, redirectWithQueryBuilderData],
|
||||
// );
|
||||
// const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
// const handleAggregateDrilldown = useCallback(
|
||||
// (key: string, drilldownQuery?: Query): void => {
|
||||
// console.log('Aggregate drilldown:', { widgetId, query, key, aggregateData });
|
||||
|
||||
// if (key === 'breakout') {
|
||||
// if (!drilldownQuery) {
|
||||
// setSubMenu(key);
|
||||
// } else {
|
||||
// redirectToViewMode(drilldownQuery);
|
||||
// onClose();
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const route = getRoute(key);
|
||||
// const timeRange = aggregateData?.timeRange;
|
||||
// const filtersToAdd = aggregateData?.filters || [];
|
||||
// const viewQuery = getViewQuery(
|
||||
// query,
|
||||
// filtersToAdd,
|
||||
// key,
|
||||
// aggregateData?.queryName || '',
|
||||
// );
|
||||
|
||||
// let queryParams = {
|
||||
// [QueryParams.compositeQuery]: JSON.stringify(viewQuery),
|
||||
// ...(timeRange && {
|
||||
// [QueryParams.startTime]: timeRange?.startTime.toString(),
|
||||
// [QueryParams.endTime]: timeRange?.endTime.toString(),
|
||||
// }),
|
||||
// } as Record<string, string>;
|
||||
|
||||
// if (route === ROUTES.METRICS_EXPLORER) {
|
||||
// queryParams = {
|
||||
// ...queryParams,
|
||||
// [QueryParams.summaryFilters]: JSON.stringify(
|
||||
// viewQuery?.builder.queryData[0].filters,
|
||||
// ),
|
||||
// };
|
||||
// }
|
||||
|
||||
// if (route) {
|
||||
// safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
|
||||
// newTab: true,
|
||||
// });
|
||||
// }
|
||||
|
||||
// onClose();
|
||||
// },
|
||||
// [
|
||||
// query,
|
||||
// widgetId,
|
||||
// safeNavigate,
|
||||
// onClose,
|
||||
// redirectToViewMode,
|
||||
// setSubMenu,
|
||||
// aggregateData,
|
||||
// ],
|
||||
// );
|
||||
|
||||
// const aggregateDrilldownConfig = useMemo(() => {
|
||||
// if (!aggregateData) {
|
||||
// console.warn('aggregateData is null in aggregateDrilldownConfig');
|
||||
// return {};
|
||||
// }
|
||||
// return getAggregateContextMenuConfig({
|
||||
// subMenu,
|
||||
// query,
|
||||
// onColumnClick: handleAggregateDrilldown,
|
||||
// aggregateData,
|
||||
// });
|
||||
// }, [handleAggregateDrilldown, query, subMenu, aggregateData]);
|
||||
|
||||
// New function to test useBreakout hook
|
||||
const { breakoutConfig } = useBreakout({
|
||||
query,
|
||||
widgetId,
|
||||
onClose,
|
||||
aggregateData,
|
||||
});
|
||||
|
||||
const { baseAggregateOptionsConfig } = useBaseAggregateOptions({
|
||||
query,
|
||||
widgetId,
|
||||
onClose,
|
||||
aggregateData,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
});
|
||||
|
||||
const aggregateDrilldownConfig = useMemo(() => {
|
||||
if (!aggregateData) {
|
||||
console.warn('aggregateData is null in testBreakoutConfig');
|
||||
return {};
|
||||
}
|
||||
|
||||
// If subMenu is breakout, use the new breakout hook
|
||||
if (subMenu === 'breakout') {
|
||||
return breakoutConfig;
|
||||
}
|
||||
|
||||
// Otherwise, use the existing getAggregateContextMenuConfig
|
||||
return baseAggregateOptionsConfig;
|
||||
}, [subMenu, aggregateData, breakoutConfig, baseAggregateOptionsConfig]);
|
||||
|
||||
return { aggregateDrilldownConfig };
|
||||
};
|
||||
|
||||
export default useAggregateDrilldown;
|
||||
@@ -0,0 +1,166 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { ContextMenuItem } from './contextConfig';
|
||||
import { getAggregateColumnHeader, getViewQuery } from './drilldownUtils';
|
||||
import { getBaseContextConfig } from './menuOptions';
|
||||
import { AggregateData } from './useAggregateDrilldown';
|
||||
|
||||
interface UseBaseAggregateOptionsProps {
|
||||
query: Query;
|
||||
widgetId: string;
|
||||
onClose: () => void;
|
||||
subMenu: string;
|
||||
setSubMenu: (subMenu: string) => void;
|
||||
aggregateData: AggregateData | null;
|
||||
}
|
||||
|
||||
interface BaseAggregateOptionsConfig {
|
||||
header?: string | React.ReactNode;
|
||||
items?: ContextMenuItem;
|
||||
}
|
||||
|
||||
const getRoute = (key: string): string => {
|
||||
switch (key) {
|
||||
case 'view_logs':
|
||||
return ROUTES.LOGS_EXPLORER;
|
||||
case 'view_metrics':
|
||||
return ROUTES.METRICS_EXPLORER;
|
||||
case 'view_traces':
|
||||
return ROUTES.TRACES_EXPLORER;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const useBaseAggregateOptions = ({
|
||||
query,
|
||||
widgetId,
|
||||
onClose,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
aggregateData,
|
||||
}: UseBaseAggregateOptionsProps): {
|
||||
baseAggregateOptionsConfig: BaseAggregateOptionsConfig;
|
||||
handleBaseDrilldown: (key: string, drilldownQuery?: Query) => void;
|
||||
} => {
|
||||
// const { redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
// const redirectToViewMode = useCallback(
|
||||
// (query: Query): void => {
|
||||
// redirectWithQueryBuilderData(
|
||||
// query,
|
||||
// { [QueryParams.expandedWidgetId]: widgetId },
|
||||
// undefined,
|
||||
// true,
|
||||
// );
|
||||
// },
|
||||
// [widgetId, redirectWithQueryBuilderData],
|
||||
// );
|
||||
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const handleBaseDrilldown = useCallback(
|
||||
(key: string): void => {
|
||||
console.log('Base drilldown:', { widgetId, query, key, aggregateData });
|
||||
|
||||
if (key === 'breakout') {
|
||||
// if (!drilldownQuery) {
|
||||
setSubMenu(key);
|
||||
return;
|
||||
// }
|
||||
}
|
||||
|
||||
const route = getRoute(key);
|
||||
const timeRange = aggregateData?.timeRange;
|
||||
const filtersToAdd = aggregateData?.filters || [];
|
||||
const viewQuery = getViewQuery(
|
||||
query,
|
||||
filtersToAdd,
|
||||
key,
|
||||
aggregateData?.queryName || '',
|
||||
);
|
||||
|
||||
let queryParams = {
|
||||
[QueryParams.compositeQuery]: JSON.stringify(viewQuery),
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange?.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange?.endTime.toString(),
|
||||
}),
|
||||
} as Record<string, string>;
|
||||
|
||||
if (route === ROUTES.METRICS_EXPLORER) {
|
||||
queryParams = {
|
||||
...queryParams,
|
||||
[QueryParams.summaryFilters]: JSON.stringify(
|
||||
viewQuery?.builder.queryData[0].filters,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (route) {
|
||||
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
|
||||
newTab: true,
|
||||
});
|
||||
}
|
||||
|
||||
onClose();
|
||||
},
|
||||
[query, widgetId, safeNavigate, onClose, setSubMenu, aggregateData],
|
||||
);
|
||||
|
||||
const baseAggregateOptionsConfig = useMemo(() => {
|
||||
if (!aggregateData) {
|
||||
console.warn('aggregateData is null in baseAggregateOptionsConfig');
|
||||
return {};
|
||||
}
|
||||
|
||||
// Skip breakout logic as it's handled by useBreakout
|
||||
if (subMenu === 'breakout') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Extract the non-breakout logic from getAggregateContextMenuConfig
|
||||
const { queryName } = aggregateData;
|
||||
const { dataSource, aggregations } = getAggregateColumnHeader(
|
||||
query,
|
||||
queryName as string,
|
||||
);
|
||||
|
||||
console.log('Header', { aggregateData });
|
||||
|
||||
return {
|
||||
header: (
|
||||
<ContextMenu.Header>
|
||||
<div style={{ textTransform: 'capitalize' }}>{dataSource}</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 'normal',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{aggregateData?.label || aggregations}
|
||||
</div>
|
||||
</ContextMenu.Header>
|
||||
),
|
||||
items: getBaseContextConfig({ handleBaseDrilldown }).map(
|
||||
({ key, label, icon, onClick }) => (
|
||||
<ContextMenu.Item key={key} icon={icon} onClick={(): void => onClick()}>
|
||||
{label}
|
||||
</ContextMenu.Item>
|
||||
),
|
||||
),
|
||||
};
|
||||
}, [subMenu, query, handleBaseDrilldown, aggregateData]);
|
||||
|
||||
return { baseAggregateOptionsConfig, handleBaseDrilldown };
|
||||
};
|
||||
|
||||
export default useBaseAggregateOptions;
|
||||
92
frontend/src/container/QueryTable/Drilldown/useBreakout.tsx
Normal file
92
frontend/src/container/QueryTable/Drilldown/useBreakout.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import BreakoutOptions from './BreakoutOptions';
|
||||
import { getQueryData } from './drilldownUtils';
|
||||
import { getBreakoutQuery } from './tableDrilldownUtils';
|
||||
import { AggregateData } from './useAggregateDrilldown';
|
||||
|
||||
interface UseBreakoutProps {
|
||||
query: Query;
|
||||
widgetId: string;
|
||||
onClose: () => void;
|
||||
aggregateData: AggregateData | null;
|
||||
}
|
||||
|
||||
interface BreakoutConfig {
|
||||
header?: string | React.ReactNode;
|
||||
items?: React.ReactNode;
|
||||
}
|
||||
|
||||
const useBreakout = ({
|
||||
query,
|
||||
widgetId,
|
||||
onClose,
|
||||
aggregateData,
|
||||
}: UseBreakoutProps): {
|
||||
breakoutConfig: BreakoutConfig;
|
||||
handleBreakoutClick: (groupBy: BaseAutocompleteData) => void;
|
||||
} => {
|
||||
const { redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const redirectToViewMode = useCallback(
|
||||
(query: Query): void => {
|
||||
redirectWithQueryBuilderData(
|
||||
query,
|
||||
{ [QueryParams.expandedWidgetId]: widgetId },
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
},
|
||||
[widgetId, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
const handleBreakoutClick = useCallback(
|
||||
(groupBy: BaseAutocompleteData): void => {
|
||||
console.log('Breakout click:', { widgetId, query, groupBy, aggregateData });
|
||||
|
||||
if (!aggregateData) {
|
||||
console.warn('aggregateData is null in handleBreakoutClick');
|
||||
return;
|
||||
}
|
||||
|
||||
const filtersToAdd = aggregateData.filters || [];
|
||||
const breakoutQuery = getBreakoutQuery(
|
||||
query,
|
||||
aggregateData,
|
||||
groupBy,
|
||||
filtersToAdd,
|
||||
);
|
||||
|
||||
redirectToViewMode(breakoutQuery);
|
||||
onClose();
|
||||
},
|
||||
[query, widgetId, aggregateData, redirectToViewMode, onClose],
|
||||
);
|
||||
|
||||
const breakoutConfig = useMemo(() => {
|
||||
if (!aggregateData) {
|
||||
console.warn('aggregateData is null in breakoutConfig');
|
||||
return {};
|
||||
}
|
||||
|
||||
const queryData = getQueryData(query, aggregateData.queryName || '');
|
||||
|
||||
return {
|
||||
header: 'Breakout by',
|
||||
items: (
|
||||
<BreakoutOptions
|
||||
queryData={queryData}
|
||||
onColumnClick={handleBreakoutClick}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}, [query, aggregateData, handleBreakoutClick]);
|
||||
|
||||
return { breakoutConfig, handleBreakoutClick };
|
||||
};
|
||||
|
||||
export default useBreakout;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ClickedData } from 'periscope/components/ContextMenu/types';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getGroupContextMenuConfig } from './contextConfig';
|
||||
import { addFilterToQuery } from './drilldownUtils';
|
||||
|
||||
const useFilterDrilldown = ({
|
||||
query,
|
||||
widgetId,
|
||||
clickedData,
|
||||
onClose,
|
||||
}: {
|
||||
query: Query;
|
||||
widgetId: string;
|
||||
clickedData: ClickedData | null;
|
||||
onClose: () => void;
|
||||
}): {
|
||||
filterDrilldownConfig: {
|
||||
header?: string | React.ReactNode;
|
||||
items?: React.ReactNode;
|
||||
};
|
||||
} => {
|
||||
const { redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const redirectToViewMode = useCallback(
|
||||
(query: Query): void => {
|
||||
redirectWithQueryBuilderData(
|
||||
query,
|
||||
{ [QueryParams.expandedWidgetId]: widgetId },
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
},
|
||||
[widgetId, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
const handleFilterDrilldown = useCallback(
|
||||
(operator: string): void => {
|
||||
const filterKey = clickedData?.column?.title as string;
|
||||
const filterValue = clickedData?.record?.[filterKey] || '';
|
||||
const newQuery = addFilterToQuery(query, [
|
||||
{
|
||||
filterKey,
|
||||
filterValue,
|
||||
operator,
|
||||
},
|
||||
]);
|
||||
redirectToViewMode(newQuery);
|
||||
onClose();
|
||||
},
|
||||
[onClose, clickedData, query, redirectToViewMode],
|
||||
);
|
||||
|
||||
const filterDrilldownConfig = useMemo(() => {
|
||||
if (!clickedData) {
|
||||
console.warn('clickedData is null in filterDrilldownConfig');
|
||||
return {};
|
||||
}
|
||||
return getGroupContextMenuConfig({
|
||||
query,
|
||||
clickedData,
|
||||
panelType: 'table',
|
||||
onColumnClick: handleFilterDrilldown,
|
||||
});
|
||||
}, [handleFilterDrilldown, clickedData, query]);
|
||||
|
||||
return {
|
||||
filterDrilldownConfig,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFilterDrilldown;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useMemo } from 'react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { isValidQueryName } from './drilldownUtils';
|
||||
import useAggregateDrilldown, { AggregateData } from './useAggregateDrilldown';
|
||||
|
||||
interface UseGraphContextMenuProps {
|
||||
widgetId?: string;
|
||||
query: Query;
|
||||
graphData: AggregateData | null;
|
||||
onClose: () => void;
|
||||
coordinates: { x: number; y: number } | null;
|
||||
subMenu: string;
|
||||
setSubMenu: (subMenu: string) => void;
|
||||
}
|
||||
|
||||
export function useGraphContextMenu({
|
||||
widgetId = '',
|
||||
query,
|
||||
graphData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
}: UseGraphContextMenuProps): {
|
||||
menuItemsConfig: {
|
||||
header?: string | React.ReactNode;
|
||||
items?: React.ReactNode;
|
||||
};
|
||||
} {
|
||||
const drilldownQuery = useGetCompositeQueryParam() || query;
|
||||
|
||||
const { aggregateDrilldownConfig } = useAggregateDrilldown({
|
||||
query: drilldownQuery,
|
||||
widgetId,
|
||||
onClose,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
aggregateData: graphData,
|
||||
});
|
||||
|
||||
const menuItemsConfig = useMemo(() => {
|
||||
if (!coordinates || !graphData) {
|
||||
return {};
|
||||
}
|
||||
// Check if queryName is valid for drilldown
|
||||
if (!isValidQueryName(graphData.queryName)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return aggregateDrilldownConfig;
|
||||
}, [coordinates, aggregateDrilldownConfig, graphData]);
|
||||
|
||||
return { menuItemsConfig };
|
||||
}
|
||||
|
||||
export default useGraphContextMenu;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { ClickedData } from 'periscope/components/ContextMenu/types';
|
||||
import { useMemo } from 'react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { ConfigType } from './contextConfig';
|
||||
import { isValidQueryName } from './drilldownUtils';
|
||||
import { getFiltersToAddToView } from './tableDrilldownUtils';
|
||||
import useAggregateDrilldown from './useAggregateDrilldown';
|
||||
import useFilterDrilldown from './useFilterDrilldown';
|
||||
|
||||
interface UseTableContextMenuProps {
|
||||
widgetId?: string;
|
||||
query: Query;
|
||||
clickedData: ClickedData | null;
|
||||
onClose: () => void;
|
||||
coordinates: { x: number; y: number } | null;
|
||||
subMenu: string;
|
||||
setSubMenu: (subMenu: string) => void;
|
||||
}
|
||||
|
||||
export function useTableContextMenu({
|
||||
widgetId = '',
|
||||
query,
|
||||
clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
}: UseTableContextMenuProps): {
|
||||
menuItemsConfig: {
|
||||
header?: string | React.ReactNode;
|
||||
items?: React.ReactNode;
|
||||
};
|
||||
} {
|
||||
const drilldownQuery = useGetCompositeQueryParam() || query;
|
||||
const { filterDrilldownConfig } = useFilterDrilldown({
|
||||
query: drilldownQuery,
|
||||
widgetId,
|
||||
clickedData,
|
||||
onClose,
|
||||
});
|
||||
|
||||
const aggregateData = useMemo(() => {
|
||||
if (!clickedData?.column?.isValueColumn) return null;
|
||||
|
||||
return {
|
||||
queryName: String(clickedData.column.queryName || ''),
|
||||
filters: getFiltersToAddToView(clickedData) || [],
|
||||
};
|
||||
}, [clickedData]);
|
||||
|
||||
const { aggregateDrilldownConfig } = useAggregateDrilldown({
|
||||
query: drilldownQuery,
|
||||
widgetId,
|
||||
onClose,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
aggregateData,
|
||||
});
|
||||
|
||||
const menuItemsConfig = useMemo(() => {
|
||||
if (!coordinates || (!clickedData && !aggregateData)) {
|
||||
if (!clickedData) {
|
||||
console.warn('clickedData is null in menuItemsConfig');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const columnType = clickedData?.column?.isValueColumn
|
||||
? ConfigType.AGGREGATE
|
||||
: ConfigType.GROUP;
|
||||
|
||||
// Check if queryName is valid for drilldown
|
||||
if (
|
||||
columnType === ConfigType.AGGREGATE &&
|
||||
!isValidQueryName(aggregateData?.queryName || '')
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
switch (columnType) {
|
||||
case ConfigType.AGGREGATE:
|
||||
return aggregateDrilldownConfig;
|
||||
case ConfigType.GROUP:
|
||||
return filterDrilldownConfig;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}, [
|
||||
clickedData,
|
||||
filterDrilldownConfig,
|
||||
coordinates,
|
||||
aggregateDrilldownConfig,
|
||||
aggregateData,
|
||||
]);
|
||||
|
||||
return { menuItemsConfig };
|
||||
}
|
||||
|
||||
export default useTableContextMenu;
|
||||
@@ -21,4 +21,5 @@ export type QueryTableProps = Omit<
|
||||
sticky?: TableProps<RowData>['sticky'];
|
||||
searchTerm?: string;
|
||||
widgetId?: string;
|
||||
enableDrillDown?: boolean;
|
||||
};
|
||||
|
||||
@@ -13,4 +13,13 @@
|
||||
width: 0.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.clickable-cell {
|
||||
cursor: pointer;
|
||||
max-width: fit-content;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './QueryTable.styles.scss';
|
||||
|
||||
import cx from 'classnames';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import Download from 'container/Download/Download';
|
||||
import { IServiceName } from 'container/MetricsApplication/Tabs/types';
|
||||
@@ -7,9 +8,11 @@ import {
|
||||
createTableColumnsFromQuery,
|
||||
RowData,
|
||||
} from 'lib/query/createTableColumnsFromQuery';
|
||||
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import useTableContextMenu from './Drilldown/useTableContextMenu';
|
||||
import { QueryTableProps } from './QueryTable.intefaces';
|
||||
import { createDownloadableData } from './utils';
|
||||
|
||||
@@ -28,9 +31,31 @@ export function QueryTable({
|
||||
...props
|
||||
}: QueryTableProps): JSX.Element {
|
||||
const { isDownloadEnabled = false, fileName = '' } = downloadOption || {};
|
||||
const isQueryTypeBuilder = query.queryType === 'builder';
|
||||
|
||||
const { servicename: encodedServiceName } = useParams<IServiceName>();
|
||||
const servicename = decodeURIComponent(encodedServiceName);
|
||||
const { loading } = props;
|
||||
const { loading, enableDrillDown = false } = props;
|
||||
|
||||
const {
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
clickedData,
|
||||
onClose,
|
||||
onClick,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
} = useCoordinates();
|
||||
const { menuItemsConfig } = useTableContextMenu({
|
||||
widgetId: widgetId || '',
|
||||
query,
|
||||
clickedData,
|
||||
onClose,
|
||||
coordinates,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
});
|
||||
|
||||
const { columns: newColumns, dataSource: newDataSource } = useMemo(() => {
|
||||
if (columns && dataSource) {
|
||||
return { columns, dataSource };
|
||||
@@ -54,6 +79,52 @@ export function QueryTable({
|
||||
|
||||
const tableColumns = modifyColumns ? modifyColumns(newColumns) : newColumns;
|
||||
|
||||
const handleColumnClick = useCallback(
|
||||
(
|
||||
e: React.MouseEvent,
|
||||
record: RowData,
|
||||
column: any,
|
||||
tableColumns: any,
|
||||
): void => {
|
||||
e.stopPropagation();
|
||||
if (isQueryTypeBuilder && enableDrillDown) {
|
||||
onClick({ x: e.clientX, y: e.clientY }, { record, column, tableColumns });
|
||||
}
|
||||
},
|
||||
[isQueryTypeBuilder, enableDrillDown, onClick],
|
||||
);
|
||||
|
||||
// Click handler to columns to capture clicked data
|
||||
const columnsWithClickHandlers = useMemo(
|
||||
() =>
|
||||
tableColumns.map((column: any): any => ({
|
||||
...column,
|
||||
render: (text: any, record: RowData, index: number): JSX.Element => {
|
||||
const originalRender = column.render;
|
||||
const renderedContent = originalRender
|
||||
? originalRender(text, record, index)
|
||||
: text;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
className={cx({
|
||||
'clickable-cell': isQueryTypeBuilder && enableDrillDown,
|
||||
})}
|
||||
tabIndex={0}
|
||||
onClick={(e): void => {
|
||||
handleColumnClick(e, record, column, tableColumns);
|
||||
}}
|
||||
onKeyDown={(): void => {}}
|
||||
>
|
||||
{renderedContent}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
})),
|
||||
[tableColumns, isQueryTypeBuilder, enableDrillDown, handleColumnClick],
|
||||
);
|
||||
|
||||
const paginationConfig = {
|
||||
pageSize: 10,
|
||||
showSizeChanger: false,
|
||||
@@ -82,28 +153,37 @@ export function QueryTable({
|
||||
}, [newDataSource, onTableSearch, searchTerm]);
|
||||
|
||||
return (
|
||||
<div className="query-table">
|
||||
{isDownloadEnabled && (
|
||||
<div className="query-table--download">
|
||||
<Download
|
||||
data={downloadableData}
|
||||
fileName={`${fileName}-${servicename}`}
|
||||
isLoading={loading as boolean}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ResizeTable
|
||||
columns={tableColumns}
|
||||
tableLayout="fixed"
|
||||
dataSource={filterTable === null ? newDataSource : filterTable}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={paginationConfig}
|
||||
widgetId={widgetId}
|
||||
shouldPersistColumnWidths
|
||||
sticky={sticky}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
<>
|
||||
<div className="query-table">
|
||||
{isDownloadEnabled && (
|
||||
<div className="query-table--download">
|
||||
<Download
|
||||
data={downloadableData}
|
||||
fileName={`${fileName}-${servicename}`}
|
||||
isLoading={loading as boolean}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ResizeTable
|
||||
columns={columnsWithClickHandlers}
|
||||
tableLayout="fixed"
|
||||
dataSource={filterTable === null ? newDataSource : filterTable}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={paginationConfig}
|
||||
widgetId={widgetId}
|
||||
shouldPersistColumnWidths
|
||||
sticky={sticky}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
<ContextMenu
|
||||
coordinates={coordinates}
|
||||
popoverPosition={popoverPosition}
|
||||
title={menuItemsConfig.header as string}
|
||||
items={menuItemsConfig.items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
|
||||
interface NavigateOptions {
|
||||
replace?: boolean;
|
||||
state?: any;
|
||||
newTab?: boolean;
|
||||
}
|
||||
|
||||
interface SafeNavigateParams {
|
||||
@@ -113,6 +114,16 @@ export const useSafeNavigate = (
|
||||
);
|
||||
}
|
||||
|
||||
// If newTab is true, open in new tab and return early
|
||||
if (options?.newTab) {
|
||||
const targetPath =
|
||||
typeof to === 'string'
|
||||
? to
|
||||
: `${to.pathname || location.pathname}${to.search || ''}`;
|
||||
window.open(targetPath, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
const urlsAreSame = areUrlsEffectivelySame(currentUrl, targetUrl);
|
||||
const isDefaultParamsNavigation = isDefaultNavigation(currentUrl, targetUrl);
|
||||
|
||||
|
||||
@@ -1,5 +1,90 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
// Helper function to get the focused/highlighted series at a specific position
|
||||
export const getFocusedSeriesAtPosition = (
|
||||
e: MouseEvent,
|
||||
u: uPlot,
|
||||
): {
|
||||
seriesIndex: number;
|
||||
seriesName: string;
|
||||
value: number;
|
||||
color: string;
|
||||
show: boolean;
|
||||
isFocused: boolean;
|
||||
} | null => {
|
||||
const bbox = u.over.getBoundingClientRect();
|
||||
const left = e.clientX - bbox.left;
|
||||
const top = e.clientY - bbox.top;
|
||||
|
||||
const timestampIndex = u.posToIdx(left);
|
||||
let focusedSeriesIndex = -1;
|
||||
let closestPixelDiff = Infinity;
|
||||
|
||||
// Check all series (skip index 0 which is the x-axis)
|
||||
for (let i = 1; i < u.data.length; i++) {
|
||||
const series = u.data[i];
|
||||
const seriesValue = series[timestampIndex];
|
||||
|
||||
if (
|
||||
seriesValue !== undefined &&
|
||||
seriesValue !== null &&
|
||||
!Number.isNaN(seriesValue)
|
||||
) {
|
||||
const seriesYPx = u.valToPos(seriesValue, 'y');
|
||||
const pixelDiff = Math.abs(seriesYPx - top);
|
||||
|
||||
if (pixelDiff < closestPixelDiff) {
|
||||
closestPixelDiff = pixelDiff;
|
||||
focusedSeriesIndex = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a focused series, return its data
|
||||
if (focusedSeriesIndex > 0) {
|
||||
const series = u.series[focusedSeriesIndex];
|
||||
const seriesValue = u.data[focusedSeriesIndex][timestampIndex];
|
||||
|
||||
// Ensure we have a valid value
|
||||
if (
|
||||
seriesValue !== undefined &&
|
||||
seriesValue !== null &&
|
||||
!Number.isNaN(seriesValue)
|
||||
) {
|
||||
// Get color - try series stroke first, then generate based on label
|
||||
let color = '#000000';
|
||||
if (typeof series.stroke === 'string') {
|
||||
color = series.stroke;
|
||||
} else if (typeof series.fill === 'string') {
|
||||
color = series.fill;
|
||||
} else {
|
||||
// Generate color based on series label (like the tooltip plugin does)
|
||||
const seriesLabel = series.label || `Series ${focusedSeriesIndex}`;
|
||||
// Detect theme mode by checking body class
|
||||
const isDarkMode = !document.body.classList.contains('lightMode');
|
||||
color = generateColor(
|
||||
seriesLabel,
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
seriesIndex: focusedSeriesIndex,
|
||||
seriesName: series.label || `Series ${focusedSeriesIndex}`,
|
||||
value: seriesValue as number,
|
||||
color,
|
||||
show: series.show !== false,
|
||||
isFocused: true, // This indicates it's the highlighted/bold one
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export interface OnClickPluginOpts {
|
||||
onClick: (
|
||||
xValue: number,
|
||||
@@ -13,6 +98,20 @@ export interface OnClickPluginOpts {
|
||||
queryName: string;
|
||||
inFocusOrNot: boolean;
|
||||
},
|
||||
absoluteMouseX?: number,
|
||||
absoluteMouseY?: number,
|
||||
axesData?: {
|
||||
xAxis: any;
|
||||
yAxis: any;
|
||||
},
|
||||
focusedSeries?: {
|
||||
seriesIndex: number;
|
||||
seriesName: string;
|
||||
value: number;
|
||||
color: string;
|
||||
show: boolean;
|
||||
isFocused: boolean;
|
||||
} | null,
|
||||
) => void;
|
||||
apiResponse?: MetricRangePayloadProps;
|
||||
}
|
||||
@@ -24,14 +123,22 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
|
||||
init: (u: uPlot) => {
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
handleClick = function (event: MouseEvent) {
|
||||
// relative coordinates
|
||||
const mouseX = event.offsetX + 40;
|
||||
const mouseY = event.offsetY + 40;
|
||||
|
||||
// absolute coordinates
|
||||
const absoluteMouseX = event.clientX;
|
||||
const absoluteMouseY = event.clientY;
|
||||
|
||||
// Convert pixel positions to data values
|
||||
// do not use mouseX and mouseY here as it offsets the timestamp as well
|
||||
const xValue = u.posToVal(event.offsetX, 'x');
|
||||
const yValue = u.posToVal(event.offsetY, 'y');
|
||||
|
||||
// Get the focused/highlighted series (the one that would be bold in hover)
|
||||
const focusedSeries = getFocusedSeriesAtPosition(event, u);
|
||||
|
||||
let metric = {};
|
||||
const { series } = u;
|
||||
const apiResult = opts.apiResponse?.data?.result || [];
|
||||
@@ -46,6 +153,8 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
if (item?.show && item?._focus) {
|
||||
console.log('>> outputMetric', apiResult[index - 1]);
|
||||
|
||||
const { metric: focusedMetric, queryName } = apiResult[index - 1] || [];
|
||||
metric = focusedMetric;
|
||||
outputMetric.queryName = queryName;
|
||||
@@ -54,7 +163,57 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
|
||||
});
|
||||
}
|
||||
|
||||
opts.onClick(xValue, yValue, mouseX, mouseY, metric, outputMetric);
|
||||
if (!outputMetric.queryName) {
|
||||
// Get the focused series data
|
||||
const focusedSeriesData = getFocusedSeriesAtPosition(event, u);
|
||||
|
||||
// If we found a valid focused series, get its data
|
||||
if (
|
||||
focusedSeriesData &&
|
||||
focusedSeriesData.seriesIndex <= apiResult.length
|
||||
) {
|
||||
console.log(
|
||||
'>> outputMetric',
|
||||
apiResult[focusedSeriesData.seriesIndex - 1],
|
||||
);
|
||||
const { metric: focusedMetric, queryName } =
|
||||
apiResult[focusedSeriesData.seriesIndex - 1] || [];
|
||||
metric = focusedMetric;
|
||||
outputMetric.queryName = queryName;
|
||||
outputMetric.inFocusOrNot = true;
|
||||
}
|
||||
}
|
||||
|
||||
const axesData = {
|
||||
xAxis: u.axes[0],
|
||||
yAxis: u.axes[1],
|
||||
};
|
||||
|
||||
console.log('>> graph click', {
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
metric,
|
||||
outputMetric,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
axesData,
|
||||
focusedSeries,
|
||||
});
|
||||
|
||||
opts.onClick(
|
||||
xValue,
|
||||
yValue,
|
||||
mouseX,
|
||||
mouseY,
|
||||
metric,
|
||||
outputMetric,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
axesData,
|
||||
focusedSeries,
|
||||
);
|
||||
};
|
||||
u.over.addEventListener('click', handleClick);
|
||||
},
|
||||
|
||||
@@ -111,7 +111,7 @@ function AlertActionButtons({
|
||||
return (
|
||||
<>
|
||||
<div className="alert-action-buttons">
|
||||
<Tooltip title={alertRuleState ? 'Enable alert' : 'Disable alert'}>
|
||||
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch
|
||||
size="small"
|
||||
|
||||
@@ -394,7 +394,7 @@ export const useAlertRuleStatusToggle = ({
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setAlertRuleState(data?.payload?.state);
|
||||
|
||||
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
|
||||
notifications.success({
|
||||
message: `Alert has been ${
|
||||
data?.payload?.state === 'disabled' ? 'disabled' : 'enabled'
|
||||
|
||||
@@ -58,6 +58,7 @@ function DashboardWidget(): JSX.Element | null {
|
||||
yAxisUnit={selectedWidget?.yAxisUnit}
|
||||
selectedGraph={selectedGraph}
|
||||
fillSpans={selectedWidget?.fillSpans}
|
||||
enableDrillDown
|
||||
/>
|
||||
</PreferenceContextProvider>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import '../RenameFunnel/RenameFunnel.styles.scss';
|
||||
|
||||
import { Input } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { AxiosError } from 'axios';
|
||||
import axios from 'axios';
|
||||
import SignozModal from 'components/SignozModal/SignozModal';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -26,6 +26,7 @@ function CreateFunnel({
|
||||
redirectToDetails,
|
||||
}: CreateFunnelProps): JSX.Element {
|
||||
const [funnelName, setFunnelName] = useState<string>('');
|
||||
const [inputError, setInputError] = useState<string>('');
|
||||
const createFunnelMutation = useCreateFunnel();
|
||||
const { notifications } = useNotifications();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -51,6 +52,7 @@ function CreateFunnel({
|
||||
logEvent(eventMessage, {});
|
||||
|
||||
setFunnelName('');
|
||||
setInputError('');
|
||||
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_FUNNELS_LIST]);
|
||||
|
||||
const funnelId = data?.payload?.funnel_id;
|
||||
@@ -65,11 +67,17 @@ function CreateFunnel({
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.error({
|
||||
message:
|
||||
((error as AxiosError)?.response?.data as string) ||
|
||||
'Failed to create funnel',
|
||||
});
|
||||
if (axios.isAxiosError(error) && error.response?.status === 400) {
|
||||
const errorMessage =
|
||||
error.response?.data?.error?.message || 'Invalid funnel name';
|
||||
setInputError(errorMessage);
|
||||
} else {
|
||||
notifications.error({
|
||||
message: axios.isAxiosError(error)
|
||||
? error.response?.data?.error?.message
|
||||
: 'Failed to create funnel',
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -77,9 +85,17 @@ function CreateFunnel({
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setFunnelName('');
|
||||
setInputError('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setFunnelName(e.target.value);
|
||||
if (inputError) {
|
||||
setInputError('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SignozModal
|
||||
open={isOpen}
|
||||
@@ -109,12 +125,18 @@ function CreateFunnel({
|
||||
<div className="funnel-modal-content">
|
||||
<span className="funnel-modal-content__label">Enter funnel name</span>
|
||||
<Input
|
||||
className="funnel-modal-content__input"
|
||||
className={`funnel-modal-content__input${
|
||||
inputError ? ' funnel-modal-content__input--error' : ''
|
||||
}`}
|
||||
value={funnelName}
|
||||
onChange={(e): void => setFunnelName(e.target.value)}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Eg. checkout dropoff funnel"
|
||||
autoFocus
|
||||
status={inputError && 'error'}
|
||||
/>
|
||||
{inputError && (
|
||||
<span className="funnel-modal-content__error">{inputError}</span>
|
||||
)}
|
||||
</div>
|
||||
</SignozModal>
|
||||
);
|
||||
|
||||
@@ -63,6 +63,18 @@
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
&--error {
|
||||
border-color: var(--bg-cherry-500);
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
color: var(--bg-cherry-500);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +94,14 @@
|
||||
&:focus {
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
&--error {
|
||||
border-color: var(--bg-cherry-500);
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
color: var(--bg-cherry-500);
|
||||
}
|
||||
}
|
||||
.funnel-modal__cancel-btn {
|
||||
|
||||
@@ -198,4 +198,3 @@ export default class FilterQueryListener extends ParseTreeListener {
|
||||
*/
|
||||
exitKey?: (ctx: KeyContext) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -133,4 +133,3 @@ export default class FilterQueryVisitor<Result> extends ParseTreeVisitor<Result>
|
||||
*/
|
||||
visitKey?: (ctx: KeyContext) => Result;
|
||||
}
|
||||
|
||||
|
||||
160
frontend/src/periscope/components/ContextMenu/index.tsx
Normal file
160
frontend/src/periscope/components/ContextMenu/index.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import './styles.scss';
|
||||
|
||||
import { Popover } from 'antd';
|
||||
import { ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Coordinates, PopoverPosition } from './types';
|
||||
import { useCoordinates } from './useCoordinates';
|
||||
|
||||
export { useCoordinates };
|
||||
export type { ClickedData, Coordinates, PopoverPosition } from './types';
|
||||
|
||||
interface ContextMenuProps {
|
||||
coordinates: Coordinates | null;
|
||||
popoverPosition?: PopoverPosition | null;
|
||||
title?: string;
|
||||
items?: ReactNode;
|
||||
onClose: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface ContextMenuItemProps {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
children,
|
||||
onClick,
|
||||
icon,
|
||||
disabled = false,
|
||||
danger = false,
|
||||
}: ContextMenuItemProps): JSX.Element {
|
||||
const className = `context-menu-item${disabled ? ' disabled' : ''}${
|
||||
danger ? ' danger' : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
>
|
||||
{icon && <span className="icon">{icon}</span>}
|
||||
<span className="text">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContextMenuHeaderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function ContextMenuHeader({ children }: ContextMenuHeaderProps): JSX.Element {
|
||||
return <div className="context-menu-header">{children}</div>;
|
||||
}
|
||||
|
||||
export function ContextMenu({
|
||||
coordinates,
|
||||
popoverPosition,
|
||||
title,
|
||||
items,
|
||||
onClose,
|
||||
children,
|
||||
}: ContextMenuProps): JSX.Element | null {
|
||||
if (!coordinates || !items) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const position: PopoverPosition = popoverPosition ?? {
|
||||
left: coordinates.x + 10,
|
||||
top: coordinates.y - 10,
|
||||
placement: 'right',
|
||||
};
|
||||
|
||||
// Render backdrop using portal to ensure it covers the entire viewport
|
||||
const backdrop = createPortal(
|
||||
<div
|
||||
className="context-menu-backdrop"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Close context menu"
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{backdrop}
|
||||
<Popover
|
||||
content={items}
|
||||
title={title}
|
||||
open={Boolean(coordinates)}
|
||||
onOpenChange={(open: boolean): void => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
trigger="click"
|
||||
overlayStyle={{
|
||||
position: 'fixed',
|
||||
left: position.left,
|
||||
top: position.top,
|
||||
width: 210,
|
||||
maxHeight: 254,
|
||||
}}
|
||||
arrow={false}
|
||||
placement={position.placement}
|
||||
rootClassName="context-menu"
|
||||
zIndex={10000}
|
||||
>
|
||||
{children}
|
||||
{/* phantom span to force Popover to position relative to viewport */}
|
||||
<span
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: position.left,
|
||||
top: position.top,
|
||||
width: 0,
|
||||
height: 0,
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Attach Item component to ContextMenu
|
||||
ContextMenu.Item = ContextMenuItem;
|
||||
ContextMenu.Header = ContextMenuHeader;
|
||||
|
||||
// default props for ContextMenuItem
|
||||
ContextMenuItem.defaultProps = {
|
||||
onClick: undefined,
|
||||
icon: undefined,
|
||||
disabled: false,
|
||||
danger: false,
|
||||
};
|
||||
|
||||
// default props
|
||||
ContextMenu.defaultProps = {
|
||||
popoverPosition: null,
|
||||
title: '',
|
||||
items: null,
|
||||
children: null,
|
||||
};
|
||||
export default ContextMenu;
|
||||
|
||||
// ENHANCEMENT:
|
||||
// 1. Adjust postion based on variable height of items. Currently hardcoded to 254px. Same for width.
|
||||
144
frontend/src/periscope/components/ContextMenu/styles.scss
Normal file
144
frontend/src/periscope/components/ContextMenu/styles.scss
Normal file
@@ -0,0 +1,144 @@
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--bg-ink-400);
|
||||
font-family: Inter;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
line-height: 17px;
|
||||
letter-spacing: 0.01em;
|
||||
transition: background-color 0.2s ease;
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background-color: var(--bg-vanilla-200);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--bg-cherry-400);
|
||||
|
||||
.icon {
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-cherry-100);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--bg-robin-500);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 17px;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-header {
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--bg-vanilla-400);
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
// Target the popover inner specifically for context menu
|
||||
.context-menu .ant-popover-inner {
|
||||
padding: 12px 8px !important;
|
||||
max-height: 254px !important;
|
||||
max-width: 210px !important;
|
||||
}
|
||||
|
||||
// Dark mode support
|
||||
.darkMode {
|
||||
.context-menu-item {
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--bg-cherry-400);
|
||||
|
||||
.icon {
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-cherry-500);
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-header {
|
||||
border-bottom: 1px solid var(--bg-slate-400);
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
|
||||
// Set the menu popover background
|
||||
.context-menu .ant-popover-inner {
|
||||
background: var(--bg-ink-500) !important;
|
||||
border: 1px solid var(--bg-slate-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu backdrop overlay
|
||||
.context-menu-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 9999;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
|
||||
// Prevent any pointer events from reaching elements behind
|
||||
pointer-events: auto;
|
||||
|
||||
// Ensure it covers the entire viewport including any scrollable areas
|
||||
position: fixed !important;
|
||||
inset: 0;
|
||||
}
|
||||
31
frontend/src/periscope/components/ContextMenu/types.ts
Normal file
31
frontend/src/periscope/components/ContextMenu/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { CustomDataColumnType } from 'container/GridTableComponent/utils';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
|
||||
export interface ClickedData {
|
||||
record: RowData;
|
||||
column: CustomDataColumnType<RowData>;
|
||||
tableColumns?: CustomDataColumnType<RowData>[];
|
||||
}
|
||||
|
||||
export interface Coordinates {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface PopoverPosition {
|
||||
left: number;
|
||||
top: number;
|
||||
placement:
|
||||
| 'top'
|
||||
| 'topLeft'
|
||||
| 'topRight'
|
||||
| 'bottom'
|
||||
| 'bottomLeft'
|
||||
| 'bottomRight'
|
||||
| 'left'
|
||||
| 'leftTop'
|
||||
| 'leftBottom'
|
||||
| 'right'
|
||||
| 'rightTop'
|
||||
| 'rightBottom';
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { Coordinates, PopoverPosition } from './types';
|
||||
|
||||
// Custom hook for managing coordinates
|
||||
export const useCoordinates = (): {
|
||||
coordinates: Coordinates | null;
|
||||
clickedData: any;
|
||||
popoverPosition: PopoverPosition | null;
|
||||
onClick: (coordinates: { x: number; y: number }, data?: any) => void;
|
||||
onClose: () => void;
|
||||
subMenu: string; // todo: create enum
|
||||
setSubMenu: (subMenu: string) => void;
|
||||
} => {
|
||||
const [coordinates, setCoordinates] = useState<Coordinates | null>(null);
|
||||
const [clickedData, setClickedData] = useState<any>(null);
|
||||
const [subMenu, setSubMenu] = useState<string>('');
|
||||
const [popoverPosition, setPopoverPosition] = useState<PopoverPosition | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const calculatePosition = useCallback(
|
||||
(x: number, y: number): PopoverPosition => {
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
const popoverWidth = 210;
|
||||
const popoverHeight = 254;
|
||||
const offset = 10;
|
||||
|
||||
let left = x + offset;
|
||||
let top = y - offset;
|
||||
let placement: PopoverPosition['placement'] = 'right';
|
||||
|
||||
// Check if popover would go off the right edge
|
||||
if (left + popoverWidth > windowWidth) {
|
||||
left = x - popoverWidth + offset;
|
||||
placement = 'left';
|
||||
}
|
||||
|
||||
// Check if popover would go off the left edge
|
||||
if (left < 0) {
|
||||
left = offset;
|
||||
placement = 'right';
|
||||
}
|
||||
|
||||
// Check if popover would go off the top edge
|
||||
if (top < 0) {
|
||||
top = offset;
|
||||
placement = placement === 'right' ? 'bottomRight' : 'bottomLeft';
|
||||
}
|
||||
|
||||
// Check if popover would go off the bottom edge
|
||||
if (top + popoverHeight > windowHeight) {
|
||||
top = windowHeight - popoverHeight - offset;
|
||||
placement = placement === 'right' ? 'topRight' : 'topLeft';
|
||||
}
|
||||
|
||||
return { left, top, placement };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onClick = useCallback(
|
||||
(coords: { x: number; y: number }, data?: any): void => {
|
||||
const coordinates: Coordinates = { x: coords.x, y: coords.y };
|
||||
const position = calculatePosition(coordinates.x, coordinates.y);
|
||||
if (data) {
|
||||
setClickedData(data);
|
||||
setCoordinates(coordinates);
|
||||
setPopoverPosition(position);
|
||||
}
|
||||
},
|
||||
[calculatePosition],
|
||||
);
|
||||
|
||||
const onClose = useCallback((): void => {
|
||||
setCoordinates(null);
|
||||
setClickedData(null);
|
||||
setPopoverPosition(null);
|
||||
setSubMenu('');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
clickedData,
|
||||
popoverPosition,
|
||||
onClick,
|
||||
onClose,
|
||||
subMenu,
|
||||
setSubMenu,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCoordinates;
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/SigNoz/signoz/pkg/variables"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
@@ -85,6 +86,60 @@ func (a *API) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
render.Success(rw, http.StatusOK, queryRangeResponse)
|
||||
}
|
||||
|
||||
// TODO(srikanthccv): everything done here can be done on frontend as well
|
||||
// For the time being I am adding a helper function
|
||||
func (a *API) ReplaceVariables(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
var queryRangeRequest qbtypes.QueryRangeRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&queryRangeRequest); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
errs := []error{}
|
||||
|
||||
for idx, item := range queryRangeRequest.CompositeQuery.Queries {
|
||||
if item.Type == qbtypes.QueryTypeBuilder {
|
||||
switch spec := item.Spec.(type) {
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
||||
if spec.Filter != nil && spec.Filter.Expression != "" {
|
||||
replaced, err := variables.ReplaceVariablesInExpression(spec.Filter.Expression, queryRangeRequest.Variables)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
spec.Filter.Expression = replaced
|
||||
}
|
||||
queryRangeRequest.CompositeQuery.Queries[idx].Spec = spec
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
|
||||
if spec.Filter != nil && spec.Filter.Expression != "" {
|
||||
replaced, err := variables.ReplaceVariablesInExpression(spec.Filter.Expression, queryRangeRequest.Variables)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
spec.Filter.Expression = replaced
|
||||
}
|
||||
queryRangeRequest.CompositeQuery.Queries[idx].Spec = spec
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
|
||||
if spec.Filter != nil && spec.Filter.Expression != "" {
|
||||
replaced, err := variables.ReplaceVariablesInExpression(spec.Filter.Expression, queryRangeRequest.Variables)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
spec.Filter.Expression = replaced
|
||||
}
|
||||
queryRangeRequest.CompositeQuery.Queries[idx].Spec = spec
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) != 0 {
|
||||
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, errors.Join(errs...).Error()))
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, queryRangeRequest)
|
||||
}
|
||||
|
||||
func (a *API) logEvent(ctx context.Context, referrer string, event *qbtypes.QBEvent) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -194,7 +194,9 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result.Warnings = stmt.Warnings
|
||||
result.WarningsDocURL = stmt.WarningsDocURL
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -297,6 +299,9 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
|
||||
}
|
||||
}
|
||||
|
||||
var warnings []string
|
||||
var warningsDocURL string
|
||||
|
||||
for _, r := range buckets {
|
||||
q.spec.Offset = 0
|
||||
q.spec.Limit = need
|
||||
@@ -305,7 +310,8 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
warnings = stmt.Warnings
|
||||
warningsDocURL = stmt.WarningsDocURL
|
||||
// Execute with proper context for partial value detection
|
||||
res, err := q.executeWithContext(ctx, stmt.Query, stmt.Args)
|
||||
if err != nil {
|
||||
@@ -345,6 +351,8 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
|
||||
Rows: rows,
|
||||
NextCursor: nextCursor,
|
||||
},
|
||||
Warnings: warnings,
|
||||
WarningsDocURL: warningsDocURL,
|
||||
Stats: qbtypes.ExecStats{
|
||||
RowsScanned: totalRows,
|
||||
BytesScanned: totalBytes,
|
||||
|
||||
@@ -332,7 +332,7 @@ func readAsScalar(rows driver.Rows, queryName string) (*qbtypes.ScalarData, erro
|
||||
}, nil
|
||||
}
|
||||
|
||||
func derefValue(v interface{}) interface{} {
|
||||
func derefValue(v any) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -290,6 +290,7 @@ func (q *querier) run(
|
||||
) (*qbtypes.QueryRangeResponse, error) {
|
||||
results := make(map[string]any)
|
||||
warnings := make([]string, 0)
|
||||
warningsDocURL := ""
|
||||
stats := qbtypes.ExecStats{}
|
||||
|
||||
hasData := func(result *qbtypes.Result) bool {
|
||||
@@ -338,6 +339,7 @@ func (q *querier) run(
|
||||
}
|
||||
results[name] = result.Value
|
||||
warnings = append(warnings, result.Warnings...)
|
||||
warningsDocURL = result.WarningsDocURL
|
||||
stats.RowsScanned += result.Stats.RowsScanned
|
||||
stats.BytesScanned += result.Stats.BytesScanned
|
||||
stats.DurationMS += result.Stats.DurationMS
|
||||
@@ -349,6 +351,7 @@ func (q *querier) run(
|
||||
}
|
||||
results[name] = result.Value
|
||||
warnings = append(warnings, result.Warnings...)
|
||||
warningsDocURL = result.WarningsDocURL
|
||||
stats.RowsScanned += result.Stats.RowsScanned
|
||||
stats.BytesScanned += result.Stats.BytesScanned
|
||||
stats.DurationMS += result.Stats.DurationMS
|
||||
@@ -360,14 +363,10 @@ func (q *querier) run(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &qbtypes.QueryRangeResponse{
|
||||
resp := &qbtypes.QueryRangeResponse{
|
||||
Type: req.RequestType,
|
||||
Data: struct {
|
||||
Results []any `json:"results"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}{
|
||||
Results: maps.Values(processedResults),
|
||||
Warnings: warnings,
|
||||
Data: qbtypes.QueryData{
|
||||
Results: maps.Values(processedResults),
|
||||
},
|
||||
Meta: struct {
|
||||
RowsScanned uint64 `json:"rowsScanned"`
|
||||
@@ -378,7 +377,23 @@ func (q *querier) run(
|
||||
BytesScanned: stats.BytesScanned,
|
||||
DurationMS: stats.DurationMS,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
if len(warnings) != 0 {
|
||||
warns := make([]qbtypes.QueryWarnDataAdditional, len(warnings))
|
||||
for i, warning := range warnings {
|
||||
warns[i] = qbtypes.QueryWarnDataAdditional{
|
||||
Message: warning,
|
||||
}
|
||||
}
|
||||
|
||||
resp.Warning = qbtypes.QueryWarnData{
|
||||
Message: "Encountered warnings",
|
||||
Url: warningsDocURL,
|
||||
Warnings: warns,
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// executeWithCache executes a query using the bucket cache
|
||||
@@ -520,9 +535,10 @@ func (q *querier) mergeResults(cached *qbtypes.Result, fresh []*qbtypes.Result)
|
||||
// If cached is nil but we have multiple fresh results, we need to merge them
|
||||
// We need to merge all fresh results properly to avoid duplicates
|
||||
merged := &qbtypes.Result{
|
||||
Type: fresh[0].Type,
|
||||
Stats: fresh[0].Stats,
|
||||
Warnings: fresh[0].Warnings,
|
||||
Type: fresh[0].Type,
|
||||
Stats: fresh[0].Stats,
|
||||
Warnings: fresh[0].Warnings,
|
||||
WarningsDocURL: fresh[0].WarningsDocURL,
|
||||
}
|
||||
|
||||
// Merge all fresh results including the first one
|
||||
@@ -537,10 +553,11 @@ func (q *querier) mergeResults(cached *qbtypes.Result, fresh []*qbtypes.Result)
|
||||
|
||||
// Start with cached result
|
||||
merged := &qbtypes.Result{
|
||||
Type: cached.Type,
|
||||
Value: cached.Value,
|
||||
Stats: cached.Stats,
|
||||
Warnings: cached.Warnings,
|
||||
Type: cached.Type,
|
||||
Value: cached.Value,
|
||||
Stats: cached.Stats,
|
||||
Warnings: cached.Warnings,
|
||||
WarningsDocURL: cached.WarningsDocURL,
|
||||
}
|
||||
|
||||
// If no fresh results, return cached
|
||||
|
||||
@@ -470,6 +470,7 @@ func (aH *APIHandler) RegisterQueryRangeV4Routes(router *mux.Router, am *middlew
|
||||
func (aH *APIHandler) RegisterQueryRangeV5Routes(router *mux.Router, am *middleware.AuthZ) {
|
||||
subRouter := router.PathPrefix("/api/v5").Subrouter()
|
||||
subRouter.HandleFunc("/query_range", am.ViewAccess(aH.QuerierAPI.QueryRange)).Methods(http.MethodPost)
|
||||
subRouter.HandleFunc("/substitute_vars", am.ViewAccess(aH.QuerierAPI.ReplaceVariables)).Methods(http.MethodPost)
|
||||
}
|
||||
|
||||
// todo(remove): Implemented at render package (github.com/SigNoz/signoz/pkg/http/render) with the new error structure
|
||||
|
||||
@@ -603,7 +603,7 @@ func (c *CompositeQuery) Validate() error {
|
||||
return fmt.Errorf("composite query is required")
|
||||
}
|
||||
|
||||
if c.BuilderQueries == nil && c.ClickHouseQueries == nil && c.PromQueries == nil {
|
||||
if c.BuilderQueries == nil && c.ClickHouseQueries == nil && c.PromQueries == nil && len(c.Queries) == 0 {
|
||||
return fmt.Errorf("composite query must contain at least one query type")
|
||||
}
|
||||
|
||||
|
||||
@@ -291,7 +291,8 @@ func (r *ThresholdRule) prepareQueryRangeV5(ctx context.Context, ts time.Time) (
|
||||
},
|
||||
NoCache: true,
|
||||
}
|
||||
copy(r.Condition().CompositeQuery.Queries, req.CompositeQuery.Queries)
|
||||
req.CompositeQuery.Queries = make([]qbtypes.QueryEnvelope, len(r.Condition().CompositeQuery.Queries))
|
||||
copy(req.CompositeQuery.Queries, r.Condition().CompositeQuery.Queries)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
@@ -503,16 +504,7 @@ func (r *ThresholdRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUI
|
||||
return nil, fmt.Errorf("internal error while querying")
|
||||
}
|
||||
|
||||
data, ok := v5Result.Data.(struct {
|
||||
Results []any `json:"results"`
|
||||
Warnings []string `json:"warnings"`
|
||||
})
|
||||
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected result from v5 querier")
|
||||
}
|
||||
|
||||
for _, item := range data.Results {
|
||||
for _, item := range v5Result.Data.Results {
|
||||
if tsData, ok := item.(*qbtypes.TimeSeriesData); ok {
|
||||
results = append(results, transition.ConvertV5TimeSeriesDataToV4Result(tsData))
|
||||
} else {
|
||||
|
||||
@@ -152,7 +152,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
|
||||
aggFunc, ok := AggreFuncMap[valuer.NewString(name)]
|
||||
if !ok {
|
||||
return nil
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unrecognized function: %s", name)
|
||||
}
|
||||
|
||||
var args []chparser.Expr
|
||||
@@ -180,7 +180,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
if aggFunc.FuncCombinator {
|
||||
// Map the predicate (last argument)
|
||||
origPred := args[len(args)-1].String()
|
||||
whereClause, _, err := PrepareWhereClause(
|
||||
whereClause, err := PrepareWhereClause(
|
||||
origPred,
|
||||
FilterExprVisitorOpts{
|
||||
FieldKeys: v.fieldKeys,
|
||||
@@ -195,7 +195,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
return err
|
||||
}
|
||||
|
||||
newPred, chArgs := whereClause.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
newPred, chArgs := whereClause.WhereClause.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
newPred = strings.TrimPrefix(newPred, "WHERE")
|
||||
parsedPred, err := parseFragment(newPred)
|
||||
if err != nil {
|
||||
|
||||
@@ -146,7 +146,7 @@ func (b *resourceFilterStatementBuilder[T]) addConditions(
|
||||
if query.Filter != nil && query.Filter.Expression != "" {
|
||||
|
||||
// warnings would be encountered as part of the main condition already
|
||||
filterWhereClause, _, err := querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
|
||||
filterWhereClause, err := querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
|
||||
FieldMapper: b.fieldMapper,
|
||||
ConditionBuilder: b.conditionBuilder,
|
||||
FieldKeys: keys,
|
||||
@@ -164,7 +164,7 @@ func (b *resourceFilterStatementBuilder[T]) addConditions(
|
||||
return err
|
||||
}
|
||||
if filterWhereClause != nil {
|
||||
sb.AddWhereClause(filterWhereClause)
|
||||
sb.AddWhereClause(filterWhereClause.WhereClause)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,14 +15,18 @@ import (
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
var searchTroubleshootingGuideURL = "https://signoz.io/docs/userguide/search-troubleshooting/"
|
||||
|
||||
// filterExpressionVisitor implements the FilterQueryVisitor interface
|
||||
// to convert the parsed filter expressions into ClickHouse WHERE clause
|
||||
type filterExpressionVisitor struct {
|
||||
fieldMapper qbtypes.FieldMapper
|
||||
conditionBuilder qbtypes.ConditionBuilder
|
||||
warnings []string
|
||||
mainWarnURL string
|
||||
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey
|
||||
errors []string
|
||||
mainErrorURL string
|
||||
builder *sqlbuilder.SelectBuilder
|
||||
fullTextColumn *telemetrytypes.TelemetryFieldKey
|
||||
jsonBodyPrefix string
|
||||
@@ -70,8 +74,14 @@ func newFilterExpressionVisitor(opts FilterExprVisitorOpts) *filterExpressionVis
|
||||
}
|
||||
}
|
||||
|
||||
type PreparedWhereClause struct {
|
||||
WhereClause *sqlbuilder.WhereClause
|
||||
Warnings []string
|
||||
WarningsDocURL string
|
||||
}
|
||||
|
||||
// PrepareWhereClause generates a ClickHouse compatible WHERE clause from the filter query
|
||||
func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (*sqlbuilder.WhereClause, []string, error) {
|
||||
func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (*PreparedWhereClause, error) {
|
||||
// Setup the ANTLR parsing pipeline
|
||||
input := antlr.NewInputStream(query)
|
||||
lexer := grammar.NewFilterQueryLexer(input)
|
||||
@@ -102,14 +112,17 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (*sqlbuilder.W
|
||||
combinedErrors := errors.Newf(
|
||||
errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"found %d syntax errors while parsing the filter expression",
|
||||
"Found %d syntax errors while parsing the search expression.",
|
||||
len(parserErrorListener.SyntaxErrors),
|
||||
)
|
||||
additionals := make([]string, len(parserErrorListener.SyntaxErrors))
|
||||
for _, err := range parserErrorListener.SyntaxErrors {
|
||||
additionals = append(additionals, err.Error())
|
||||
if err.Error() != "" {
|
||||
additionals = append(additionals, err.Error())
|
||||
}
|
||||
}
|
||||
return nil, nil, combinedErrors.WithAdditional(additionals...)
|
||||
|
||||
return nil, combinedErrors.WithAdditional(additionals...).WithUrl(searchTroubleshootingGuideURL)
|
||||
}
|
||||
|
||||
// Visit the parse tree with our ClickHouse visitor
|
||||
@@ -120,15 +133,23 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (*sqlbuilder.W
|
||||
combinedErrors := errors.Newf(
|
||||
errors.TypeInvalidInput,
|
||||
errors.CodeInvalidInput,
|
||||
"found %d errors while parsing the search expression",
|
||||
"Found %d errors while parsing the search expression.",
|
||||
len(visitor.errors),
|
||||
)
|
||||
return nil, nil, combinedErrors.WithAdditional(visitor.errors...)
|
||||
url := visitor.mainErrorURL
|
||||
if url == "" {
|
||||
url = searchTroubleshootingGuideURL
|
||||
}
|
||||
return nil, combinedErrors.WithAdditional(visitor.errors...).WithUrl(url)
|
||||
}
|
||||
|
||||
if cond == "" {
|
||||
cond = "true"
|
||||
}
|
||||
|
||||
whereClause := sqlbuilder.NewWhereClause().AddWhereExpr(visitor.builder.Args, cond)
|
||||
|
||||
return whereClause, visitor.warnings, nil
|
||||
return &PreparedWhereClause{whereClause, visitor.warnings, visitor.mainWarnURL}, nil
|
||||
}
|
||||
|
||||
// Visit dispatches to the specific visit method based on node type
|
||||
@@ -194,7 +215,13 @@ func (v *filterExpressionVisitor) VisitOrExpression(ctx *grammar.OrExpressionCon
|
||||
|
||||
andExpressionConditions := make([]string, len(andExpressions))
|
||||
for i, expr := range andExpressions {
|
||||
andExpressionConditions[i] = v.Visit(expr).(string)
|
||||
if condExpr, ok := v.Visit(expr).(string); ok && condExpr != "" {
|
||||
andExpressionConditions[i] = condExpr
|
||||
}
|
||||
}
|
||||
|
||||
if len(andExpressionConditions) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(andExpressionConditions) == 1 {
|
||||
@@ -210,7 +237,13 @@ func (v *filterExpressionVisitor) VisitAndExpression(ctx *grammar.AndExpressionC
|
||||
|
||||
unaryExpressionConditions := make([]string, len(unaryExpressions))
|
||||
for i, expr := range unaryExpressions {
|
||||
unaryExpressionConditions[i] = v.Visit(expr).(string)
|
||||
if condExpr, ok := v.Visit(expr).(string); ok && condExpr != "" {
|
||||
unaryExpressionConditions[i] = condExpr
|
||||
}
|
||||
}
|
||||
|
||||
if len(unaryExpressionConditions) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(unaryExpressionConditions) == 1 {
|
||||
@@ -236,7 +269,10 @@ func (v *filterExpressionVisitor) VisitUnaryExpression(ctx *grammar.UnaryExpress
|
||||
func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
|
||||
if ctx.OrExpression() != nil {
|
||||
// This is a parenthesized expression
|
||||
return fmt.Sprintf("(%s)", v.Visit(ctx.OrExpression()).(string))
|
||||
if condExpr, ok := v.Visit(ctx.OrExpression()).(string); ok && condExpr != "" {
|
||||
return fmt.Sprintf("(%s)", v.Visit(ctx.OrExpression()).(string))
|
||||
}
|
||||
return ""
|
||||
} else if ctx.Comparison() != nil {
|
||||
return v.Visit(ctx.Comparison())
|
||||
} else if ctx.FunctionCall() != nil {
|
||||
@@ -248,7 +284,7 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
|
||||
// Handle standalone key/value as a full text search term
|
||||
if ctx.GetChildCount() == 1 {
|
||||
if v.skipFullTextFilter {
|
||||
return "true"
|
||||
return ""
|
||||
}
|
||||
|
||||
if v.fullTextColumn == nil {
|
||||
@@ -297,11 +333,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
|
||||
|
||||
// if key is missing and can be ignored, the condition is ignored
|
||||
if len(keys) == 0 && v.ignoreNotFoundKeys {
|
||||
// Why do we return "true"? to prevent from create a empty tuple
|
||||
// example, if the condition is (x AND (y OR z))
|
||||
// if we find ourselves ignoring all, then it creates and invalid
|
||||
// condition (()) which throws invalid tuples error
|
||||
return "true"
|
||||
return ""
|
||||
}
|
||||
|
||||
// this is used to skip the resource filtering on main table if
|
||||
@@ -315,11 +347,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
|
||||
}
|
||||
keys = filteredKeys
|
||||
if len(keys) == 0 {
|
||||
// Why do we return "true"? to prevent from create a empty tuple
|
||||
// example, if the condition is (resource.service.name='api' AND (env='prod' OR env='production'))
|
||||
// if we find ourselves skipping all, then it creates and invalid
|
||||
// condition (()) which throws invalid tuples error
|
||||
return "true"
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +396,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
|
||||
var varItem qbtypes.VariableItem
|
||||
varItem, ok = v.variables[var_]
|
||||
// if not present, try without `$` prefix
|
||||
if !ok {
|
||||
if !ok && len(var_) > 0 {
|
||||
varItem, ok = v.variables[var_[1:]]
|
||||
}
|
||||
|
||||
@@ -547,7 +575,7 @@ func (v *filterExpressionVisitor) VisitValueList(ctx *grammar.ValueListContext)
|
||||
func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) any {
|
||||
|
||||
if v.skipFullTextFilter {
|
||||
return "true"
|
||||
return ""
|
||||
}
|
||||
|
||||
var text string
|
||||
@@ -573,7 +601,7 @@ func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) an
|
||||
// VisitFunctionCall handles function calls like has(), hasAny(), etc.
|
||||
func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallContext) any {
|
||||
if v.skipFunctionCalls {
|
||||
return "true"
|
||||
return ""
|
||||
}
|
||||
|
||||
// Get function name based on which token is present
|
||||
@@ -609,6 +637,10 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
|
||||
if strings.HasPrefix(key.Name, v.jsonBodyPrefix) {
|
||||
fieldName, _ = v.jsonKeyToKey(context.Background(), key, qbtypes.FilterOperatorUnknown, value)
|
||||
} else {
|
||||
// TODO(add docs for json body search)
|
||||
if v.mainErrorURL == "" {
|
||||
v.mainErrorURL = "https://signoz.io/docs/userguide/search-troubleshooting/#function-supports-only-body-json-search"
|
||||
}
|
||||
v.errors = append(v.errors, fmt.Sprintf("function `%s` supports only body JSON search", functionName))
|
||||
return ""
|
||||
}
|
||||
@@ -736,13 +768,15 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
|
||||
// TODO(srikanthccv): do we want to return an error here?
|
||||
// should we infer the type and auto-magically build a key for expression?
|
||||
v.errors = append(v.errors, fmt.Sprintf("key `%s` not found", fieldKey.Name))
|
||||
v.mainErrorURL = "https://signoz.io/docs/userguide/search-troubleshooting/#key-fieldname-not-found"
|
||||
}
|
||||
}
|
||||
|
||||
if len(fieldKeysForName) > 1 && !v.keysWithWarnings[keyName] {
|
||||
v.mainWarnURL = "https://signoz.io/docs/userguide/field-context-data-types/"
|
||||
// this is warning state, we must have a unambiguous key
|
||||
v.warnings = append(v.warnings, fmt.Sprintf(
|
||||
"key `%s` is ambiguous, found %d different combinations of field context and data type: %v",
|
||||
"key `%s` is ambiguous, found %d different combinations of field context / data type: %v",
|
||||
fieldKey.Name,
|
||||
len(fieldKeysForName),
|
||||
fieldKeysForName,
|
||||
|
||||
@@ -161,7 +161,7 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("%s: %s", tc.category, limitString(tc.query, 50)), func(t *testing.T) {
|
||||
|
||||
clause, _, err := querybuilder.PrepareWhereClause(tc.query, opts)
|
||||
clause, err := querybuilder.PrepareWhereClause(tc.query, opts)
|
||||
|
||||
if tc.shouldPass {
|
||||
if err != nil {
|
||||
@@ -175,7 +175,7 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
// Build the SQL and print it for debugging
|
||||
sql, args := clause.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
sql, args := clause.WhereClause.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
require.Equal(t, tc.expectedQuery, sql)
|
||||
require.Equal(t, tc.expectedArgs, args)
|
||||
|
||||
@@ -2297,7 +2297,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("%s: %s", tc.category, limitString(tc.query, 50)), func(t *testing.T) {
|
||||
|
||||
clause, _, err := querybuilder.PrepareWhereClause(tc.query, opts)
|
||||
clause, err := querybuilder.PrepareWhereClause(tc.query, opts)
|
||||
|
||||
if tc.shouldPass {
|
||||
if err != nil {
|
||||
@@ -2311,7 +2311,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
}
|
||||
|
||||
// Build the SQL and print it for debugging
|
||||
sql, args := clause.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
sql, args := clause.WhereClause.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
require.Equal(t, tc.expectedQuery, sql)
|
||||
require.Equal(t, tc.expectedArgs, args)
|
||||
|
||||
@@ -228,7 +228,8 @@ func (b *logQueryStatementBuilder) buildListQuery(
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
|
||||
|
||||
// Add filter conditions
|
||||
warnings, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -258,11 +259,16 @@ func (b *logQueryStatementBuilder) buildListQuery(
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
return &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
@@ -322,7 +328,8 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
}
|
||||
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
|
||||
warnings, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -401,11 +408,16 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
finalArgs = querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
}
|
||||
|
||||
return &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
// buildScalarQuery builds a query for scalar panel type
|
||||
@@ -469,7 +481,8 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
|
||||
|
||||
// Add filter conditions
|
||||
warnings, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -511,11 +524,16 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
return &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
// buildFilterCondition builds SQL condition from filter expression
|
||||
@@ -526,15 +544,14 @@ func (b *logQueryStatementBuilder) addFilterCondition(
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) ([]string, error) {
|
||||
) (*querybuilder.PreparedWhereClause, error) {
|
||||
|
||||
var filterWhereClause *sqlbuilder.WhereClause
|
||||
var warnings []string
|
||||
var preparedWhereClause *querybuilder.PreparedWhereClause
|
||||
var err error
|
||||
|
||||
if query.Filter != nil && query.Filter.Expression != "" {
|
||||
// add filter expression
|
||||
filterWhereClause, warnings, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
|
||||
preparedWhereClause, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
|
||||
FieldMapper: b.fm,
|
||||
ConditionBuilder: b.cb,
|
||||
FieldKeys: keys,
|
||||
@@ -550,8 +567,8 @@ func (b *logQueryStatementBuilder) addFilterCondition(
|
||||
}
|
||||
}
|
||||
|
||||
if filterWhereClause != nil {
|
||||
sb.AddWhereClause(filterWhereClause)
|
||||
if preparedWhereClause != nil {
|
||||
sb.AddWhereClause(preparedWhereClause.WhereClause)
|
||||
}
|
||||
|
||||
// add time filter
|
||||
@@ -560,7 +577,7 @@ func (b *logQueryStatementBuilder) addFilterCondition(
|
||||
|
||||
sb.Where(sb.GE("timestamp", fmt.Sprintf("%d", start)), sb.L("timestamp", fmt.Sprintf("%d", end)), sb.GE("ts_bucket_start", startBucket), sb.LE("ts_bucket_start", endBucket))
|
||||
|
||||
return warnings, nil
|
||||
return preparedWhereClause, nil
|
||||
}
|
||||
|
||||
func aggOrderBy(k qbtypes.OrderBy, q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) (int, bool) {
|
||||
|
||||
@@ -245,3 +245,112 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatementBuilderListQueryResourceTests(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
expected qbtypes.Statement
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
name: "List with full text search",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "hello",
|
||||
},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND match(body, ?) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "hello", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "list query with mat col order by",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "service.name = 'cartservice' hello",
|
||||
},
|
||||
Limit: 10,
|
||||
Order: []qbtypes.OrderBy{
|
||||
{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "materialized.key.name",
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (match(body, ?)) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY `attribute_string_materialized$$key$$name` AS `materialized.key.name` desc LIMIT ?",
|
||||
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "hello", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "List with json search",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "body.status = 'success'",
|
||||
},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (JSON_VALUE(body, '$.\"status\"') = ? AND JSON_EXISTS(body, '$.\"status\"')) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "success", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
cb := NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
|
||||
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(nil, fm, cb, "", nil)
|
||||
|
||||
resourceFilterStmtBuilder := resourceFilterStmtBuilder()
|
||||
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
fm,
|
||||
cb,
|
||||
resourceFilterStmtBuilder,
|
||||
aggExprRewriter,
|
||||
DefaultFullTextColumn,
|
||||
BodyJSONStringSearchPrefix,
|
||||
GetBodyJSONKey,
|
||||
)
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
||||
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
|
||||
|
||||
if c.expectedErr != nil {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), c.expectedErr.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.expected.Query, q.Query)
|
||||
require.Equal(t, c.expected.Args, q.Args)
|
||||
require.Equal(t, c.expected.Warnings, q.Warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,13 +701,13 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, fieldValueSel
|
||||
return nil, err
|
||||
}
|
||||
|
||||
whereClause, _, err := querybuilder.PrepareWhereClause(fieldValueSelector.ExistingQuery, querybuilder.FilterExprVisitorOpts{
|
||||
whereClause, err := querybuilder.PrepareWhereClause(fieldValueSelector.ExistingQuery, querybuilder.FilterExprVisitorOpts{
|
||||
FieldMapper: t.fm,
|
||||
ConditionBuilder: t.conditionBuilder,
|
||||
FieldKeys: keys,
|
||||
})
|
||||
if err == nil {
|
||||
sb.AddWhereClause(whereClause)
|
||||
sb.AddWhereClause(whereClause.WhereClause)
|
||||
} else {
|
||||
t.logger.WarnContext(ctx, "error parsing existing query for related values", "error", err)
|
||||
}
|
||||
|
||||
@@ -289,11 +289,11 @@ func (b *metricQueryStatementBuilder) buildTimeSeriesCTE(
|
||||
) (string, []any, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
var filterWhere *sqlbuilder.WhereClause
|
||||
var preparedWhereClause *querybuilder.PreparedWhereClause
|
||||
var err error
|
||||
|
||||
if query.Filter != nil && query.Filter.Expression != "" {
|
||||
filterWhere, _, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
|
||||
preparedWhereClause, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
|
||||
FieldMapper: b.fm,
|
||||
ConditionBuilder: b.cb,
|
||||
FieldKeys: keys,
|
||||
@@ -332,8 +332,8 @@ func (b *metricQueryStatementBuilder) buildTimeSeriesCTE(
|
||||
sb.EQ("__normalized", false),
|
||||
)
|
||||
|
||||
if filterWhere != nil {
|
||||
sb.AddWhereClause(filterWhere)
|
||||
if preparedWhereClause != nil {
|
||||
sb.AddWhereClause(preparedWhereClause.WhereClause)
|
||||
}
|
||||
|
||||
sb.GroupBy("fingerprint")
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestSpanScopeFilterExpression(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
}}
|
||||
|
||||
whereClause, _, err := querybuilder.PrepareWhereClause(tt.expression, querybuilder.FilterExprVisitorOpts{
|
||||
whereClause, err := querybuilder.PrepareWhereClause(tt.expression, querybuilder.FilterExprVisitorOpts{
|
||||
FieldMapper: fm,
|
||||
ConditionBuilder: cb,
|
||||
FieldKeys: fieldKeys,
|
||||
@@ -77,7 +77,7 @@ func TestSpanScopeFilterExpression(t *testing.T) {
|
||||
require.NotNil(t, whereClause)
|
||||
|
||||
// Apply the where clause to the builder and get the SQL
|
||||
sb.AddWhereClause(whereClause)
|
||||
sb.AddWhereClause(whereClause.WhereClause)
|
||||
whereSQL, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
t.Logf("Generated SQL: %s", whereSQL)
|
||||
assert.Contains(t, whereSQL, tt.expectedCondition)
|
||||
@@ -129,7 +129,7 @@ func TestSpanScopeWithResourceFilter(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
}}
|
||||
|
||||
_, _, err := querybuilder.PrepareWhereClause(tt.expression, querybuilder.FilterExprVisitorOpts{
|
||||
_, err := querybuilder.PrepareWhereClause(tt.expression, querybuilder.FilterExprVisitorOpts{
|
||||
FieldMapper: fm,
|
||||
ConditionBuilder: cb,
|
||||
FieldKeys: fieldKeys,
|
||||
|
||||
@@ -303,7 +303,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
|
||||
|
||||
// Add filter conditions
|
||||
warnings, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -333,11 +333,16 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
return &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
func (b *traceQueryStatementBuilder) buildTraceQuery(
|
||||
@@ -369,7 +374,7 @@ func (b *traceQueryStatementBuilder) buildTraceQuery(
|
||||
}
|
||||
|
||||
// Add filter conditions
|
||||
warnings, err := b.addFilterCondition(ctx, distSB, start, end, query, keys, variables)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, distSB, start, end, query, keys, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -441,11 +446,16 @@ func (b *traceQueryStatementBuilder) buildTraceQuery(
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL + " SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000"
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
return &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
@@ -505,7 +515,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
}
|
||||
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
|
||||
warnings, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -581,11 +591,16 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
finalArgs = querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
}
|
||||
|
||||
return &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
// buildScalarQuery builds a query for scalar panel type
|
||||
@@ -649,7 +664,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
|
||||
|
||||
// Add filter conditions
|
||||
warnings, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -691,11 +706,16 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
return &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
// buildFilterCondition builds SQL condition from filter expression
|
||||
@@ -706,15 +726,14 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) ([]string, error) {
|
||||
) (*querybuilder.PreparedWhereClause, error) {
|
||||
|
||||
var filterWhereClause *sqlbuilder.WhereClause
|
||||
var warnings []string
|
||||
var preparedWhereClause *querybuilder.PreparedWhereClause
|
||||
var err error
|
||||
|
||||
if query.Filter != nil && query.Filter.Expression != "" {
|
||||
// add filter expression
|
||||
filterWhereClause, warnings, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
|
||||
preparedWhereClause, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
|
||||
FieldMapper: b.fm,
|
||||
ConditionBuilder: b.cb,
|
||||
FieldKeys: keys,
|
||||
@@ -727,8 +746,8 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
|
||||
}
|
||||
}
|
||||
|
||||
if filterWhereClause != nil {
|
||||
sb.AddWhereClause(filterWhereClause)
|
||||
if preparedWhereClause != nil {
|
||||
sb.AddWhereClause(preparedWhereClause.WhereClause)
|
||||
}
|
||||
|
||||
// add time filter
|
||||
@@ -737,7 +756,7 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
|
||||
|
||||
sb.Where(sb.GE("timestamp", fmt.Sprintf("%d", start)), sb.L("timestamp", fmt.Sprintf("%d", end)), sb.GE("ts_bucket_start", startBucket), sb.LE("ts_bucket_start", endBucket))
|
||||
|
||||
return warnings, nil
|
||||
return preparedWhereClause, nil
|
||||
}
|
||||
|
||||
func aggOrderBy(k qbtypes.OrderBy, q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) (int, bool) {
|
||||
|
||||
@@ -64,6 +64,35 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "OR b/w resource attr and attribute",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "service.name = 'redis-manual' OR http.request.method = 'GET'",
|
||||
},
|
||||
Limit: 10,
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) OR true) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(mapContains(resources_string, 'service.name') = ?, resources_string['service.name'], NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((attributes_string['http.request.method'] = ? AND mapContains(attributes_string, 'http.request.method') = ?)) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(mapContains(resources_string, 'service.name') = ?, resources_string['service.name'], NULL)) AS `service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((attributes_string['http.request.method'] = ? AND mapContains(attributes_string, 'http.request.method') = ?)) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), true, "GET", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, true, "GET", true, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "legacy httpRoute in group by",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
@@ -128,7 +157,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (true AND true AND true) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(attribute_string_http$$route <> ?, attribute_string_http$$route, NULL)) AS `httpRoute`, toString(multiIf(http_method <> ?, http_method, NULL)) AS `httpMethod`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((resource_string_service$$name = ? AND resource_string_service$$name <> ?) AND http_method <> ? AND kind_string = ?) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `httpRoute`, `httpMethod` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(attribute_string_http$$route <> ?, attribute_string_http$$route, NULL)) AS `httpRoute`, toString(multiIf(http_method <> ?, http_method, NULL)) AS `httpMethod`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((resource_string_service$$name = ? AND resource_string_service$$name <> ?) AND http_method <> ? AND kind_string = ?) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`httpRoute`, `httpMethod`) GLOBAL IN (SELECT `httpRoute`, `httpMethod` FROM __limit_cte) GROUP BY ts, `httpRoute`, `httpMethod`",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(attribute_string_http$$route <> ?, attribute_string_http$$route, NULL)) AS `httpRoute`, toString(multiIf(http_method <> ?, http_method, NULL)) AS `httpMethod`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((resource_string_service$$name = ? AND resource_string_service$$name <> ?) AND http_method <> ? AND kind_string = ?) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `httpRoute`, `httpMethod` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(attribute_string_http$$route <> ?, attribute_string_http$$route, NULL)) AS `httpRoute`, toString(multiIf(http_method <> ?, http_method, NULL)) AS `httpMethod`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((resource_string_service$$name = ? AND resource_string_service$$name <> ?) AND http_method <> ? AND kind_string = ?) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`httpRoute`, `httpMethod`) GLOBAL IN (SELECT `httpRoute`, `httpMethod` FROM __limit_cte) GROUP BY ts, `httpRoute`, `httpMethod`",
|
||||
Args: []any{uint64(1747945619), uint64(1747983448), "", "", "redis-manual", "", "", "Server", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "", "", "redis-manual", "", "", "Server", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
|
||||
@@ -47,8 +47,7 @@ func (s *Step) UnmarshalJSON(b []byte) error {
|
||||
}
|
||||
|
||||
func (s Step) MarshalJSON() ([]byte, error) {
|
||||
// Emit human‑friendly string → "30s"
|
||||
return json.Marshal(s.Duration.String())
|
||||
return json.Marshal(s.Duration.Seconds())
|
||||
}
|
||||
|
||||
// FilterOperator is the operator for the filter.
|
||||
|
||||
@@ -37,7 +37,7 @@ type QueryBuilderQuery[T any] struct {
|
||||
Limit int `json:"limit,omitempty"`
|
||||
|
||||
// limitBy fields to limit by
|
||||
LimitBy LimitBy `json:"limitBy,omitempty"`
|
||||
LimitBy *LimitBy `json:"limitBy,omitempty"`
|
||||
|
||||
// offset the number of rows to skip
|
||||
// TODO: remove this once we have cursor-based pagination everywhere?
|
||||
|
||||
@@ -41,9 +41,10 @@ type AggExprRewriter interface {
|
||||
}
|
||||
|
||||
type Statement struct {
|
||||
Query string
|
||||
Args []any
|
||||
Warnings []string
|
||||
Query string
|
||||
Args []any
|
||||
Warnings []string
|
||||
WarningsDocURL string
|
||||
}
|
||||
|
||||
// StatementBuilder builds the query.
|
||||
|
||||
@@ -15,10 +15,11 @@ type Query interface {
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Type RequestType
|
||||
Value any // concrete Go value (to be type asserted based on the RequestType)
|
||||
Stats ExecStats
|
||||
Warnings []string
|
||||
Type RequestType
|
||||
Value any // concrete Go value (to be type asserted based on the RequestType)
|
||||
Stats ExecStats
|
||||
Warnings []string
|
||||
WarningsDocURL string
|
||||
}
|
||||
|
||||
type ExecStats struct {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -26,11 +27,27 @@ type QBEvent struct {
|
||||
HasData bool `json:"-"`
|
||||
}
|
||||
|
||||
type QueryWarnData struct {
|
||||
Message string `json:"message"`
|
||||
Url string `json:"url,omitempty"`
|
||||
Warnings []QueryWarnDataAdditional `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type QueryWarnDataAdditional struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type QueryData struct {
|
||||
Results []any `json:"results"`
|
||||
}
|
||||
|
||||
type QueryRangeResponse struct {
|
||||
Type RequestType `json:"type"`
|
||||
Data any `json:"data"`
|
||||
Data QueryData `json:"data"`
|
||||
Meta ExecStats `json:"meta"`
|
||||
|
||||
Warning QueryWarnData `json:"warning,omitempty"`
|
||||
|
||||
QBEvent *QBEvent `json:"-"`
|
||||
}
|
||||
|
||||
@@ -168,6 +185,41 @@ type RawRow struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
func roundToNonZeroDecimals(val float64, n int) float64 {
|
||||
if val == 0 || math.IsNaN(val) || math.IsInf(val, 0) {
|
||||
return val
|
||||
}
|
||||
|
||||
absVal := math.Abs(val)
|
||||
|
||||
// For numbers >= 1, we want to round to n decimal places total
|
||||
if absVal >= 1 {
|
||||
// Round to n decimal places
|
||||
multiplier := math.Pow(10, float64(n))
|
||||
rounded := math.Round(val*multiplier) / multiplier
|
||||
|
||||
// If the result is a whole number, return it as such
|
||||
if rounded == math.Trunc(rounded) {
|
||||
return rounded
|
||||
}
|
||||
|
||||
// Remove trailing zeros by converting to string and back
|
||||
str := strconv.FormatFloat(rounded, 'f', -1, 64)
|
||||
result, _ := strconv.ParseFloat(str, 64)
|
||||
return result
|
||||
}
|
||||
|
||||
// For numbers < 1, count n significant figures after first non-zero digit
|
||||
order := math.Floor(math.Log10(absVal))
|
||||
scale := math.Pow(10, -order+float64(n)-1)
|
||||
rounded := math.Round(val*scale) / scale
|
||||
|
||||
// Clean up floating point precision
|
||||
str := strconv.FormatFloat(rounded, 'f', -1, 64)
|
||||
result, _ := strconv.ParseFloat(str, 64)
|
||||
return result
|
||||
}
|
||||
|
||||
func sanitizeValue(v any) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
@@ -181,7 +233,7 @@ func sanitizeValue(v any) any {
|
||||
} else if math.IsInf(f, -1) {
|
||||
return "-Inf"
|
||||
}
|
||||
return f
|
||||
return roundToNonZeroDecimals(f, 3)
|
||||
}
|
||||
|
||||
if f, ok := v.(float32); ok {
|
||||
@@ -193,7 +245,7 @@ func sanitizeValue(v any) any {
|
||||
} else if math.IsInf(f64, -1) {
|
||||
return "-Inf"
|
||||
}
|
||||
return f
|
||||
return float32(roundToNonZeroDecimals(f64, 3)) // ADD ROUNDING HERE
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(v)
|
||||
|
||||
@@ -175,7 +175,7 @@ func (rc *RuleCondition) IsValid() bool {
|
||||
}
|
||||
if rc.QueryType() == v3.QueryTypePromQL {
|
||||
|
||||
if len(rc.CompositeQuery.PromQueries) == 0 {
|
||||
if len(rc.CompositeQuery.PromQueries) == 0 && len(rc.CompositeQuery.Queries) == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
528
pkg/variables/variable_replace_visitor.go
Normal file
528
pkg/variables/variable_replace_visitor.go
Normal file
@@ -0,0 +1,528 @@
|
||||
package variables
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/grammar"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/antlr4-go/antlr/v4"
|
||||
)
|
||||
|
||||
// ErrorListener collects syntax errors during parsing
|
||||
type ErrorListener struct {
|
||||
*antlr.DefaultErrorListener
|
||||
SyntaxErrors []error
|
||||
}
|
||||
|
||||
// NewErrorListener creates a new error listener
|
||||
func NewErrorListener() *ErrorListener {
|
||||
return &ErrorListener{
|
||||
DefaultErrorListener: antlr.NewDefaultErrorListener(),
|
||||
SyntaxErrors: []error{},
|
||||
}
|
||||
}
|
||||
|
||||
// SyntaxError is called when a syntax error is encountered
|
||||
func (e *ErrorListener) SyntaxError(recognizer antlr.Recognizer, offendingSymbol any, line, column int, msg string, ex antlr.RecognitionException) {
|
||||
e.SyntaxErrors = append(e.SyntaxErrors, fmt.Errorf("line %d:%d %s", line, column, msg))
|
||||
}
|
||||
|
||||
// variableReplacementVisitor implements the visitor interface
|
||||
// to replace variables in filter expressions with their actual values
|
||||
type variableReplacementVisitor struct {
|
||||
variables map[string]qbtypes.VariableItem
|
||||
errors []string
|
||||
}
|
||||
|
||||
// specialSkipMarker is used to indicate that a condition should be removed
|
||||
const specialSkipMarker = "__SKIP_CONDITION__"
|
||||
|
||||
// ReplaceVariablesInExpression takes a filter expression and returns it with variables replaced
|
||||
func ReplaceVariablesInExpression(expression string, variables map[string]qbtypes.VariableItem) (string, error) {
|
||||
input := antlr.NewInputStream(expression)
|
||||
lexer := grammar.NewFilterQueryLexer(input)
|
||||
|
||||
visitor := &variableReplacementVisitor{
|
||||
variables: variables,
|
||||
errors: []string{},
|
||||
}
|
||||
|
||||
lexerErrorListener := NewErrorListener()
|
||||
lexer.RemoveErrorListeners()
|
||||
lexer.AddErrorListener(lexerErrorListener)
|
||||
|
||||
tokens := antlr.NewCommonTokenStream(lexer, 0)
|
||||
parserErrorListener := NewErrorListener()
|
||||
parser := grammar.NewFilterQueryParser(tokens)
|
||||
parser.RemoveErrorListeners()
|
||||
parser.AddErrorListener(parserErrorListener)
|
||||
|
||||
tree := parser.Query()
|
||||
|
||||
if len(parserErrorListener.SyntaxErrors) > 0 {
|
||||
return "", fmt.Errorf("syntax errors in expression: %v", parserErrorListener.SyntaxErrors)
|
||||
}
|
||||
|
||||
result := visitor.Visit(tree).(string)
|
||||
|
||||
if len(visitor.errors) > 0 {
|
||||
return "", fmt.Errorf("errors processing expression: %v", visitor.errors)
|
||||
}
|
||||
|
||||
// If the entire expression should be skipped, return empty string
|
||||
if result == specialSkipMarker {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Visit dispatches to the specific visit method based on node type
|
||||
func (v *variableReplacementVisitor) Visit(tree antlr.ParseTree) any {
|
||||
if tree == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch t := tree.(type) {
|
||||
case *grammar.QueryContext:
|
||||
return v.VisitQuery(t)
|
||||
case *grammar.ExpressionContext:
|
||||
return v.VisitExpression(t)
|
||||
case *grammar.OrExpressionContext:
|
||||
return v.VisitOrExpression(t)
|
||||
case *grammar.AndExpressionContext:
|
||||
return v.VisitAndExpression(t)
|
||||
case *grammar.UnaryExpressionContext:
|
||||
return v.VisitUnaryExpression(t)
|
||||
case *grammar.PrimaryContext:
|
||||
return v.VisitPrimary(t)
|
||||
case *grammar.ComparisonContext:
|
||||
return v.VisitComparison(t)
|
||||
case *grammar.InClauseContext:
|
||||
return v.VisitInClause(t)
|
||||
case *grammar.NotInClauseContext:
|
||||
return v.VisitNotInClause(t)
|
||||
case *grammar.ValueListContext:
|
||||
return v.VisitValueList(t)
|
||||
case *grammar.FullTextContext:
|
||||
return v.VisitFullText(t)
|
||||
case *grammar.FunctionCallContext:
|
||||
return v.VisitFunctionCall(t)
|
||||
case *grammar.FunctionParamListContext:
|
||||
return v.VisitFunctionParamList(t)
|
||||
case *grammar.FunctionParamContext:
|
||||
return v.VisitFunctionParam(t)
|
||||
case *grammar.ArrayContext:
|
||||
return v.VisitArray(t)
|
||||
case *grammar.ValueContext:
|
||||
return v.VisitValue(t)
|
||||
case *grammar.KeyContext:
|
||||
return v.VisitKey(t)
|
||||
default:
|
||||
// For unknown types, return the original text
|
||||
return tree.GetText()
|
||||
}
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitQuery(ctx *grammar.QueryContext) any {
|
||||
return v.Visit(ctx.Expression())
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitExpression(ctx *grammar.ExpressionContext) any {
|
||||
return v.Visit(ctx.OrExpression())
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitOrExpression(ctx *grammar.OrExpressionContext) any {
|
||||
andExpressions := ctx.AllAndExpression()
|
||||
|
||||
parts := make([]string, 0, len(andExpressions))
|
||||
for _, expr := range andExpressions {
|
||||
part := v.Visit(expr).(string)
|
||||
// Skip conditions that should be removed
|
||||
if part != specialSkipMarker && part != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return specialSkipMarker
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
return strings.Join(parts, " OR ")
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitAndExpression(ctx *grammar.AndExpressionContext) any {
|
||||
unaryExpressions := ctx.AllUnaryExpression()
|
||||
|
||||
parts := make([]string, 0, len(unaryExpressions))
|
||||
for _, expr := range unaryExpressions {
|
||||
part := v.Visit(expr).(string)
|
||||
// Skip conditions that should be removed
|
||||
if part != specialSkipMarker && part != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return specialSkipMarker
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
return strings.Join(parts, " AND ")
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitUnaryExpression(ctx *grammar.UnaryExpressionContext) any {
|
||||
result := v.Visit(ctx.Primary()).(string)
|
||||
|
||||
// If the condition should be skipped, propagate it up
|
||||
if result == specialSkipMarker {
|
||||
return specialSkipMarker
|
||||
}
|
||||
|
||||
if ctx.NOT() != nil {
|
||||
return "NOT " + result
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
|
||||
if ctx.OrExpression() != nil {
|
||||
return "(" + v.Visit(ctx.OrExpression()).(string) + ")"
|
||||
} else if ctx.Comparison() != nil {
|
||||
return v.Visit(ctx.Comparison())
|
||||
} else if ctx.FunctionCall() != nil {
|
||||
return v.Visit(ctx.FunctionCall())
|
||||
} else if ctx.FullText() != nil {
|
||||
return v.Visit(ctx.FullText())
|
||||
}
|
||||
|
||||
// Handle standalone key/value
|
||||
if ctx.GetChildCount() == 1 {
|
||||
child := ctx.GetChild(0)
|
||||
if parseTree, ok := child.(antlr.ParseTree); ok {
|
||||
return v.Visit(parseTree).(string)
|
||||
}
|
||||
// Fallback to getting text from the context
|
||||
return ctx.GetText()
|
||||
}
|
||||
|
||||
return ctx.GetText()
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitComparison(ctx *grammar.ComparisonContext) any {
|
||||
// First check if any value contains __all__ variable
|
||||
values := ctx.AllValue()
|
||||
for _, val := range values {
|
||||
valueResult := v.Visit(val).(string)
|
||||
if valueResult == specialSkipMarker {
|
||||
return specialSkipMarker
|
||||
}
|
||||
}
|
||||
|
||||
// Also check in IN/NOT IN clauses
|
||||
if ctx.InClause() != nil {
|
||||
inResult := v.Visit(ctx.InClause()).(string)
|
||||
if inResult == specialSkipMarker {
|
||||
return specialSkipMarker
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.NotInClause() != nil {
|
||||
notInResult := v.Visit(ctx.NotInClause()).(string)
|
||||
if notInResult == specialSkipMarker {
|
||||
return specialSkipMarker
|
||||
}
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
// Add key
|
||||
parts = append(parts, v.Visit(ctx.Key()).(string))
|
||||
|
||||
// Handle EXISTS
|
||||
if ctx.EXISTS() != nil {
|
||||
if ctx.NOT() != nil {
|
||||
parts = append(parts, " NOT")
|
||||
}
|
||||
parts = append(parts, " EXISTS")
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
// Handle IN/NOT IN
|
||||
if ctx.InClause() != nil {
|
||||
parts = append(parts, " IN ")
|
||||
parts = append(parts, v.Visit(ctx.InClause()).(string))
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
if ctx.NotInClause() != nil {
|
||||
parts = append(parts, " NOT IN ")
|
||||
parts = append(parts, v.Visit(ctx.NotInClause()).(string))
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
// Handle BETWEEN
|
||||
if ctx.BETWEEN() != nil {
|
||||
if ctx.NOT() != nil {
|
||||
parts = append(parts, " NOT")
|
||||
}
|
||||
parts = append(parts, " BETWEEN ")
|
||||
values := ctx.AllValue()
|
||||
parts = append(parts, v.Visit(values[0]).(string))
|
||||
parts = append(parts, " AND ")
|
||||
parts = append(parts, v.Visit(values[1]).(string))
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
// Handle other operators
|
||||
if ctx.EQUALS() != nil {
|
||||
parts = append(parts, " = ")
|
||||
} else if ctx.NOT_EQUALS() != nil {
|
||||
parts = append(parts, " != ")
|
||||
} else if ctx.NEQ() != nil {
|
||||
parts = append(parts, " <> ")
|
||||
} else if ctx.LT() != nil {
|
||||
parts = append(parts, " < ")
|
||||
} else if ctx.LE() != nil {
|
||||
parts = append(parts, " <= ")
|
||||
} else if ctx.GT() != nil {
|
||||
parts = append(parts, " > ")
|
||||
} else if ctx.GE() != nil {
|
||||
parts = append(parts, " >= ")
|
||||
} else if ctx.LIKE() != nil {
|
||||
parts = append(parts, " LIKE ")
|
||||
} else if ctx.ILIKE() != nil {
|
||||
parts = append(parts, " ILIKE ")
|
||||
} else if ctx.NOT_LIKE() != nil {
|
||||
parts = append(parts, " NOT LIKE ")
|
||||
} else if ctx.NOT_ILIKE() != nil {
|
||||
parts = append(parts, " NOT ILIKE ")
|
||||
} else if ctx.REGEXP() != nil {
|
||||
if ctx.NOT() != nil {
|
||||
parts = append(parts, " NOT")
|
||||
}
|
||||
parts = append(parts, " REGEXP ")
|
||||
} else if ctx.CONTAINS() != nil {
|
||||
if ctx.NOT() != nil {
|
||||
parts = append(parts, " NOT")
|
||||
}
|
||||
parts = append(parts, " CONTAINS ")
|
||||
}
|
||||
|
||||
// Add value
|
||||
if len(values) > 0 {
|
||||
parts = append(parts, v.Visit(values[0]).(string))
|
||||
}
|
||||
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitInClause(ctx *grammar.InClauseContext) any {
|
||||
if ctx.ValueList() != nil {
|
||||
result := v.Visit(ctx.ValueList()).(string)
|
||||
if result == specialSkipMarker {
|
||||
return specialSkipMarker
|
||||
}
|
||||
return result
|
||||
}
|
||||
result := v.Visit(ctx.Value()).(string)
|
||||
if result == specialSkipMarker {
|
||||
return specialSkipMarker
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitNotInClause(ctx *grammar.NotInClauseContext) any {
|
||||
if ctx.ValueList() != nil {
|
||||
result := v.Visit(ctx.ValueList()).(string)
|
||||
if result == specialSkipMarker {
|
||||
return specialSkipMarker
|
||||
}
|
||||
return result
|
||||
}
|
||||
result := v.Visit(ctx.Value()).(string)
|
||||
if result == specialSkipMarker {
|
||||
return specialSkipMarker
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitValueList(ctx *grammar.ValueListContext) any {
|
||||
values := ctx.AllValue()
|
||||
|
||||
// Check if any value is __all__
|
||||
for _, val := range values {
|
||||
result := v.Visit(val).(string)
|
||||
if result == specialSkipMarker {
|
||||
return specialSkipMarker
|
||||
}
|
||||
}
|
||||
|
||||
parts := make([]string, 0, len(values))
|
||||
for i, val := range values {
|
||||
if i > 0 {
|
||||
parts = append(parts, ", ")
|
||||
}
|
||||
parts = append(parts, v.Visit(val).(string))
|
||||
}
|
||||
|
||||
return "(" + strings.Join(parts, "") + ")"
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitFullText(ctx *grammar.FullTextContext) any {
|
||||
if ctx.QUOTED_TEXT() != nil {
|
||||
return ctx.QUOTED_TEXT().GetText()
|
||||
} else if ctx.FREETEXT() != nil {
|
||||
return ctx.FREETEXT().GetText()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitFunctionCall(ctx *grammar.FunctionCallContext) any {
|
||||
var functionName string
|
||||
if ctx.HAS() != nil {
|
||||
functionName = "has"
|
||||
} else if ctx.HASANY() != nil {
|
||||
functionName = "hasAny"
|
||||
} else if ctx.HASALL() != nil {
|
||||
functionName = "hasAll"
|
||||
}
|
||||
|
||||
params := v.Visit(ctx.FunctionParamList()).(string)
|
||||
return functionName + "(" + params + ")"
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitFunctionParamList(ctx *grammar.FunctionParamListContext) any {
|
||||
params := ctx.AllFunctionParam()
|
||||
parts := make([]string, 0, len(params))
|
||||
|
||||
for i, param := range params {
|
||||
if i > 0 {
|
||||
parts = append(parts, ", ")
|
||||
}
|
||||
parts = append(parts, v.Visit(param).(string))
|
||||
}
|
||||
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitFunctionParam(ctx *grammar.FunctionParamContext) any {
|
||||
if ctx.Key() != nil {
|
||||
return v.Visit(ctx.Key())
|
||||
} else if ctx.Value() != nil {
|
||||
return v.Visit(ctx.Value())
|
||||
} else if ctx.Array() != nil {
|
||||
return v.Visit(ctx.Array())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitArray(ctx *grammar.ArrayContext) any {
|
||||
valueList := v.Visit(ctx.ValueList()).(string)
|
||||
// Don't wrap in brackets if it's already wrapped in parentheses
|
||||
if strings.HasPrefix(valueList, "(") {
|
||||
return valueList
|
||||
}
|
||||
return "[" + valueList + "]"
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitValue(ctx *grammar.ValueContext) any {
|
||||
// First get the original value
|
||||
var originalValue string
|
||||
if ctx.QUOTED_TEXT() != nil {
|
||||
originalValue = ctx.QUOTED_TEXT().GetText()
|
||||
} else if ctx.NUMBER() != nil {
|
||||
originalValue = ctx.NUMBER().GetText()
|
||||
} else if ctx.KEY() != nil {
|
||||
originalValue = ctx.KEY().GetText()
|
||||
}
|
||||
|
||||
// Check if this is a variable (starts with $)
|
||||
if strings.HasPrefix(originalValue, "$") {
|
||||
varName := originalValue
|
||||
|
||||
// Try with $ prefix first
|
||||
varItem, ok := v.variables[varName]
|
||||
if !ok && len(varName) > 1 {
|
||||
// Try without $ prefix
|
||||
varItem, ok = v.variables[varName[1:]]
|
||||
}
|
||||
|
||||
if ok {
|
||||
// Handle dynamic variable with __all__ value
|
||||
if varItem.Type == qbtypes.DynamicVariableType {
|
||||
if allVal, ok := varItem.Value.(string); ok && allVal == "__all__" {
|
||||
// Return special marker to indicate this condition should be removed
|
||||
return specialSkipMarker
|
||||
}
|
||||
}
|
||||
|
||||
// Replace variable with its value
|
||||
return v.formatVariableValue(varItem.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// Return original value if not a variable or variable not found
|
||||
return originalValue
|
||||
}
|
||||
|
||||
func (v *variableReplacementVisitor) VisitKey(ctx *grammar.KeyContext) any {
|
||||
keyText := ctx.GetText()
|
||||
|
||||
// Check if this key is actually a variable
|
||||
if strings.HasPrefix(keyText, "$") {
|
||||
varName := keyText
|
||||
|
||||
// Try with $ prefix first
|
||||
varItem, ok := v.variables[varName]
|
||||
if !ok && len(varName) > 1 {
|
||||
// Try without $ prefix
|
||||
varItem, ok = v.variables[varName[1:]]
|
||||
}
|
||||
|
||||
if ok {
|
||||
// Handle dynamic variable with __all__ value
|
||||
if varItem.Type == qbtypes.DynamicVariableType {
|
||||
if allVal, ok := varItem.Value.(string); ok && allVal == "__all__" {
|
||||
return specialSkipMarker
|
||||
}
|
||||
}
|
||||
// Replace variable with its value
|
||||
return v.formatVariableValue(varItem.Value)
|
||||
}
|
||||
}
|
||||
|
||||
return keyText
|
||||
}
|
||||
|
||||
// formatVariableValue formats a variable value for inclusion in the expression
|
||||
func (v *variableReplacementVisitor) formatVariableValue(value any) string {
|
||||
switch val := value.(type) {
|
||||
case string:
|
||||
// Quote string values
|
||||
return fmt.Sprintf("'%s'", strings.ReplaceAll(val, "'", "\\'"))
|
||||
case []any:
|
||||
// Format array values
|
||||
parts := make([]string, len(val))
|
||||
for i, item := range val {
|
||||
parts[i] = v.formatVariableValue(item)
|
||||
}
|
||||
return "(" + strings.Join(parts, ", ") + ")"
|
||||
case int, int32, int64, float32, float64:
|
||||
return fmt.Sprintf("%v", val)
|
||||
case bool:
|
||||
return strconv.FormatBool(val)
|
||||
default:
|
||||
return fmt.Sprintf("%v", val)
|
||||
}
|
||||
}
|
||||
463
pkg/variables/variable_replace_visitor_test.go
Normal file
463
pkg/variables/variable_replace_visitor_test.go
Normal file
@@ -0,0 +1,463 @@
|
||||
package variables
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestReplaceVariablesInExpression(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expression string
|
||||
variables map[string]qbtypes.VariableItem
|
||||
expected string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple string variable replacement",
|
||||
expression: "service.name = $service",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"service": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "auth-service",
|
||||
},
|
||||
},
|
||||
expected: "service.name = 'auth-service'",
|
||||
},
|
||||
{
|
||||
name: "simple string variable replacement",
|
||||
expression: "service.name = $service",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"service": {
|
||||
Type: qbtypes.QueryVariableType,
|
||||
Value: "auth-service",
|
||||
},
|
||||
},
|
||||
expected: "service.name = 'auth-service'",
|
||||
},
|
||||
{
|
||||
name: "simple string variable replacement",
|
||||
expression: "service.name = $service",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"service": {
|
||||
Type: qbtypes.CustomVariableType,
|
||||
Value: "auth-service",
|
||||
},
|
||||
},
|
||||
expected: "service.name = 'auth-service'",
|
||||
},
|
||||
{
|
||||
name: "simple string variable replacement",
|
||||
expression: "service.name = $service",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"service": {
|
||||
Type: qbtypes.TextBoxVariableType,
|
||||
Value: "auth-service",
|
||||
},
|
||||
},
|
||||
expected: "service.name = 'auth-service'",
|
||||
},
|
||||
{
|
||||
name: "variable with dollar sign prefix in map",
|
||||
expression: "service.name = $service",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"$service": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "auth-service",
|
||||
},
|
||||
},
|
||||
expected: "service.name = 'auth-service'",
|
||||
},
|
||||
{
|
||||
name: "numeric variable replacement",
|
||||
expression: "http.status_code > $threshold",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"threshold": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: 400,
|
||||
},
|
||||
},
|
||||
expected: "http.status_code > 400",
|
||||
},
|
||||
{
|
||||
name: "boolean variable replacement",
|
||||
expression: "is_error = $error_flag",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"error_flag": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: true,
|
||||
},
|
||||
},
|
||||
expected: "is_error = true",
|
||||
},
|
||||
{
|
||||
name: "array variable in IN clause",
|
||||
expression: "service.name IN $services",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"services": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: []any{"auth", "api", "web"},
|
||||
},
|
||||
},
|
||||
expected: "service.name IN ('auth', 'api', 'web')",
|
||||
},
|
||||
{
|
||||
name: "array variable with mixed types",
|
||||
expression: "id IN $ids",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"ids": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: []any{1, 2, "three", 4.5},
|
||||
},
|
||||
},
|
||||
expected: "id IN (1, 2, 'three', 4.5)",
|
||||
},
|
||||
{
|
||||
name: "multiple variables in expression",
|
||||
expression: "service.name = $service AND env = $environment AND status_code >= $min_code",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"service": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "auth-service",
|
||||
},
|
||||
"environment": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "production",
|
||||
},
|
||||
"min_code": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: 400,
|
||||
},
|
||||
},
|
||||
expected: "service.name = 'auth-service' AND env = 'production' AND status_code >= 400",
|
||||
},
|
||||
{
|
||||
name: "variable in complex expression with parentheses",
|
||||
expression: "(service.name = $service OR service.name = 'default') AND status_code > $threshold",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"service": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "auth",
|
||||
},
|
||||
"threshold": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: 200,
|
||||
},
|
||||
},
|
||||
expected: "(service.name = 'auth' OR service.name = 'default') AND status_code > 200",
|
||||
},
|
||||
{
|
||||
name: "variable not found - preserved as is",
|
||||
expression: "service.name = $unknown_service",
|
||||
variables: map[string]qbtypes.VariableItem{},
|
||||
expected: "service.name = $unknown_service",
|
||||
},
|
||||
{
|
||||
name: "string with quotes needs escaping",
|
||||
expression: "message = $msg",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"msg": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "user's request",
|
||||
},
|
||||
},
|
||||
expected: "message = 'user\\'s request'",
|
||||
},
|
||||
{
|
||||
name: "dynamic variable with __all__ value",
|
||||
expression: "service.name = $all_services",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"all_services": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "__all__",
|
||||
},
|
||||
},
|
||||
expected: "", // Special value preserved
|
||||
},
|
||||
{
|
||||
name: "variable in NOT IN clause",
|
||||
expression: "service.name NOT IN $excluded",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"excluded": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: []any{"test", "debug"},
|
||||
},
|
||||
},
|
||||
expected: "service.name NOT IN ('test', 'debug')",
|
||||
},
|
||||
{
|
||||
name: "variable in BETWEEN clause",
|
||||
expression: "latency BETWEEN $min AND $max",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"min": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: 100,
|
||||
},
|
||||
"max": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: 500,
|
||||
},
|
||||
},
|
||||
expected: "latency BETWEEN 100 AND 500",
|
||||
},
|
||||
{
|
||||
name: "variable in LIKE expression",
|
||||
expression: "service.name LIKE $pattern",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"pattern": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "%auth%",
|
||||
},
|
||||
},
|
||||
expected: "service.name LIKE '%auth%'",
|
||||
},
|
||||
{
|
||||
name: "variable in function call",
|
||||
expression: "has(tags, $tag)",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"tag": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "error",
|
||||
},
|
||||
},
|
||||
expected: "has(tags, 'error')",
|
||||
},
|
||||
{
|
||||
name: "variable in hasAny function",
|
||||
expression: "hasAny(tags, $tags)",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"tags": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: []any{"error", "warning", "info"},
|
||||
},
|
||||
},
|
||||
expected: "hasAny(tags, ('error', 'warning', 'info'))",
|
||||
},
|
||||
{
|
||||
name: "empty array variable",
|
||||
expression: "service.name IN $services",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"services": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: []any{},
|
||||
},
|
||||
},
|
||||
expected: "service.name IN ()",
|
||||
},
|
||||
{
|
||||
name: "expression with OR and variables",
|
||||
expression: "env = $env1 OR env = $env2",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"env1": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "staging",
|
||||
},
|
||||
"env2": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "production",
|
||||
},
|
||||
},
|
||||
expected: "env = 'staging' OR env = 'production'",
|
||||
},
|
||||
{
|
||||
name: "NOT expression with variable",
|
||||
expression: "NOT service.name = $service",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"service": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "test-service",
|
||||
},
|
||||
},
|
||||
expected: "NOT service.name = 'test-service'",
|
||||
},
|
||||
{
|
||||
name: "variable in EXISTS clause",
|
||||
expression: "tags EXISTS",
|
||||
variables: map[string]qbtypes.VariableItem{},
|
||||
expected: "tags EXISTS",
|
||||
},
|
||||
{
|
||||
name: "complex nested expression",
|
||||
expression: "(service.name IN $services AND env = $env) OR (status_code >= $error_code)",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"services": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: []any{"auth", "api"},
|
||||
},
|
||||
"env": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "prod",
|
||||
},
|
||||
"error_code": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: 500,
|
||||
},
|
||||
},
|
||||
expected: "(service.name IN ('auth', 'api') AND env = 'prod') OR (status_code >= 500)",
|
||||
},
|
||||
{
|
||||
name: "float variable",
|
||||
expression: "cpu_usage > $threshold",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"threshold": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: 85.5,
|
||||
},
|
||||
},
|
||||
expected: "cpu_usage > 85.5",
|
||||
},
|
||||
{
|
||||
name: "variable in REGEXP expression",
|
||||
expression: "message REGEXP $pattern",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"pattern": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "^ERROR.*",
|
||||
},
|
||||
},
|
||||
expected: "message REGEXP '^ERROR.*'",
|
||||
},
|
||||
{
|
||||
name: "variable in NOT REGEXP expression",
|
||||
expression: "message NOT REGEXP $pattern",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"pattern": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "^DEBUG.*",
|
||||
},
|
||||
},
|
||||
expected: "message NOT REGEXP '^DEBUG.*'",
|
||||
},
|
||||
{
|
||||
name: "invalid syntax",
|
||||
expression: "service.name = = $service",
|
||||
variables: map[string]qbtypes.VariableItem{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "full text search not affected by variables",
|
||||
expression: "'error message'",
|
||||
variables: map[string]qbtypes.VariableItem{},
|
||||
expected: "'error message'",
|
||||
},
|
||||
{
|
||||
name: "comparison operators",
|
||||
expression: "a < $v1 AND b <= $v2 AND c > $v3 AND d >= $v4 AND e != $v5 AND f <> $v6",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"v1": {Type: qbtypes.DynamicVariableType, Value: 10},
|
||||
"v2": {Type: qbtypes.DynamicVariableType, Value: 20},
|
||||
"v3": {Type: qbtypes.DynamicVariableType, Value: 30},
|
||||
"v4": {Type: qbtypes.DynamicVariableType, Value: 40},
|
||||
"v5": {Type: qbtypes.DynamicVariableType, Value: "test"},
|
||||
"v6": {Type: qbtypes.DynamicVariableType, Value: "other"},
|
||||
},
|
||||
expected: "a < 10 AND b <= 20 AND c > 30 AND d >= 40 AND e != 'test' AND f <> 'other'",
|
||||
},
|
||||
{
|
||||
name: "CONTAINS operator with variable",
|
||||
expression: "message CONTAINS $text",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"text": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "error",
|
||||
},
|
||||
},
|
||||
expected: "message CONTAINS 'error'",
|
||||
},
|
||||
{
|
||||
name: "NOT CONTAINS operator with variable",
|
||||
expression: "message NOT CONTAINS $text",
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"text": {
|
||||
Type: qbtypes.DynamicVariableType,
|
||||
Value: "debug",
|
||||
},
|
||||
},
|
||||
expected: "message NOT CONTAINS 'debug'",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ReplaceVariablesInExpression(tt.expression, tt.variables)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatVariableValue(t *testing.T) {
|
||||
visitor := &variableReplacementVisitor{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "string value",
|
||||
value: "hello",
|
||||
expected: "'hello'",
|
||||
},
|
||||
{
|
||||
name: "string with single quote",
|
||||
value: "user's data",
|
||||
expected: "'user\\'s data'",
|
||||
},
|
||||
{
|
||||
name: "integer value",
|
||||
value: 42,
|
||||
expected: "42",
|
||||
},
|
||||
{
|
||||
name: "float value",
|
||||
value: 3.14,
|
||||
expected: "3.14",
|
||||
},
|
||||
{
|
||||
name: "boolean true",
|
||||
value: true,
|
||||
expected: "true",
|
||||
},
|
||||
{
|
||||
name: "boolean false",
|
||||
value: false,
|
||||
expected: "false",
|
||||
},
|
||||
{
|
||||
name: "array of strings",
|
||||
value: []any{"a", "b", "c"},
|
||||
expected: "('a', 'b', 'c')",
|
||||
},
|
||||
{
|
||||
name: "array of mixed types",
|
||||
value: []any{"string", 123, true, 45.6},
|
||||
expected: "('string', 123, true, 45.6)",
|
||||
},
|
||||
{
|
||||
name: "empty array",
|
||||
value: []any{},
|
||||
expected: "()",
|
||||
},
|
||||
{
|
||||
name: "nil value",
|
||||
value: nil,
|
||||
expected: "<nil>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := visitor.formatVariableValue(tt.value)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user