mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-18 17:30:41 +01:00
Compare commits
1 Commits
feat/dashb
...
issue_6090
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dae0b975a |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -67,6 +67,7 @@ jobs:
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- tracedetail
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
|
||||
@@ -341,7 +341,7 @@ gateway:
|
||||
##################### Tokenizer #####################
|
||||
tokenizer:
|
||||
# Specifies the tokenizer provider to use.
|
||||
provider: opaque
|
||||
provider: jwt
|
||||
lifetime:
|
||||
# The duration for which a user can be idle before being required to authenticate.
|
||||
idle: 168h
|
||||
|
||||
@@ -384,49 +384,13 @@ components:
|
||||
required:
|
||||
- routingKey
|
||||
type: object
|
||||
AlertmanagertypesChannelSlackAction:
|
||||
properties:
|
||||
confirm:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackConfirmation'
|
||||
name:
|
||||
type: string
|
||||
style:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- text
|
||||
type: object
|
||||
AlertmanagertypesChannelSlackConfig:
|
||||
properties:
|
||||
actions:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackAction'
|
||||
type: array
|
||||
apiUrl:
|
||||
format: password
|
||||
type: string
|
||||
channel:
|
||||
type: string
|
||||
color:
|
||||
type: string
|
||||
fallback:
|
||||
type: string
|
||||
fields:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackField'
|
||||
type: array
|
||||
footer:
|
||||
type: string
|
||||
pretext:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
@@ -434,37 +398,9 @@ components:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
titleLink:
|
||||
type: string
|
||||
required:
|
||||
- apiUrl
|
||||
type: object
|
||||
AlertmanagertypesChannelSlackConfirmation:
|
||||
properties:
|
||||
dismissText:
|
||||
type: string
|
||||
okText:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
required:
|
||||
- text
|
||||
type: object
|
||||
AlertmanagertypesChannelSlackField:
|
||||
properties:
|
||||
short:
|
||||
nullable: true
|
||||
type: boolean
|
||||
title:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- title
|
||||
- value
|
||||
type: object
|
||||
AlertmanagertypesChannelWebhookConfig:
|
||||
properties:
|
||||
bearerToken:
|
||||
@@ -3274,53 +3210,6 @@ components:
|
||||
repeatVariable:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesAreaChartAppearance:
|
||||
properties:
|
||||
fillMode:
|
||||
$ref: '#/components/schemas/DashboardtypesAreaFillMode'
|
||||
fillOpacity:
|
||||
$ref: '#/components/schemas/DashboardtypesFillOpacity'
|
||||
lineInterpolation:
|
||||
$ref: '#/components/schemas/DashboardtypesLineInterpolation'
|
||||
lineStyle:
|
||||
$ref: '#/components/schemas/DashboardtypesLineStyle'
|
||||
showPoints:
|
||||
type: boolean
|
||||
spanGaps:
|
||||
$ref: '#/components/schemas/DashboardtypesSpanGaps'
|
||||
type: object
|
||||
DashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesAreaChartAppearance'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesAreaChartVisualization'
|
||||
type: object
|
||||
DashboardtypesAreaChartVisualization:
|
||||
properties:
|
||||
fillSpans:
|
||||
type: boolean
|
||||
stack:
|
||||
$ref: '#/components/schemas/DashboardtypesStackMode'
|
||||
timePreference:
|
||||
$ref: '#/components/schemas/DashboardtypesTimePreference'
|
||||
type: object
|
||||
DashboardtypesAreaFillMode:
|
||||
enum:
|
||||
- solid
|
||||
- gradient
|
||||
type: string
|
||||
DashboardtypesAxes:
|
||||
properties:
|
||||
isLogScale:
|
||||
@@ -3552,11 +3441,6 @@ components:
|
||||
- gradient
|
||||
- none
|
||||
type: string
|
||||
DashboardtypesFillOpacity:
|
||||
maximum: 1
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesGettableDashboardV2:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -4019,7 +3903,6 @@ components:
|
||||
DashboardtypesPanelPlugin:
|
||||
discriminator:
|
||||
mapping:
|
||||
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
@@ -4032,7 +3915,6 @@ components:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
@@ -4044,7 +3926,6 @@ components:
|
||||
enum:
|
||||
- signoz/TimeSeriesPanel
|
||||
- signoz/BarChartPanel
|
||||
- signoz/AreaChartPanel
|
||||
- signoz/NumberPanel
|
||||
- signoz/PieChartPanel
|
||||
- signoz/TablePanel
|
||||
@@ -4052,18 +3933,6 @@ components:
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/AreaChartPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesAreaChartPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4394,12 +4263,6 @@ components:
|
||||
are connected.
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesStackMode:
|
||||
enum:
|
||||
- none
|
||||
- normal
|
||||
- percent
|
||||
type: string
|
||||
DashboardtypesStorableDashboardData:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
@@ -9576,6 +9439,29 @@ components:
|
||||
required:
|
||||
- aggregations
|
||||
type: object
|
||||
SpantypesGettableTraceSummary:
|
||||
properties:
|
||||
ai:
|
||||
$ref: '#/components/schemas/SpantypesTraceAISummary'
|
||||
endTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
hasMissingSpans:
|
||||
type: boolean
|
||||
rootServiceEntryPoint:
|
||||
type: string
|
||||
rootServiceName:
|
||||
type: string
|
||||
startTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalErrorSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
type: object
|
||||
SpantypesGettableWaterfallTrace:
|
||||
properties:
|
||||
endTimestampMillis:
|
||||
@@ -9859,6 +9745,32 @@ components:
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
SpantypesTraceAISummary:
|
||||
properties:
|
||||
tokens:
|
||||
$ref: '#/components/schemas/SpantypesTraceAITokens'
|
||||
totalCost:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
SpantypesTraceAITokens:
|
||||
properties:
|
||||
cacheRead:
|
||||
minimum: 0
|
||||
type: integer
|
||||
cacheWrite:
|
||||
minimum: 0
|
||||
type: integer
|
||||
input:
|
||||
minimum: 0
|
||||
type: integer
|
||||
output:
|
||||
minimum: 0
|
||||
type: integer
|
||||
reasoning:
|
||||
minimum: 0
|
||||
type: integer
|
||||
type: object
|
||||
SpantypesUpdatableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -15597,6 +15509,66 @@ paths:
|
||||
summary: Get aggregations for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v1/traces/{traceID}/summary:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the trace-level fields of the waterfall (time range, root,
|
||||
span counts, missing spans) and, when the trace has gen_ai spans, its token
|
||||
and cost totals. Computed in one aggregate query.
|
||||
operationId: GetTraceSummary
|
||||
parameters:
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableTraceSummary'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get summary for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v1/user/me:
|
||||
get:
|
||||
deprecated: true
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
# DSL Filtering to SQL
|
||||
|
||||
To support search on any entity's list page (dashboards, alert rules, ...), use [pkg/parser/filterquery/sqlcompiler](/pkg/parser/filterquery/sqlcompiler/compiler.go). It compiles a filter DSL string into a WHERE clause for the relational store: `?`-placeholder SQL plus bind arguments, ready for bun on both SQLite and Postgres. This doc explains what the compiler already does and what an adopting module supplies: a `FieldResolver` that says which keys exist and what each maps to.
|
||||
|
||||
The dashboards list is the adopter today; the alert rules list revamp is adopting it next.
|
||||
|
||||
## What is the DSL?
|
||||
|
||||
A few queries, from simple to full:
|
||||
|
||||
```
|
||||
payment
|
||||
status = active AND name CONTAINS cpu
|
||||
(labels.team IN ('infra', 'platform') OR labels.env EXISTS) AND created_at > '2025-01-01T00:00:00Z'
|
||||
"name = something"
|
||||
```
|
||||
|
||||
- `payment` is free text: a bare token with no key, matched as a substring wherever the module decides (name, description, ...).
|
||||
- `status = active AND name CONTAINS cpu` is two comparisons of the shape `key OP value`. The `AND` is optional; adjacent terms are an implicit `AND`.
|
||||
- The third query shows grouping and precedence: parentheses > `NOT` > `AND` > `OR`. Values are bare tokens or quoted strings; `IN` accepts `in(...)` and `[...]` forms.
|
||||
- `"name = something"` is quoted, so it is free text for that exact phrase instead of a `name = something` comparison. Quoting is the escape hatch for a phrase that looks like DSL.
|
||||
|
||||
The grammar lives at [grammar/FilterQuery.g4](/grammar/FilterQuery.g4) (see its `comparison` rule for the full operator list), with the ANTLR-generated parser in [pkg/parser/filterquery/grammar](/pkg/parser/filterquery/grammar). It is the same grammar the telemetry search bars use, so the query language feels identical everywhere.
|
||||
|
||||
## What does the framework already cover?
|
||||
|
||||
```go
|
||||
compiled, errs := sqlcompiler.Compile(query, formatter, resolver)
|
||||
|
||||
type Compiled struct {
|
||||
SQL string
|
||||
Args []any
|
||||
}
|
||||
```
|
||||
|
||||
`Compile` returns either a non-nil `*Compiled` or a list of human-readable errors. `Compiled.SQL` is the WHERE clause with `?` placeholders and `Compiled.Args` holds the bind arguments in placeholder order; the store passes both to bun. An empty query compiles to an empty `Compiled`; callers gate on `IsEmpty()`, not nil. The package handles:
|
||||
|
||||
- Parsing, with syntax errors collected at line/column positions instead of failing on the first one.
|
||||
- The boolean tree: `AND`/`OR`/`NOT`, parentheses, implicit `AND`, and pruning of empty conditions.
|
||||
- Operator extraction, including inversion of `NOT LIKE`, `NOT IN`, `NOT EXISTS` and friends.
|
||||
- Typed value extraction with accumulated errors: the user sees every problem in the query at once.
|
||||
- Argument binding through go-sqlbuilder; no value is ever interpolated into the SQL text.
|
||||
|
||||
The resolver is called once per term and builds each predicate with helpers the compiler provides (next section).
|
||||
|
||||
## When do I write a FieldResolver?
|
||||
|
||||
Whenever a module adopts the DSL for its list page. The resolver is the per-module policy and the only code you write:
|
||||
|
||||
```go
|
||||
type FieldResolver interface {
|
||||
ResolveComparison(v *Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string
|
||||
ResolveFreeText(v *Visitor, value string) string
|
||||
}
|
||||
```
|
||||
|
||||
- `ResolveComparison` is called once per `key OP value` term. It decides whether the key exists and which column expression it maps to, and returns the SQL predicate for the term.
|
||||
- `ResolveFreeText` is called for a bare or quoted keyless token. It returns a predicate matching the token across whatever the module considers searchable (name, description, tags, ...).
|
||||
- Both report a bad key, operator or value with `v.AddError(...)` and return `""`. Never panic, never fail fast; the compile fails at the end with all accumulated errors.
|
||||
|
||||
The `*Visitor` passed in provides everything needed to build predicates. Use these instead of hand-building SQL or managing arguments yourself:
|
||||
|
||||
| On the `Visitor` | Use |
|
||||
| --- | --- |
|
||||
| `Sb` | the compile's root `SelectBuilder`; predicates and their arguments attach to it |
|
||||
| `Formatter` | dialect-portable column expressions (`JSONExtractString`, `LowerExpression`) valid on both SQLite and Postgres |
|
||||
| `BuildStringOperation` | `=`, `!=`, `LIKE`/`ILIKE`, `CONTAINS`, `IN` on a string column; escapes `%`/`_` for `CONTAINS`, rejects patterns ending in a dangling backslash, lowers both sides for `ILIKE` so SQLite and Postgres agree |
|
||||
| `BuildTimestampComparison` | equality, ranges and `BETWEEN` on RFC3339 timestamps |
|
||||
| `BuildBoolComparison` | `= true/false` |
|
||||
| `BuildFreeTextContains` | case-insensitive substring match, `COALESCE`d so `NOT (...)` does not drop rows where the column is NULL |
|
||||
| `ExtractSingleStringValue`, `ExtractStringValueList` | typed value extraction when building a custom predicate |
|
||||
| `AddError` | report a problem; errors accumulate |
|
||||
|
||||
In the simplest case, keys map straight to columns and the resolver is a switch. The doc's running example, an imaginary `sample_entity` table:
|
||||
|
||||
```go
|
||||
func (r sampleEntityFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
|
||||
switch key {
|
||||
case "created_by":
|
||||
return v.BuildStringOperation(v.Sb, ctx, operation, "sample_entity.created_by", key)
|
||||
case "created_at":
|
||||
return v.BuildTimestampComparison(ctx, operation, "sample_entity.created_at")
|
||||
case "locked":
|
||||
return v.BuildBoolComparison(ctx, operation, "sample_entity.locked")
|
||||
}
|
||||
v.AddError("unknown key %q", key)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (sampleEntityFieldResolver) ResolveFreeText(v *sqlcompiler.Visitor, value string) string {
|
||||
return v.BuildFreeTextContains(v.Sb, "sample_entity.name", value)
|
||||
}
|
||||
```
|
||||
|
||||
### Special cases
|
||||
|
||||
Each entity decides its own key policy. The sections below grow the `sample_entity` resolver; the full real-world adopter to read alongside is dashboards' resolver, [pkg/modules/dashboard/impldashboard/listfilter_resolver.go](/pkg/modules/dashboard/impldashboard/listfilter_resolver.go).
|
||||
|
||||
#### Reserved and non-reserved keys
|
||||
|
||||
A resolver splits the key space in two:
|
||||
|
||||
- Reserved keys are properties the entity defines for all its instances: every `sample_entity` has a `name`, `created_by`, `created_at` and `locked`, so those keys are claimed up front and always mean that property. The list API can advertise the set (dashboards and rules return `reservedKeywords`) so frontend suggestions never go stale.
|
||||
- Every other key is non-reserved: things users attach to individual instances as they want. For `sample_entity` those are labels, so `team = infra` matches only the instances a user labeled `team: infra` (built out under [Relation tables](#relation-tables)). Dashboards exposes tags the same way, and an entity is free to back this with any other per-instance construct. An entity with nothing user-attached rejects unknown keys with `v.AddError`, as the resolver above does.
|
||||
|
||||
So the first thing `ResolveComparison` does is route the key:
|
||||
|
||||
```go
|
||||
if allowedOperations, isReserved := ReservedOps[key]; isReserved {
|
||||
return r.resolveReservedKey(v, ctx, operation, key, allowedOperations)
|
||||
}
|
||||
return r.buildLabelComparison(v, ctx, operation, key)
|
||||
```
|
||||
|
||||
#### Operator allowlists
|
||||
|
||||
Not every operator makes sense on every key, reserved or not (`name BETWEEN ...` does not). Declare what each accepts and check before building. `sample_entity` pairs each reserved key with its allowed operators:
|
||||
|
||||
```go
|
||||
var ReservedOps = map[string]map[qbtypesv5.FilterOperator]struct{}{
|
||||
"name": stringSearchOps(),
|
||||
"created_at": numericRangeOps(),
|
||||
"locked": boolOps(),
|
||||
}
|
||||
|
||||
if _, allowed := allowedOperations[operation]; !allowed {
|
||||
v.AddError("operator %s is not allowed for key %q", sqlcompiler.OperationName(operation), key)
|
||||
return ""
|
||||
}
|
||||
```
|
||||
|
||||
Non-reserved keys get allowlists too, usually one shared list since they are all shaped alike: a label lookup is a string match, so `created_at > '2025-01-01T00:00:00Z'` is fine but `team > infra` is rejected with an `AddError`. Dashboards' real instances of both are `ReservedOps` and `TagKeyOps` in [pkg/types/dashboardtypes](/pkg/types/dashboardtypes/list_filter.go).
|
||||
|
||||
#### JSON columns
|
||||
|
||||
Suppose `sample_entity` keeps `name` inside a `data` JSON column instead of a plain column. The resolver then builds the column expression with `v.Formatter.JSONExtractString`, which renders correctly on both dialects, and `name CONTAINS cpu` compiles (SQLite flavor) to:
|
||||
|
||||
```sql
|
||||
json_extract("sample_entity"."data", '$.name') LIKE ? ESCAPE '\'
|
||||
-- args: ["%cpu%"]
|
||||
```
|
||||
|
||||
Dashboards stores name and description this way inside `dashboard.data`.
|
||||
|
||||
#### Relation tables
|
||||
|
||||
The label policy from above: say `sample_entity` labels live in `label`/`label_relation` join tables, so a label term becomes an `EXISTS` subquery. Build it on a fresh `sqlbuilder.SelectBuilder` and pass that builder into `BuildStringOperation`, so its arguments thread through the compile. `team = infra` compiles to:
|
||||
|
||||
```sql
|
||||
EXISTS (SELECT 1 FROM label_relation lr JOIN label l ON l.id = lr.label_id
|
||||
WHERE lr.entity_id = sample_entity.id
|
||||
AND LOWER(l.key) = LOWER(?) AND l.value = ?)
|
||||
-- args: ["team", "infra"]
|
||||
```
|
||||
|
||||
For a negative operator (`team != infra`), build the positive predicate and toggle `NotExists` on the outer builder, so rows without the label at all also match. Dashboards' tags follow this exact pattern over the shared `tag`/`tag_relation` tables.
|
||||
|
||||
## How to wire it in?
|
||||
|
||||
Give the module a thin `Compile` wrapper that maps the error list onto the module's error code:
|
||||
|
||||
```go
|
||||
func Compile(query string, formatter sqlstore.SQLFormatter) (*sqlcompiler.Compiled, error) {
|
||||
compiled, errs := sqlcompiler.Compile(query, formatter, sampleEntityFieldResolver{})
|
||||
if len(errs) > 0 {
|
||||
return nil, errors.NewInvalidInputf(sampleentitytypes.ErrCodeSampleEntityListFilterInvalid,
|
||||
"invalid filter query: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return compiled, nil
|
||||
}
|
||||
```
|
||||
|
||||
Dashboards' real wrapper is [pkg/modules/dashboard/impldashboard/listfilter.go](/pkg/modules/dashboard/impldashboard/listfilter.go).
|
||||
|
||||
The store then appends `compiled.SQL` with `compiled.Args` to its list query when `!compiled.IsEmpty()`.
|
||||
|
||||
## Caveats
|
||||
|
||||
- This compiler is for the relational store only. Telemetry filters are a different pipeline; they stay on querybuilder's ClickHouse visitor.
|
||||
- A `key REGEXP value` term parses, but no predicate builder implements it: `BuildStringOperation` rejects it with an error, since SQLite has no portable `REGEXP` (Postgres spells it `~`). A resolver may implement it itself for a dialect it controls.
|
||||
- `has(...)` function calls and `search(...)` from the telemetry grammar are not implemented; they fall through to `ResolveFreeText` as literal text.
|
||||
@@ -17,7 +17,7 @@ For example, the [prometheus](/pkg/prometheus) provider delivers a prometheus en
|
||||
|
||||
- `pkg/prometheus/prometheus.go` - Interface definition
|
||||
- `pkg/prometheus/config.go` - Configuration
|
||||
- `pkg/prometheus/clickhouseprometheusv2/provider.go` - Clickhouse-powered implementation
|
||||
- `pkg/prometheus/clickhouseprometheus/provider.go` - Clickhouse-powered implementation
|
||||
- `pkg/prometheus/prometheustest/provider.go` - Mock implementation
|
||||
|
||||
## How to wire it up?
|
||||
|
||||
@@ -21,5 +21,4 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
|
||||
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
|
||||
- [Service](service.md) - Managed service lifecycle with `factory.Service`
|
||||
- [SQL](sql.md) - Database and SQL patterns
|
||||
- [DSL Filtering to SQL](dslfilteringtosql.md) - Compiling the list filter DSL to relational-store WHERE clauses
|
||||
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`
|
||||
|
||||
@@ -9,16 +9,15 @@ change breaks an invariant, flag it and discuss it first.
|
||||
|
||||
---
|
||||
|
||||
## Why the provider looks like this
|
||||
## Why a second provider
|
||||
|
||||
The removed v1 provider served the promql engine through the remote-read
|
||||
protobuf adapter. It fetched every raw sample of a query's union window,
|
||||
serialized all of them, and gave them to the engine. The cost followed the
|
||||
ingested data, not the question. This is how a dashboard of PromQL panels
|
||||
could take an instance down. v2 replaced it after a byte-level parity
|
||||
rollout, and v1 was then deleted.
|
||||
The v1 provider (`pkg/prometheus/clickhouseprometheus`) serves the promql
|
||||
engine through the remote-read protobuf adapter. It fetches every raw sample
|
||||
of a query's union window. It serializes all of them and gives them to the
|
||||
engine. The cost follows the ingested data, not the question. This is how a
|
||||
dashboard of PromQL panels can take an instance down.
|
||||
|
||||
Each query runs in one of two ways. The classifier decides per query:
|
||||
In v2, each query runs in one of two ways. The classifier decides per query:
|
||||
|
||||
- **Transpiled**: ClickHouse evaluates the query. Only final (or near-final)
|
||||
per-group grid arrays come back. The statements use the
|
||||
@@ -31,7 +30,7 @@ Each query runs in one of two ways. The classifier decides per query:
|
||||
lost user. A construct that cannot reproduce engine semantics exactly falls
|
||||
back. It does not approximate.** The conformance suite
|
||||
(`tests/integration/tests/promqlconformance/`) replays Prometheus' own test
|
||||
corpus against the provider. It is the arbiter. The classification golden
|
||||
corpus against both providers. It is the arbiter. The classification golden
|
||||
(`testdata/classification_golden.json`) freezes the route of each corpus
|
||||
expression. The rest of this document is the PromQL-to-SQL story. That
|
||||
mapping is where correctness is won or lost.
|
||||
@@ -264,8 +263,7 @@ per-thread partials scaled memory with the thread count. The slide then
|
||||
combines each slot's at-most-W bucket partials by direct aggregation
|
||||
(`arraySum(arraySlice(...))`). Window sums are added the way the engine adds
|
||||
them. There is no prefix-sum differencing: its large-minus-large
|
||||
cancellation would drift past the conformance tolerance on counter-sized
|
||||
values.
|
||||
cancellation would drift past the shadow tolerance on counter-sized values.
|
||||
This is correct per slot because the bucket union is the exact window
|
||||
multiset, and avg/min/max/sum/count are order-insensitive on a multiset
|
||||
(sum/avg up to summation order; see the float caveat above). A slot with
|
||||
@@ -335,7 +333,7 @@ can carry them.
|
||||
## The engine path
|
||||
|
||||
Queries that do not transpile run in the stock engine over this package's
|
||||
`storage.Querier`. Samples are fetched per
|
||||
`storage.Querier`. This is still not the v1 path. Samples are fetched per
|
||||
selector with the engine's per-selector hints, not the query-wide union
|
||||
window. So `foo / foo offset 1d` reads two narrow windows, not the widest
|
||||
one twice. Instant selectors of subquery-free queries fetch only the last
|
||||
@@ -369,9 +367,9 @@ same predicates as a shard-local semi-join, not a GLOBAL broadcast of the
|
||||
matched set. The temporality filter on every samples statement is a
|
||||
semantic no-op: the matched fingerprints already come from those
|
||||
temporalities. It engages the leading samples primary-key column.
|
||||
Delta-temporality series stay invisible to PromQL here, as they were before
|
||||
v2. To make Delta visible is its own change with its own semantics to
|
||||
design. A Delta stream fed to `rate()`
|
||||
Delta-temporality series stay invisible to PromQL here, exactly as in v1.
|
||||
The rollout gate is parity with v1. To make Delta visible is its own change
|
||||
with its own semantics to design. A Delta stream fed to `rate()`
|
||||
as-if-cumulative would be wrong, not just new.
|
||||
|
||||
## Observability
|
||||
|
||||
@@ -160,7 +160,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
|
||||
triggeredTestAlerts := []map[*alertmanagertypes.PostableAlert][]string{}
|
||||
|
||||
// Variable to store promProvider for cleanup
|
||||
var promProvider prometheus.Prometheus
|
||||
var promProvider *prometheustest.Provider
|
||||
|
||||
// Create manager using test factory with hooks
|
||||
mgr := rules.NewTestManager(t, &rules.TestManagerOptions{
|
||||
@@ -185,29 +185,76 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
|
||||
TelemetryStoreHook: func(store telemetrystore.TelemetryStore) {
|
||||
mockStore := store.(*telemetrystoretest.Provider)
|
||||
|
||||
// Grid the TestNotification eval computes over (see
|
||||
// Timestamps on base_rule); nil args match any window.
|
||||
// Set up Prometheus-specific mock data
|
||||
// Fingerprint columns for Prometheus queries
|
||||
fingerprintCols := []cmock.ColumnType{
|
||||
{Name: "fingerprint", Type: "UInt64"},
|
||||
{Name: "any(labels)", Type: "String"},
|
||||
}
|
||||
|
||||
// Samples columns for Prometheus queries
|
||||
samplesCols := []cmock.ColumnType{
|
||||
{Name: "metric_name", Type: "String"},
|
||||
{Name: "fingerprint", Type: "UInt64"},
|
||||
{Name: "unix_milli", Type: "Int64"},
|
||||
{Name: "value", Type: "Float64"},
|
||||
{Name: "flags", Type: "UInt32"},
|
||||
}
|
||||
|
||||
// Calculate query time range similar to Prometheus rule tests
|
||||
// TestNotification uses time.Now().UTC() for evaluation
|
||||
// We calculate the query window based on current time to match what the actual evaluation will use
|
||||
evalTime := baseTime
|
||||
evalWindowMs := int64(5 * 60 * 1000) // 5 minutes in ms
|
||||
gridEnd := (evalTime.UnixMilli() / 60000) * 60000
|
||||
gridStart := gridEnd - evalWindowMs
|
||||
evalTimeMs := evalTime.UnixMilli()
|
||||
queryStart := ((evalTimeMs-2*evalWindowMs)/60000)*60000 + 1 // truncate to minute + 1ms
|
||||
queryEnd := (evalTimeMs / 60000) * 60000 // truncate to minute
|
||||
|
||||
tsList := make([]int64, 0, len(tc.Values))
|
||||
vList := make([]float64, 0, len(tc.Values))
|
||||
// Create fingerprint data
|
||||
fingerprint := uint64(12345)
|
||||
labelsJSON := `{"__name__":"test_metric"}`
|
||||
fingerprintData := [][]interface{}{
|
||||
{fingerprint, labelsJSON},
|
||||
}
|
||||
fingerprintRows := cmock.NewRows(fingerprintCols, fingerprintData)
|
||||
|
||||
// Create samples data from test case values, calculating timestamps relative to baseTime
|
||||
validSamplesData := make([][]interface{}, 0)
|
||||
for _, v := range tc.Values {
|
||||
// Skip NaN and Inf values in the samples data
|
||||
if math.IsNaN(v.Value) || math.IsInf(v.Value, 0) {
|
||||
continue
|
||||
}
|
||||
tsList = append(tsList, baseTime.Add(v.Offset).UnixMilli())
|
||||
vList = append(vList, v.Value)
|
||||
// Calculate timestamp relative to baseTime
|
||||
sampleTimestamp := baseTime.Add(v.Offset).UnixMilli()
|
||||
validSamplesData = append(validSamplesData, []interface{}{
|
||||
"test_metric",
|
||||
fingerprint,
|
||||
sampleTimestamp,
|
||||
v.Value,
|
||||
uint32(0), // flags - 0 means normal value
|
||||
})
|
||||
}
|
||||
grid := prometheustest.LastSampleGrid(tsList, vList, gridStart, gridEnd, 60_000, 300_000)
|
||||
samplesRows := cmock.NewRows(samplesCols, validSamplesData)
|
||||
|
||||
mock := mockStore.Mock()
|
||||
mock.ExpectQuery("SELECT gkey").
|
||||
WithArgs("test_metric", nil, nil, "test_metric", nil, nil).
|
||||
WillReturnRows(cmock.NewRows(prometheustest.GridCols, [][]any{{`[["__name__","test_metric"]]`, grid}}))
|
||||
|
||||
// Mock the fingerprint query (for Prometheus label matching)
|
||||
// args: $1=metric_name (the __name__ matcher maps onto the column)
|
||||
mock.ExpectQuery("SELECT fingerprint, any").
|
||||
WithArgs("test_metric").
|
||||
WillReturnRows(fingerprintRows)
|
||||
|
||||
// Mock the samples query (for Prometheus metric data)
|
||||
// args: metric_name IN (discovered names), subquery metric_name, start, end
|
||||
mock.ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
|
||||
WithArgs(
|
||||
"test_metric",
|
||||
"test_metric",
|
||||
queryStart,
|
||||
queryEnd,
|
||||
).
|
||||
WillReturnRows(samplesRows)
|
||||
|
||||
// Create Prometheus provider for this test
|
||||
promProvider = prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, store)
|
||||
@@ -242,6 +289,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
|
||||
assert.Empty(t, triggeredTestAlerts)
|
||||
}
|
||||
|
||||
promProvider.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,73 +40,7 @@ export interface AlertmanagertypesChannelDTO {
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind {
|
||||
slack = 'slack',
|
||||
}
|
||||
export interface AlertmanagertypesChannelSlackConfirmationDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
dismissText?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
okText?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelSlackActionDTO {
|
||||
confirm?: AlertmanagertypesChannelSlackConfirmationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
style?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelSlackFieldDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
short?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
actions?: AlertmanagertypesChannelSlackActionDTO[];
|
||||
/**
|
||||
* @type string
|
||||
* @format password
|
||||
@@ -116,26 +50,6 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
* @type string
|
||||
*/
|
||||
channel?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
fallback?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
fields?: AlertmanagertypesChannelSlackFieldDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
footer?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
pretext?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
@@ -148,10 +62,6 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
titleLink?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO {
|
||||
@@ -4099,52 +4009,6 @@ export interface DashboardGridLayoutSpecDTO {
|
||||
repeatVariable?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesAreaFillModeDTO {
|
||||
solid = 'solid',
|
||||
gradient = 'gradient',
|
||||
}
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 1
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesFillOpacityDTO = number | null;
|
||||
|
||||
export enum DashboardtypesLineInterpolationDTO {
|
||||
linear = 'linear',
|
||||
spline = 'spline',
|
||||
step_after = 'step_after',
|
||||
step_before = 'step_before',
|
||||
}
|
||||
export enum DashboardtypesLineStyleDTO {
|
||||
solid = 'solid',
|
||||
dashed = 'dashed',
|
||||
}
|
||||
export interface DashboardtypesSpanGapsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
|
||||
*/
|
||||
fillLessThan?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
|
||||
*/
|
||||
fillOnlyBelow?: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAreaChartAppearanceDTO {
|
||||
fillMode?: DashboardtypesAreaFillModeDTO;
|
||||
fillOpacity?: DashboardtypesFillOpacityDTO | null;
|
||||
lineInterpolation?: DashboardtypesLineInterpolationDTO;
|
||||
lineStyle?: DashboardtypesLineStyleDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
showPoints?: boolean;
|
||||
spanGaps?: DashboardtypesSpanGapsDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAxesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -4222,11 +4086,6 @@ export interface DashboardtypesThresholdWithLabelDTO {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export enum DashboardtypesStackModeDTO {
|
||||
none = 'none',
|
||||
normal = 'normal',
|
||||
percent = 'percent',
|
||||
}
|
||||
export enum DashboardtypesTimePreferenceDTO {
|
||||
global_time = 'global_time',
|
||||
last_5_min = 'last_5_min',
|
||||
@@ -4239,27 +4098,6 @@ export enum DashboardtypesTimePreferenceDTO {
|
||||
last_1_week = 'last_1_week',
|
||||
last_1_month = 'last_1_month',
|
||||
}
|
||||
export interface DashboardtypesAreaChartVisualizationDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
fillSpans?: boolean;
|
||||
stack?: DashboardtypesStackModeDTO;
|
||||
timePreference?: DashboardtypesTimePreferenceDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAreaChartPanelSpecDTO {
|
||||
axes?: DashboardtypesAxesDTO;
|
||||
chartAppearance?: DashboardtypesAreaChartAppearanceDTO;
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
|
||||
visualization?: DashboardtypesAreaChartVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesBarChartVisualizationDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -4957,6 +4795,29 @@ export enum DashboardtypesFillModeDTO {
|
||||
gradient = 'gradient',
|
||||
none = 'none',
|
||||
}
|
||||
export enum DashboardtypesLineInterpolationDTO {
|
||||
linear = 'linear',
|
||||
spline = 'spline',
|
||||
step_after = 'step_after',
|
||||
step_before = 'step_before',
|
||||
}
|
||||
export enum DashboardtypesLineStyleDTO {
|
||||
solid = 'solid',
|
||||
dashed = 'dashed',
|
||||
}
|
||||
export interface DashboardtypesSpanGapsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
|
||||
*/
|
||||
fillLessThan?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
|
||||
*/
|
||||
fillOnlyBelow?: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardtypesTimeSeriesChartAppearanceDTO {
|
||||
fillMode?: DashboardtypesFillModeDTO;
|
||||
lineInterpolation?: DashboardtypesLineInterpolationDTO;
|
||||
@@ -5009,18 +4870,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesBarChartPanelSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTOKind {
|
||||
'signoz/AreaChartPanel' = 'signoz/AreaChartPanel',
|
||||
}
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/AreaChartPanel
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTOKind;
|
||||
spec: DashboardtypesAreaChartPanelSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpecDTOKind {
|
||||
'signoz/NumberPanel' = 'signoz/NumberPanel',
|
||||
}
|
||||
@@ -5226,7 +5075,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
|
||||
@@ -6151,7 +5999,6 @@ export interface DashboardtypesListableDashboardViewDTO {
|
||||
export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
|
||||
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
|
||||
'signoz/AreaChartPanel' = 'signoz/AreaChartPanel',
|
||||
'signoz/NumberPanel' = 'signoz/NumberPanel',
|
||||
'signoz/PieChartPanel' = 'signoz/PieChartPanel',
|
||||
'signoz/TablePanel' = 'signoz/TablePanel',
|
||||
@@ -11037,6 +10884,78 @@ export interface SpantypesGettableTraceAggregationsDTO {
|
||||
aggregations: SpantypesSpanAggregationResultDTO[];
|
||||
}
|
||||
|
||||
export interface SpantypesTraceAITokensDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
cacheRead?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
cacheWrite?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
input?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
output?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
reasoning?: number;
|
||||
}
|
||||
|
||||
export interface SpantypesTraceAISummaryDTO {
|
||||
tokens?: SpantypesTraceAITokensDTO;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
totalCost?: number | null;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableTraceSummaryDTO {
|
||||
ai?: SpantypesTraceAISummaryDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
endTimestampMillis?: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMissingSpans?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceEntryPoint?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceName?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
startTimestampMillis?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalErrorSpansCount?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalSpansCount?: number;
|
||||
}
|
||||
|
||||
export interface SpantypesOtelSpanRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -12748,6 +12667,17 @@ export type GetTraceAggregations200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetTraceSummaryPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetTraceSummary200 = {
|
||||
data: SpantypesGettableTraceSummaryDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUserPreferences200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
@@ -16,6 +22,8 @@ import type {
|
||||
GetFlamegraphPathParameters,
|
||||
GetTraceAggregations200,
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetTraceSummary200,
|
||||
GetTraceSummaryPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -27,6 +35,26 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
const withQueryKey = <T extends object, K>(
|
||||
query: T,
|
||||
queryKey: K,
|
||||
): T & { queryKey: K } => {
|
||||
const result = { queryKey } as T & { queryKey: K };
|
||||
for (const key of Object.keys(query)) {
|
||||
// The explicit queryKey always wins, matching the previous
|
||||
// `{ ...query, queryKey }` spread where it was set last.
|
||||
if (key === 'queryKey') {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(result, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (query as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes span aggregations grouped by requested field.
|
||||
* @summary Get aggregations for a trace
|
||||
@@ -127,6 +155,108 @@ export const useGetTraceAggregations = <
|
||||
> => {
|
||||
return useMutation(getGetTraceAggregationsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the trace-level fields of the waterfall (time range, root, span counts, missing spans) and, when the trace has gen_ai spans, its token and cost totals. Computed in one aggregate query.
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
export const getTraceSummary = (
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetTraceSummary200>({
|
||||
url: `/api/v1/traces/${traceID}/summary`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetTraceSummaryQueryKey = ({
|
||||
traceID,
|
||||
}: GetTraceSummaryPathParameters) => {
|
||||
return [`/api/v1/traces/${traceID}/summary`] as const;
|
||||
};
|
||||
|
||||
export const getGetTraceSummaryQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetTraceSummaryQueryKey({ traceID });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceSummary>>> = ({
|
||||
signal,
|
||||
}) => getTraceSummary({ traceID }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: traceID !== null && traceID !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetTraceSummaryQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>
|
||||
>;
|
||||
export type GetTraceSummaryQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
|
||||
export function useGetTraceSummary<
|
||||
TData = Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetTraceSummaryQueryOptions({ traceID }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
export const invalidateGetTraceSummary = async (
|
||||
queryClient: QueryClient,
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetTraceSummaryQueryKey({ traceID }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the flamegraph view of spans for a given trace ID.
|
||||
* @summary Get flamegraph view for a trace
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import type {
|
||||
GetAIObservabilityFieldsKeys200,
|
||||
GetAIObservabilityFieldsValues200,
|
||||
GetAIObservabilityFieldsKeysParams,
|
||||
GetAIObservabilityFieldsValuesParams,
|
||||
GetFieldsKeys200,
|
||||
GetFieldsKeysParams,
|
||||
GetFieldsValues200,
|
||||
GetFieldsValuesParams,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type FieldKeysConfig = GetFieldsKeysParams;
|
||||
export type FieldKeysConfig =
|
||||
| GetFieldsKeysParams
|
||||
| GetAIObservabilityFieldsKeysParams;
|
||||
|
||||
export type FieldValuesConfig = GetFieldsValuesParams;
|
||||
export type FieldValuesConfig =
|
||||
| GetFieldsValuesParams
|
||||
| GetAIObservabilityFieldsValuesParams;
|
||||
|
||||
export type FieldKeysConfigProp = Omit<
|
||||
FieldKeysConfig,
|
||||
|
||||
@@ -17,7 +17,6 @@ function InputWithLabel({
|
||||
onChange,
|
||||
className,
|
||||
closeIcon,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
initialValue?: string | number | null;
|
||||
@@ -28,7 +27,6 @@ function InputWithLabel({
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
closeIcon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}): JSX.Element {
|
||||
const [inputValue, setInputValue] = useState<string>(
|
||||
initialValue ? initialValue.toString() : '',
|
||||
@@ -55,7 +53,6 @@ function InputWithLabel({
|
||||
type={type}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
name={label.toLowerCase()}
|
||||
data-testid={`input-${label}`}
|
||||
/>
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { OPERATORS, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Formula } from 'container/QueryBuilder/components/Formula';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderField } from './queryBuilderFields.types';
|
||||
import {
|
||||
mergeQueryBuilderFieldsConfig,
|
||||
RAW_QUERY_FIELDS,
|
||||
resolveQueryBuilderField,
|
||||
} from './queryBuilderFields.utils';
|
||||
import { QueryBuilderV2Provider } from './QueryBuilderV2Context';
|
||||
import { clearPreviousQuery } from './QueryV2/previousQuery.utils';
|
||||
import QueryFooter from './QueryV2/QueryFooter/QueryFooter';
|
||||
@@ -21,18 +14,12 @@ import TraceOperator from './QueryV2/TraceOperator/TraceOperator';
|
||||
|
||||
import './QueryBuilderV2.styles.scss';
|
||||
|
||||
// Raw rows come from logs or spans; metrics only exist aggregated.
|
||||
const RAW_QUERY_SIGNALS = [
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
];
|
||||
|
||||
export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
config,
|
||||
panelType: newPanelType,
|
||||
fieldsConfig,
|
||||
allowedDataSources,
|
||||
isRawQuery = false,
|
||||
filterConfigs = {},
|
||||
queryComponents,
|
||||
isListViewPanel = false,
|
||||
showOnlyWhereClause = false,
|
||||
showTraceOperator = false,
|
||||
version,
|
||||
@@ -84,48 +71,55 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resolvedConfig = useMemo(
|
||||
() =>
|
||||
mergeQueryBuilderFieldsConfig(
|
||||
isRawQuery ? RAW_QUERY_FIELDS : undefined,
|
||||
fieldsConfig,
|
||||
),
|
||||
[isRawQuery, fieldsConfig],
|
||||
);
|
||||
|
||||
const additionalQueries = useMemo(
|
||||
() =>
|
||||
resolveQueryBuilderField(
|
||||
QueryBuilderField.AdditionalQueries,
|
||||
resolvedConfig,
|
||||
),
|
||||
[resolvedConfig],
|
||||
);
|
||||
|
||||
const formula = useMemo(
|
||||
() => resolveQueryBuilderField(QueryBuilderField.Formula, resolvedConfig),
|
||||
[resolvedConfig],
|
||||
);
|
||||
|
||||
const isMultiQueryAllowed = useMemo(
|
||||
() => !additionalQueries.hidden && (!isRawQuery || showTraceOperator),
|
||||
[additionalQueries.hidden, showTraceOperator, isRawQuery],
|
||||
() => !isListViewPanel || showTraceOperator,
|
||||
[showTraceOperator, isListViewPanel],
|
||||
);
|
||||
|
||||
const queryDataSources = useMemo(
|
||||
() => allowedDataSources ?? (isRawQuery ? RAW_QUERY_SIGNALS : undefined),
|
||||
[allowedDataSources, isRawQuery],
|
||||
);
|
||||
const listViewLogFilterConfigs: QueryBuilderProps['filterConfigs'] =
|
||||
useMemo(() => {
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
filters: {
|
||||
customKey: 'body',
|
||||
customOp: OPERATORS.CONTAINS,
|
||||
},
|
||||
};
|
||||
|
||||
// What the editor renders. A single-query builder edits the first query alone, so
|
||||
// the query list beside it must not advertise ones there is no way to reach.
|
||||
const renderedQueries = useMemo(
|
||||
() =>
|
||||
isMultiQueryAllowed
|
||||
? currentQuery.builder.queryData
|
||||
: currentQuery.builder.queryData.slice(0, 1),
|
||||
[isMultiQueryAllowed, currentQuery.builder.queryData],
|
||||
);
|
||||
return config;
|
||||
}, []);
|
||||
|
||||
const listViewTracesFilterConfigs: QueryBuilderProps['filterConfigs'] =
|
||||
useMemo(() => {
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
limit: { isHidden: true, isDisabled: true },
|
||||
filters: {
|
||||
customKey: 'body',
|
||||
customOp: OPERATORS.CONTAINS,
|
||||
},
|
||||
};
|
||||
|
||||
return config;
|
||||
}, []);
|
||||
|
||||
const queryFilterConfigs = useMemo(() => {
|
||||
if (isListViewPanel) {
|
||||
return currentQuery.builder.queryData[0].dataSource === DataSource.TRACES
|
||||
? listViewTracesFilterConfigs
|
||||
: listViewLogFilterConfigs;
|
||||
}
|
||||
|
||||
return filterConfigs;
|
||||
}, [
|
||||
isListViewPanel,
|
||||
filterConfigs,
|
||||
currentQuery.builder.queryData,
|
||||
listViewLogFilterConfigs,
|
||||
listViewTracesFilterConfigs,
|
||||
]);
|
||||
|
||||
const traceOperator = useMemo((): IBuilderTraceOperator | undefined => {
|
||||
if (
|
||||
@@ -151,46 +145,31 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
|
||||
);
|
||||
|
||||
const shouldShowFooter = useMemo(
|
||||
() =>
|
||||
(!showOnlyWhereClause && !isListViewPanel) ||
|
||||
(currentDataSource === DataSource.TRACES && showTraceOperator),
|
||||
[isListViewPanel, showTraceOperator, showOnlyWhereClause, currentDataSource],
|
||||
);
|
||||
|
||||
const showQueryList = useMemo(
|
||||
() => (!showOnlyWhereClause && !isRawQuery) || showTraceOperator,
|
||||
[isRawQuery, showOnlyWhereClause, showTraceOperator],
|
||||
() => (!showOnlyWhereClause && !isListViewPanel) || showTraceOperator,
|
||||
[isListViewPanel, showOnlyWhereClause, showTraceOperator],
|
||||
);
|
||||
|
||||
const showFormula = useMemo(() => {
|
||||
if (formula.hidden) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentDataSource === DataSource.TRACES) {
|
||||
return !isRawQuery;
|
||||
return !isListViewPanel;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [formula.hidden, isRawQuery, currentDataSource]);
|
||||
}, [isListViewPanel, currentDataSource]);
|
||||
|
||||
const showAddTraceOperator = useMemo(
|
||||
() => showTraceOperator && !traceOperator && hasAtLeastOneTraceQuery,
|
||||
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
|
||||
);
|
||||
|
||||
// Nothing left to add means no footer at all, rather than an empty bar under the
|
||||
// last query.
|
||||
const shouldShowFooter = useMemo(
|
||||
() =>
|
||||
(!additionalQueries.hidden || showFormula || showAddTraceOperator) &&
|
||||
((!showOnlyWhereClause && !isRawQuery) ||
|
||||
(currentDataSource === DataSource.TRACES && showTraceOperator)),
|
||||
[
|
||||
additionalQueries.hidden,
|
||||
showFormula,
|
||||
showAddTraceOperator,
|
||||
isRawQuery,
|
||||
showTraceOperator,
|
||||
showOnlyWhereClause,
|
||||
currentDataSource,
|
||||
],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
@@ -220,8 +199,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
key={currentQuery.builder.queryData[0].queryName}
|
||||
index={0}
|
||||
query={currentQuery.builder.queryData[0]}
|
||||
fieldsConfig={fieldsConfig}
|
||||
allowedDataSources={queryDataSources}
|
||||
filterConfigs={queryFilterConfigs}
|
||||
queryComponents={queryComponents}
|
||||
isMultiQueryAllowed={isMultiQueryAllowed}
|
||||
showTraceOperator={showTraceOperator}
|
||||
hasTraceOperator={hasTraceOperator}
|
||||
@@ -229,7 +208,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
isAvailableToDisable={false}
|
||||
queryVariant={config?.queryVariant || 'dropdown'}
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
isRawQuery={isRawQuery}
|
||||
isListViewPanel={isListViewPanel}
|
||||
signalSource={currentQuery.builder.queryData[0].source as 'meter' | ''}
|
||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
@@ -237,14 +216,14 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
savePreviousQuery={savePreviousQuery}
|
||||
/>
|
||||
) : (
|
||||
renderedQueries.map((query, index) => (
|
||||
currentQuery.builder.queryData.map((query, index) => (
|
||||
<QueryV2
|
||||
ref={containerRef}
|
||||
key={query.queryName}
|
||||
index={index}
|
||||
query={query}
|
||||
fieldsConfig={fieldsConfig}
|
||||
allowedDataSources={queryDataSources}
|
||||
filterConfigs={queryFilterConfigs}
|
||||
queryComponents={queryComponents}
|
||||
version={version}
|
||||
isMultiQueryAllowed={isMultiQueryAllowed}
|
||||
isAvailableToDisable={false}
|
||||
@@ -252,7 +231,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
hasTraceOperator={hasTraceOperator}
|
||||
queryVariant={config?.queryVariant || 'dropdown'}
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
isRawQuery={isRawQuery}
|
||||
isListViewPanel={isListViewPanel}
|
||||
signalSource={query.source as 'meter' | ''}
|
||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
@@ -272,7 +251,14 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
|
||||
return (
|
||||
<div key={formula.queryName} className="qb-formula">
|
||||
<Formula query={query} formula={formula} index={index} isQBV2 />
|
||||
<Formula
|
||||
filterConfigs={filterConfigs}
|
||||
query={query}
|
||||
formula={formula}
|
||||
index={index}
|
||||
isAdditionalFilterEnable={false}
|
||||
isQBV2
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -281,13 +267,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
|
||||
{shouldShowFooter && (
|
||||
<QueryFooter
|
||||
showAddQuery={!additionalQueries.hidden}
|
||||
showAddFormula={showFormula}
|
||||
isAddFormulaDisabled={formula.disabled}
|
||||
addFormulaDisabledReason={formula.reason}
|
||||
addNewBuilderQuery={addNewBuilderQuery}
|
||||
isAddQueryDisabled={additionalQueries.disabled}
|
||||
addQueryDisabledReason={additionalQueries.reason}
|
||||
addNewFormula={addNewFormula}
|
||||
addTraceOperator={addTraceOperator}
|
||||
showAddTraceOperator={showAddTraceOperator}
|
||||
@@ -296,8 +277,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
|
||||
{hasTraceOperator && (
|
||||
<TraceOperator
|
||||
isRawQuery={isRawQuery}
|
||||
fieldsConfig={resolvedConfig}
|
||||
isListViewPanel={isListViewPanel}
|
||||
traceOperator={traceOperator as IBuilderTraceOperator}
|
||||
/>
|
||||
)}
|
||||
@@ -305,7 +285,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
|
||||
{showQueryList && (
|
||||
<div className="query-names-section">
|
||||
{renderedQueries.map((query) => (
|
||||
{currentQuery.builder.queryData.map((query) => (
|
||||
<div key={query.queryName} className="query-name">
|
||||
{query.queryName}
|
||||
</div>
|
||||
|
||||
@@ -23,11 +23,6 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--margin-2);
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -15,16 +14,6 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
QueryBuilderField,
|
||||
QueryBuilderFieldsConfig,
|
||||
} from '../../queryBuilderFields.types';
|
||||
import {
|
||||
mergeQueryBuilderFieldsConfig,
|
||||
RAW_QUERY_FIELDS,
|
||||
resolveQueryBuilderFields,
|
||||
} from '../../queryBuilderFields.utils';
|
||||
|
||||
import HavingFilter from './HavingFilter/HavingFilter';
|
||||
import { buildDefaultLegendFromGroupBy } from './utils';
|
||||
|
||||
@@ -33,37 +22,34 @@ import './QueryAddOns.styles.scss';
|
||||
interface AddOn {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
key: QueryBuilderField;
|
||||
key: string;
|
||||
description?: string;
|
||||
docLink?: string;
|
||||
}
|
||||
|
||||
/** Fields the add-on bar does not own: each has its own control elsewhere in the query. */
|
||||
type NonAddOnField =
|
||||
| QueryBuilderField.Aggregation
|
||||
| QueryBuilderField.StepInterval
|
||||
| QueryBuilderField.Functions
|
||||
| QueryBuilderField.Formula
|
||||
| QueryBuilderField.AdditionalQueries;
|
||||
|
||||
// Omit rather than Partial, so a field added to the enum has to be placed on one side.
|
||||
const ADD_ONS_KEYS_TO_QUERY_PATH: Omit<
|
||||
Record<QueryBuilderField, string>,
|
||||
NonAddOnField
|
||||
> = {
|
||||
[QueryBuilderField.GroupBy]: 'groupBy',
|
||||
[QueryBuilderField.Having]: 'having.expression',
|
||||
[QueryBuilderField.OrderBy]: 'orderBy',
|
||||
[QueryBuilderField.Limit]: 'limit',
|
||||
[QueryBuilderField.Legend]: 'legend',
|
||||
[QueryBuilderField.ReduceTo]: 'reduceTo',
|
||||
const ADD_ONS_KEYS = {
|
||||
GROUP_BY: 'group_by',
|
||||
HAVING: 'having',
|
||||
ORDER_BY: 'order_by',
|
||||
LIMIT: 'limit',
|
||||
LEGEND_FORMAT: 'legend_format',
|
||||
REDUCE_TO: 'reduce_to',
|
||||
};
|
||||
|
||||
const ADD_ONS: AddOn[] = [
|
||||
const ADD_ONS_KEYS_TO_QUERY_PATH = {
|
||||
[ADD_ONS_KEYS.GROUP_BY]: 'groupBy',
|
||||
[ADD_ONS_KEYS.HAVING]: 'having.expression',
|
||||
[ADD_ONS_KEYS.ORDER_BY]: 'orderBy',
|
||||
[ADD_ONS_KEYS.LIMIT]: 'limit',
|
||||
[ADD_ONS_KEYS.LEGEND_FORMAT]: 'legend',
|
||||
[ADD_ONS_KEYS.REDUCE_TO]: 'reduceTo',
|
||||
};
|
||||
|
||||
const ADD_ONS = [
|
||||
{
|
||||
icon: <BarChart size={14} />,
|
||||
label: 'Group By',
|
||||
key: QueryBuilderField.GroupBy,
|
||||
key: ADD_ONS_KEYS.GROUP_BY,
|
||||
description:
|
||||
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
|
||||
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
|
||||
@@ -71,7 +57,7 @@ const ADD_ONS: AddOn[] = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Having',
|
||||
key: QueryBuilderField.Having,
|
||||
key: ADD_ONS_KEYS.HAVING,
|
||||
description:
|
||||
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
|
||||
docLink:
|
||||
@@ -80,7 +66,7 @@ const ADD_ONS: AddOn[] = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Order By',
|
||||
key: QueryBuilderField.OrderBy,
|
||||
key: ADD_ONS_KEYS.ORDER_BY,
|
||||
description:
|
||||
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
|
||||
docLink:
|
||||
@@ -89,7 +75,7 @@ const ADD_ONS: AddOn[] = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Limit',
|
||||
key: QueryBuilderField.Limit,
|
||||
key: ADD_ONS_KEYS.LIMIT,
|
||||
description:
|
||||
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
|
||||
docLink:
|
||||
@@ -98,7 +84,7 @@ const ADD_ONS: AddOn[] = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Legend format',
|
||||
key: QueryBuilderField.Legend,
|
||||
key: ADD_ONS_KEYS.LEGEND_FORMAT,
|
||||
description:
|
||||
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
|
||||
docLink:
|
||||
@@ -106,10 +92,10 @@ const ADD_ONS: AddOn[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const REDUCE_TO: AddOn = {
|
||||
const REDUCE_TO = {
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Reduce to',
|
||||
key: QueryBuilderField.ReduceTo,
|
||||
key: ADD_ONS_KEYS.REDUCE_TO,
|
||||
description:
|
||||
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
|
||||
docLink:
|
||||
@@ -168,26 +154,26 @@ function TooltipContent({
|
||||
function QueryAddOns({
|
||||
query,
|
||||
version,
|
||||
isRawQuery,
|
||||
isListViewPanel,
|
||||
showReduceTo,
|
||||
panelType,
|
||||
index,
|
||||
fieldsConfig,
|
||||
isForTraceOperator = false,
|
||||
}: {
|
||||
query: IBuilderQuery;
|
||||
version: string;
|
||||
isRawQuery: boolean;
|
||||
isListViewPanel: boolean;
|
||||
showReduceTo: boolean;
|
||||
panelType: PANEL_TYPES | null;
|
||||
index: number;
|
||||
fieldsConfig?: QueryBuilderFieldsConfig;
|
||||
isForTraceOperator?: boolean;
|
||||
}): JSX.Element {
|
||||
const [addOns, setAddOns] = useState<AddOn[]>(ADD_ONS);
|
||||
|
||||
const [selectedViews, setSelectedViews] = useState<AddOn[]>([]);
|
||||
|
||||
const initializedRef = useRef(false);
|
||||
const prevAvailableKeysRef = useRef<Set<QueryBuilderField> | null>(null);
|
||||
const prevAvailableKeysRef = useRef<Set<string> | null>(null);
|
||||
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
index,
|
||||
@@ -198,62 +184,40 @@ function QueryAddOns({
|
||||
|
||||
const { handleSetQueryData } = useQueryBuilder();
|
||||
|
||||
const supportedAddOns = useMemo((): AddOn[] => {
|
||||
let addOns: AddOn[];
|
||||
useEffect(() => {
|
||||
if (isListViewPanel) {
|
||||
setAddOns([]);
|
||||
|
||||
if (panelType === PANEL_TYPES.VALUE) {
|
||||
addOns = ADD_ONS.filter((addOn) => addOn.key === QueryBuilderField.Legend);
|
||||
} else if (query.dataSource === DataSource.METRICS) {
|
||||
// Group by for metrics is offered by MetricsAggregateSection instead.
|
||||
addOns = ADD_ONS.filter((addOn) => addOn.key !== QueryBuilderField.GroupBy);
|
||||
} else {
|
||||
addOns = [...ADD_ONS];
|
||||
setSelectedViews([
|
||||
ADD_ONS.find((addOn) => addOn.key === ADD_ONS_KEYS.ORDER_BY) as AddOn,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return showReduceTo ? [...addOns, REDUCE_TO] : addOns;
|
||||
}, [panelType, query.dataSource, showReduceTo]);
|
||||
let filteredAddOns: AddOn[];
|
||||
if (panelType === PANEL_TYPES.VALUE) {
|
||||
// Filter out all add-ons except legend format
|
||||
filteredAddOns = ADD_ONS.filter(
|
||||
(addOn) => addOn.key === ADD_ONS_KEYS.LEGEND_FORMAT,
|
||||
);
|
||||
} else {
|
||||
filteredAddOns = Object.values(ADD_ONS);
|
||||
|
||||
const resolvedFields = useMemo(
|
||||
() =>
|
||||
resolveQueryBuilderFields(
|
||||
supportedAddOns.map((addOn) => addOn.key),
|
||||
mergeQueryBuilderFieldsConfig(
|
||||
isRawQuery ? RAW_QUERY_FIELDS : undefined,
|
||||
fieldsConfig,
|
||||
),
|
||||
),
|
||||
[supportedAddOns, fieldsConfig, isRawQuery],
|
||||
);
|
||||
if (query.dataSource === DataSource.METRICS) {
|
||||
// Filter out group_by for metrics data source (handled in MetricsAggregateSection)
|
||||
filteredAddOns = filteredAddOns.filter(
|
||||
(addOn) => addOn.key !== ADD_ONS_KEYS.GROUP_BY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const offeredAddOns = useMemo(
|
||||
() =>
|
||||
supportedAddOns.filter((addOn) => !resolvedFields.get(addOn.key)?.hidden),
|
||||
[supportedAddOns, resolvedFields],
|
||||
);
|
||||
if (showReduceTo) {
|
||||
filteredAddOns = [...filteredAddOns, REDUCE_TO];
|
||||
}
|
||||
setAddOns(filteredAddOns);
|
||||
|
||||
const pinnedAddOns = useMemo(
|
||||
() => offeredAddOns.filter((addOn) => resolvedFields.get(addOn.key)?.pinned),
|
||||
[offeredAddOns, resolvedFields],
|
||||
);
|
||||
|
||||
const togglableAddOns = useMemo(
|
||||
() => offeredAddOns.filter((addOn) => !resolvedFields.get(addOn.key)?.pinned),
|
||||
[offeredAddOns, resolvedFields],
|
||||
);
|
||||
|
||||
const isPinned = useCallback(
|
||||
(key: QueryBuilderField): boolean => Boolean(resolvedFields.get(key)?.pinned),
|
||||
[resolvedFields],
|
||||
);
|
||||
|
||||
const isDisabled = useCallback(
|
||||
(key: QueryBuilderField): boolean =>
|
||||
Boolean(resolvedFields.get(key)?.disabled),
|
||||
[resolvedFields],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const availableAddOnKeys = new Set(offeredAddOns.map((a) => a.key));
|
||||
const availableAddOnKeys = new Set(filteredAddOns.map((a) => a.key));
|
||||
const previousKeys = prevAvailableKeysRef.current;
|
||||
const hasAvailabilityItemsChanged =
|
||||
previousKeys !== null &&
|
||||
@@ -267,39 +231,27 @@ function QueryAddOns({
|
||||
const activeAddOnKeys = new Set(
|
||||
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
|
||||
.filter(([, path]) => hasValue(get(query, path)))
|
||||
.map(([key]) => key as QueryBuilderField),
|
||||
.map(([key]) => key),
|
||||
);
|
||||
|
||||
// Initial seeding from query values on mount. A disabled field never opens.
|
||||
// Initial seeding from query values on mount
|
||||
setSelectedViews(
|
||||
offeredAddOns.filter((addOn) => {
|
||||
const resolved = resolvedFields.get(addOn.key);
|
||||
|
||||
return (
|
||||
resolved?.pinned ||
|
||||
(activeAddOnKeys.has(addOn.key) && !resolved?.disabled)
|
||||
);
|
||||
}),
|
||||
filteredAddOns.filter(
|
||||
(addOn) =>
|
||||
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedViews((prev) => {
|
||||
const kept = prev.filter((view) => availableAddOnKeys.has(view.key));
|
||||
|
||||
const reopenedPinned = pinnedAddOns.filter(
|
||||
(addOn) => !kept.some((view) => view.key === addOn.key),
|
||||
);
|
||||
|
||||
return [...kept, ...reopenedPinned];
|
||||
});
|
||||
}, [offeredAddOns, pinnedAddOns, query]);
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) =>
|
||||
filteredAddOns.some((addOn) => addOn.key === view.key),
|
||||
),
|
||||
);
|
||||
}, [panelType, isListViewPanel, query, showReduceTo]);
|
||||
|
||||
const handleOptionClick = (clickedAddOn: AddOn): void => {
|
||||
if (isDisabled(clickedAddOn.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isAlreadySelected = selectedViews.some(
|
||||
(view) => view.key === clickedAddOn.key,
|
||||
);
|
||||
@@ -313,7 +265,7 @@ function QueryAddOns({
|
||||
// and existing group-by keys, prefill the legend using all group-by keys.
|
||||
// This keeps existing custom legends intact and only helps seed a sensible default.
|
||||
if (
|
||||
clickedAddOn.key === QueryBuilderField.Legend &&
|
||||
clickedAddOn.key === ADD_ONS_KEYS.LEGEND_FORMAT &&
|
||||
isEmpty(query?.legend) &&
|
||||
Array.isArray(query.groupBy) &&
|
||||
query.groupBy.length > 0
|
||||
@@ -358,16 +310,9 @@ function QueryAddOns({
|
||||
[handleSetQueryData, index, query],
|
||||
);
|
||||
|
||||
const handleRemoveView = useCallback(
|
||||
(key: QueryBuilderField): void => {
|
||||
if (isPinned(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedViews((prev) => prev.filter((view) => view.key !== key));
|
||||
},
|
||||
[isPinned],
|
||||
);
|
||||
const handleRemoveView = useCallback((key: string): void => {
|
||||
setSelectedViews((prev) => prev.filter((view) => view.key !== key));
|
||||
}, []);
|
||||
|
||||
const handleChangeQueryLegend = useCallback(
|
||||
(value: string) => {
|
||||
@@ -396,7 +341,7 @@ function QueryAddOns({
|
||||
<div className="query-add-ons" data-testid="query-add-ons">
|
||||
{selectedViews.length > 0 && (
|
||||
<div className="selected-add-ons-content">
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.GroupBy) && (
|
||||
{selectedViews.find((view) => view.key === 'group_by') && (
|
||||
<div className="add-on-content" data-testid="group-by-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<Tooltip
|
||||
@@ -424,17 +369,15 @@ function QueryAddOns({
|
||||
onChange={handleChangeGroupByKeys}
|
||||
/>
|
||||
</div>
|
||||
{!isPinned(QueryBuilderField.GroupBy) && (
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView(QueryBuilderField.GroupBy)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView('group_by')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.Having) && (
|
||||
{selectedViews.find((view) => view.key === 'having') && (
|
||||
<div className="add-on-content" data-testid="having-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<Tooltip
|
||||
@@ -454,7 +397,11 @@ function QueryAddOns({
|
||||
</Tooltip>
|
||||
<div className="input">
|
||||
<HavingFilter
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Having)}
|
||||
onClose={(): void => {
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) => view.key !== 'having'),
|
||||
);
|
||||
}}
|
||||
onChange={handleChangeHaving}
|
||||
queryData={query}
|
||||
/>
|
||||
@@ -462,7 +409,7 @@ function QueryAddOns({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.Limit) && (
|
||||
{selectedViews.find((view) => view.key === 'limit') && (
|
||||
<div className="add-on-content" data-testid="limit-content">
|
||||
<InputWithLabel
|
||||
label="Limit"
|
||||
@@ -470,12 +417,16 @@ function QueryAddOns({
|
||||
onChange={handleChangeLimit}
|
||||
initialValue={query?.limit ?? undefined}
|
||||
placeholder="Enter limit"
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
|
||||
onClose={(): void => {
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) => view.key !== 'limit'),
|
||||
);
|
||||
}}
|
||||
closeIcon={<ChevronUp size={16} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.OrderBy) && (
|
||||
{selectedViews.find((view) => view.key === 'order_by') && (
|
||||
<div className="add-on-content" data-testid="order-by-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<Tooltip
|
||||
@@ -498,22 +449,22 @@ function QueryAddOns({
|
||||
entityVersion={version}
|
||||
query={query}
|
||||
onChange={handleChangeOrderByKeys}
|
||||
isRawQuery={isRawQuery}
|
||||
isListViewPanel={isListViewPanel}
|
||||
isNewQueryV2
|
||||
/>
|
||||
</div>
|
||||
{!isPinned(QueryBuilderField.OrderBy) && (
|
||||
{!isListViewPanel && (
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView(QueryBuilderField.OrderBy)}
|
||||
onClick={(): void => handleRemoveView('order_by')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.ReduceTo) &&
|
||||
{selectedViews.find((view) => view.key === 'reduce_to') &&
|
||||
showReduceTo && (
|
||||
<div className="add-on-content" data-testid="reduce-to-content">
|
||||
<div className="periscope-input-with-label">
|
||||
@@ -536,25 +487,27 @@ function QueryAddOns({
|
||||
<ReduceToFilter query={query} onChange={handleChangeReduceToV5} />
|
||||
</div>
|
||||
|
||||
{!isPinned(QueryBuilderField.ReduceTo) && (
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView(QueryBuilderField.ReduceTo)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView('reduce_to')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.Legend) && (
|
||||
{selectedViews.find((view) => view.key === 'legend_format') && (
|
||||
<div className="add-on-content" data-testid="legend-format-content">
|
||||
<InputWithLabel
|
||||
label="Legend format"
|
||||
placeholder="Write legend format"
|
||||
onChange={handleChangeQueryLegend}
|
||||
initialValue={isEmpty(query?.legend) ? undefined : query?.legend}
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Legend)}
|
||||
onClose={(): void => {
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) => view.key !== 'legend_format'),
|
||||
);
|
||||
}}
|
||||
closeIcon={<ChevronUp size={16} />}
|
||||
/>
|
||||
</div>
|
||||
@@ -567,49 +520,42 @@ function QueryAddOns({
|
||||
className="add-ons-tabs"
|
||||
value={selectedViews.map((view) => view.key)}
|
||||
onChange={(newKeys: string[]): void => {
|
||||
const oldKeys: string[] = selectedViews.map((view) => view.key);
|
||||
const oldKeys = selectedViews.map((view) => view.key);
|
||||
const toggledKey =
|
||||
newKeys.find((key) => !oldKeys.includes(key)) ??
|
||||
oldKeys.find((key) => !newKeys.includes(key));
|
||||
newKeys.find((k) => !oldKeys.includes(k)) ??
|
||||
oldKeys.find((k) => !newKeys.includes(k));
|
||||
if (!toggledKey) {
|
||||
return;
|
||||
}
|
||||
const clickedAddOn = togglableAddOns.find((a) => a.key === toggledKey);
|
||||
const clickedAddOn = addOns.find((a) => a.key === toggledKey);
|
||||
if (clickedAddOn) {
|
||||
handleOptionClick(clickedAddOn);
|
||||
}
|
||||
}}
|
||||
items={togglableAddOns.map((addOn) => {
|
||||
const resolved = resolvedFields.get(addOn.key);
|
||||
|
||||
return {
|
||||
value: addOn.key,
|
||||
label: (
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label={addOn.label}
|
||||
description={resolved?.reason ?? addOn.description}
|
||||
docLink={resolved?.disabled ? undefined : addOn.docLink}
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
items={addOns.map((addOn) => ({
|
||||
value: addOn.key,
|
||||
label: (
|
||||
<Tooltip
|
||||
title={
|
||||
<TooltipContent
|
||||
label={addOn.label}
|
||||
description={addOn.description}
|
||||
docLink={addOn.docLink}
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<span
|
||||
className="add-on-tab-title"
|
||||
data-testid={`query-add-on-${addOn.key}`}
|
||||
>
|
||||
<span
|
||||
className={cx('add-on-tab-title', {
|
||||
'add-on-tab-title--disabled': resolved?.disabled,
|
||||
})}
|
||||
aria-disabled={resolved?.disabled}
|
||||
data-testid={`query-add-on-${addOn.key}`}
|
||||
>
|
||||
{addOn.icon}
|
||||
{addOn.label}
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
};
|
||||
})}
|
||||
{addOn.icon}
|
||||
{addOn.label}
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,12 +8,6 @@ import {
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
QueryBuilderField,
|
||||
QueryBuilderFieldsConfig,
|
||||
} from '../../queryBuilderFields.types';
|
||||
import { resolveQueryBuilderField } from '../../queryBuilderFields.utils';
|
||||
|
||||
import QueryAggregationSelect from './QueryAggregationSelect';
|
||||
|
||||
import './QueryAggregation.styles.scss';
|
||||
@@ -24,32 +18,24 @@ function QueryAggregationOptions({
|
||||
onAggregationIntervalChange,
|
||||
onChange,
|
||||
queryData,
|
||||
fieldsConfig,
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
panelType?: string;
|
||||
onAggregationIntervalChange: (value: number) => void;
|
||||
onChange?: (value: string) => void;
|
||||
queryData: IBuilderQuery | IBuilderTraceOperator;
|
||||
fieldsConfig?: QueryBuilderFieldsConfig;
|
||||
}): JSX.Element {
|
||||
const stepInterval = useMemo(() => {
|
||||
const showAggregationInterval = useMemo(() => {
|
||||
if (panelType === PANEL_TYPES.VALUE) {
|
||||
return { hidden: true, disabled: false, reason: undefined };
|
||||
return false;
|
||||
}
|
||||
|
||||
const isNonMetricSource =
|
||||
dataSource === DataSource.TRACES || dataSource === DataSource.LOGS;
|
||||
|
||||
if (
|
||||
isNonMetricSource &&
|
||||
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
|
||||
) {
|
||||
return { hidden: true, disabled: false, reason: undefined };
|
||||
if (dataSource === DataSource.TRACES || dataSource === DataSource.LOGS) {
|
||||
return !(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE);
|
||||
}
|
||||
|
||||
return resolveQueryBuilderField(QueryBuilderField.StepInterval, fieldsConfig);
|
||||
}, [dataSource, panelType, fieldsConfig]);
|
||||
return true;
|
||||
}, [dataSource, panelType]);
|
||||
|
||||
const handleAggregationIntervalChange = (value: string): void => {
|
||||
onAggregationIntervalChange(Number(value));
|
||||
@@ -71,24 +57,22 @@ function QueryAggregationOptions({
|
||||
}
|
||||
/>
|
||||
|
||||
{!stepInterval.hidden && (
|
||||
{showAggregationInterval && (
|
||||
<div className="query-aggregation-interval">
|
||||
<Tooltip
|
||||
title={
|
||||
stepInterval.reason ?? (
|
||||
<div>
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
>
|
||||
Learn about step intervals
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
<div>
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
>
|
||||
Learn about step intervals
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
@@ -108,7 +92,6 @@ function QueryAggregationOptions({
|
||||
placeholder="Auto"
|
||||
type="number"
|
||||
onChange={handleAggregationIntervalChange}
|
||||
disabled={stepInterval.disabled}
|
||||
labelAfter
|
||||
/>
|
||||
</div>
|
||||
@@ -122,7 +105,6 @@ function QueryAggregationOptions({
|
||||
QueryAggregationOptions.defaultProps = {
|
||||
panelType: null,
|
||||
onChange: undefined,
|
||||
fieldsConfig: undefined,
|
||||
};
|
||||
|
||||
export default QueryAggregationOptions;
|
||||
|
||||
@@ -17,13 +17,13 @@ function TraceOperatorSection({
|
||||
const { currentQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const showTraceOperatorWarning = useMemo(() => {
|
||||
const isRawQueryPanel =
|
||||
const isListViewPanel =
|
||||
panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE;
|
||||
const hasMultipleQueries = currentQuery.builder.queryData.length > 1;
|
||||
const hasTraceOperator =
|
||||
currentQuery.builder.queryTraceOperator &&
|
||||
currentQuery.builder.queryTraceOperator.length > 0;
|
||||
return isRawQueryPanel && hasMultipleQueries && !hasTraceOperator;
|
||||
return isListViewPanel && hasMultipleQueries && !hasTraceOperator;
|
||||
}, [
|
||||
currentQuery?.builder?.queryData,
|
||||
currentQuery?.builder?.queryTraceOperator,
|
||||
@@ -77,74 +77,50 @@ export default function QueryFooter({
|
||||
addNewBuilderQuery,
|
||||
addNewFormula,
|
||||
addTraceOperator,
|
||||
showAddQuery = true,
|
||||
showAddFormula = true,
|
||||
showAddTraceOperator = false,
|
||||
isAddQueryDisabled = false,
|
||||
addQueryDisabledReason,
|
||||
isAddFormulaDisabled = false,
|
||||
addFormulaDisabledReason,
|
||||
}: {
|
||||
addNewBuilderQuery: () => void;
|
||||
addNewFormula: () => void;
|
||||
addTraceOperator?: () => void;
|
||||
showAddTraceOperator: boolean;
|
||||
showAddQuery?: boolean;
|
||||
showAddFormula?: boolean;
|
||||
isAddQueryDisabled?: boolean;
|
||||
addQueryDisabledReason?: string;
|
||||
isAddFormulaDisabled?: boolean;
|
||||
addFormulaDisabledReason?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="qb-footer">
|
||||
<div className="qb-footer-container">
|
||||
{showAddQuery && (
|
||||
<div className="qb-add-new-query">
|
||||
<Tooltip
|
||||
title={
|
||||
addQueryDisabledReason ?? (
|
||||
<div style={{ textAlign: 'center' }}>Add New Query</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="add-new-query-button periscope-btn "
|
||||
data-testid="add-new-query-button"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={addNewBuilderQuery}
|
||||
disabled={isAddQueryDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="qb-add-new-query">
|
||||
<Tooltip title={<div style={{ textAlign: 'center' }}>Add New Query</div>}>
|
||||
<Button
|
||||
className="add-new-query-button periscope-btn "
|
||||
icon={<Plus size={16} />}
|
||||
onClick={addNewBuilderQuery}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{showAddFormula && (
|
||||
<div className="qb-add-formula">
|
||||
<Tooltip
|
||||
title={
|
||||
addFormulaDisabledReason ?? (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
Add New Formula
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/querying/multi-query-analysis/#advanced-comparisons"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{' '}
|
||||
<br />
|
||||
Learn more
|
||||
</Typography.Link>
|
||||
</div>
|
||||
)
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
Add New Formula
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/querying/multi-query-analysis/#advanced-comparisons"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{' '}
|
||||
<br />
|
||||
Learn more
|
||||
</Typography.Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="add-formula-button periscope-btn "
|
||||
data-testid="add-formula-button"
|
||||
icon={<Sigma size={16} />}
|
||||
onClick={addNewFormula}
|
||||
disabled={isAddFormulaDisabled}
|
||||
>
|
||||
Add Formula
|
||||
</Button>
|
||||
|
||||
@@ -20,13 +20,6 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderField } from '../queryBuilderFields.types';
|
||||
import {
|
||||
mergeQueryBuilderFieldsConfig,
|
||||
RAW_QUERY_FIELDS,
|
||||
resolveQueryBuilderField,
|
||||
} from '../queryBuilderFields.utils';
|
||||
|
||||
import MetricsAggregateSection from './MerticsAggregateSection/MetricsAggregateSection';
|
||||
import { MetricsSelect } from './MetricsSelect/MetricsSelect';
|
||||
import QueryAddOns from './QueryAddOns/QueryAddOns';
|
||||
@@ -38,7 +31,8 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
index,
|
||||
queryVariant,
|
||||
query,
|
||||
isRawQuery = false,
|
||||
filterConfigs,
|
||||
isListViewPanel = false,
|
||||
showTraceOperator = false,
|
||||
hasTraceOperator = false,
|
||||
version,
|
||||
@@ -49,8 +43,6 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
signalSourceChangeEnabled = false,
|
||||
queriesCount = 1,
|
||||
savePreviousQuery = false,
|
||||
fieldsConfig,
|
||||
allowedDataSources,
|
||||
}: QueryProps & {
|
||||
onSignalSourceChange: (value: string) => void;
|
||||
signalSourceChangeEnabled: boolean;
|
||||
@@ -61,7 +53,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
): JSX.Element {
|
||||
const { cloneQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const hasQueryFunctions = query?.functions?.length > 0;
|
||||
const showFunctions = query?.functions?.length > 0;
|
||||
const { dataSource, builderQueryType } = query;
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
@@ -74,7 +66,8 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
} = useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
isRawQuery,
|
||||
filterConfigs,
|
||||
isListViewPanel,
|
||||
entityVersion: version,
|
||||
savePreviousQuery,
|
||||
});
|
||||
@@ -106,31 +99,14 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
[dataSource, builderQueryType],
|
||||
);
|
||||
|
||||
const resolvedConfig = useMemo(
|
||||
() =>
|
||||
mergeQueryBuilderFieldsConfig(
|
||||
isRawQuery ? RAW_QUERY_FIELDS : undefined,
|
||||
fieldsConfig,
|
||||
),
|
||||
[isRawQuery, fieldsConfig],
|
||||
);
|
||||
|
||||
const aggregation = useMemo(
|
||||
() => resolveQueryBuilderField(QueryBuilderField.Aggregation, resolvedConfig),
|
||||
[resolvedConfig],
|
||||
);
|
||||
|
||||
const functions = useMemo(
|
||||
() => resolveQueryBuilderField(QueryBuilderField.Functions, resolvedConfig),
|
||||
[resolvedConfig],
|
||||
);
|
||||
|
||||
const showInlineQuerySearch = useMemo(() => {
|
||||
if (!showTraceOperator) {
|
||||
return false;
|
||||
}
|
||||
return dataSource === DataSource.TRACES && (hasTraceOperator || isRawQuery);
|
||||
}, [hasTraceOperator, isRawQuery, showTraceOperator, dataSource]);
|
||||
return (
|
||||
dataSource === DataSource.TRACES && (hasTraceOperator || isListViewPanel)
|
||||
);
|
||||
}, [hasTraceOperator, isListViewPanel, showTraceOperator, dataSource]);
|
||||
|
||||
const handleChangeAggregateEvery = useCallback(
|
||||
(value: IBuilderQuery['stepInterval']) => {
|
||||
@@ -173,15 +149,12 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
hasTraceOperator={hasTraceOperator}
|
||||
isMetricsDataSource={dataSource === DataSource.METRICS}
|
||||
showFunctions={
|
||||
!functions.hidden &&
|
||||
((version && version === ENTITY_VERSION_V4) ||
|
||||
query.dataSource === DataSource.LOGS ||
|
||||
query.dataSource === DataSource.METRICS ||
|
||||
hasQueryFunctions ||
|
||||
false)
|
||||
(version && version === ENTITY_VERSION_V4) ||
|
||||
query.dataSource === DataSource.LOGS ||
|
||||
query.dataSource === DataSource.METRICS ||
|
||||
showFunctions ||
|
||||
false
|
||||
}
|
||||
areFunctionsDisabled={functions.disabled}
|
||||
functionsDisabledReason={functions.reason}
|
||||
isCollapsed={isCollapsed}
|
||||
showTraceOperator={showTraceOperator}
|
||||
entityType="query"
|
||||
@@ -194,8 +167,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
onQueryFunctionsUpdates={handleQueryFunctionsUpdates}
|
||||
showDeleteButton={false}
|
||||
showCloneOption={false}
|
||||
isRawQuery={isRawQuery}
|
||||
allowedDataSources={allowedDataSources}
|
||||
isListViewPanel={isListViewPanel}
|
||||
index={index}
|
||||
queryVariant={queryVariant}
|
||||
onChangeDataSource={handleChangeDataSource}
|
||||
@@ -295,7 +267,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
</div>
|
||||
|
||||
{!showOnlyWhereClause &&
|
||||
!aggregation.hidden &&
|
||||
!isListViewPanel &&
|
||||
!(hasTraceOperator && dataSource === DataSource.TRACES) &&
|
||||
dataSource !== DataSource.METRICS && (
|
||||
<QueryAggregation
|
||||
@@ -305,7 +277,6 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
onAggregationIntervalChange={handleChangeAggregateEvery}
|
||||
onChange={handleChangeAggregation}
|
||||
queryData={query}
|
||||
fieldsConfig={fieldsConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -326,10 +297,9 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
index={index}
|
||||
query={query}
|
||||
version="v3"
|
||||
isRawQuery={isRawQuery}
|
||||
isListViewPanel={isListViewPanel}
|
||||
showReduceTo={showReduceTo}
|
||||
panelType={panelType}
|
||||
fieldsConfig={fieldsConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderFieldsConfig } from '../../queryBuilderFields.types';
|
||||
import QueryAddOns from '../QueryAddOns/QueryAddOns';
|
||||
import QueryAggregation from '../QueryAggregation/QueryAggregation';
|
||||
import TraceOperatorEditor from './TraceOperatorEditor';
|
||||
@@ -20,12 +19,10 @@ import './TraceOperator.styles.scss';
|
||||
|
||||
export default function TraceOperator({
|
||||
traceOperator,
|
||||
isRawQuery = false,
|
||||
fieldsConfig,
|
||||
isListViewPanel = false,
|
||||
}: {
|
||||
traceOperator: IBuilderTraceOperator;
|
||||
isRawQuery?: boolean;
|
||||
fieldsConfig?: QueryBuilderFieldsConfig;
|
||||
isListViewPanel?: boolean;
|
||||
}): JSX.Element {
|
||||
const { panelType, removeTraceOperator } = useQueryBuilder();
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
@@ -61,12 +58,12 @@ export default function TraceOperator({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx('qb-trace-operator', !isRawQuery && 'non-list-view')}>
|
||||
<div className={cx('qb-trace-operator', !isListViewPanel && 'non-list-view')}>
|
||||
<div className="qb-trace-operator-container">
|
||||
<div
|
||||
className={cx(
|
||||
'qb-trace-operator-label-with-input',
|
||||
!isRawQuery && 'qb-trace-operator-arrow',
|
||||
!isListViewPanel && 'qb-trace-operator-arrow',
|
||||
)}
|
||||
>
|
||||
<Typography.Text className="label">Trace Operator</Typography.Text>
|
||||
@@ -79,9 +76,9 @@ export default function TraceOperator({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isRawQuery && (
|
||||
{!isListViewPanel && (
|
||||
<div className="qb-trace-operator-aggregation-container">
|
||||
<div className={cx(!isRawQuery && 'qb-trace-operator-arrow')}>
|
||||
<div className={cx(!isListViewPanel && 'qb-trace-operator-arrow')}>
|
||||
<QueryAggregation
|
||||
dataSource={DataSource.TRACES}
|
||||
key={`query-search-${traceOperator.queryName}`}
|
||||
@@ -89,13 +86,12 @@ export default function TraceOperator({
|
||||
onAggregationIntervalChange={handleChangeAggregateEvery}
|
||||
onChange={handleChangeAggregation}
|
||||
queryData={traceOperator}
|
||||
fieldsConfig={fieldsConfig}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
'qb-trace-operator-add-ons-container',
|
||||
!isRawQuery && 'qb-trace-operator-arrow',
|
||||
!isListViewPanel && 'qb-trace-operator-arrow',
|
||||
)}
|
||||
>
|
||||
<QueryAddOns
|
||||
@@ -103,10 +99,9 @@ export default function TraceOperator({
|
||||
query={traceOperator}
|
||||
version="v3"
|
||||
isForTraceOperator
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo={false}
|
||||
panelType={panelType}
|
||||
fieldsConfig={fieldsConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -142,6 +142,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
isMetricsDataSource: false,
|
||||
operators: [],
|
||||
spaceAggregationOptions: [],
|
||||
listOfAdditionalFilters: [],
|
||||
handleChangeOperator: jest.fn(),
|
||||
handleSpaceAggregationChange: jest.fn(),
|
||||
handleChangeAggregatorAttribute: jest.fn(),
|
||||
@@ -151,6 +152,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
jest.fn() as unknown as ReturnType<UseQueryOperations>['handleChangeQueryData'],
|
||||
handleChangeFormulaData: jest.fn(),
|
||||
handleQueryFunctionsUpdates: handleQueryFunctionsUpdatesMock,
|
||||
listOfAdditionalFormulaFilters: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={baseQuery()}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.VALUE}
|
||||
index={0}
|
||||
@@ -119,7 +119,7 @@ describe('QueryAddOns', () => {
|
||||
groupBy: ['service.name'],
|
||||
})}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -135,7 +135,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={baseQuery()}
|
||||
version="v5"
|
||||
isRawQuery
|
||||
isListViewPanel
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.LIST}
|
||||
index={0}
|
||||
@@ -151,7 +151,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={baseQuery({ limit: 5 })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -176,7 +176,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -195,7 +195,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={baseQuery()}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -211,7 +211,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={baseQuery({ reduceTo: ReduceOperators.SUM })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -234,7 +234,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -286,7 +286,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -314,7 +314,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
isListViewPanel={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import QueryFooter from '../QueryV2/QueryFooter/QueryFooter';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): {
|
||||
currentQuery: { builder: { queryData: unknown[] } };
|
||||
panelType: string;
|
||||
} => ({
|
||||
currentQuery: { builder: { queryData: [] } },
|
||||
panelType: 'time_series',
|
||||
}),
|
||||
}));
|
||||
|
||||
const noop = (): void => {};
|
||||
|
||||
describe('QueryFooter', () => {
|
||||
it('offers both buttons by default', () => {
|
||||
render(
|
||||
<QueryFooter
|
||||
addNewBuilderQuery={noop}
|
||||
addNewFormula={noop}
|
||||
showAddTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('add-new-query-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('add-formula-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// A kind whose request takes a single query (Heatmap) hides the button outright
|
||||
// rather than disabling it — a query it adds is one the builder cannot render.
|
||||
it('drops the Add New Query button when the caller withholds it', () => {
|
||||
render(
|
||||
<QueryFooter
|
||||
addNewBuilderQuery={noop}
|
||||
addNewFormula={noop}
|
||||
showAddQuery={false}
|
||||
showAddTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('add-new-query-button')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('add-formula-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
import { QueryBuilderField } from '../queryBuilderFields.types';
|
||||
import {
|
||||
mergeQueryBuilderFieldsConfig,
|
||||
RAW_QUERY_FIELDS,
|
||||
resolveQueryBuilderField,
|
||||
resolveQueryBuilderFields,
|
||||
} from '../queryBuilderFields.utils';
|
||||
|
||||
const SUPPORTED = [
|
||||
QueryBuilderField.GroupBy,
|
||||
QueryBuilderField.Having,
|
||||
QueryBuilderField.OrderBy,
|
||||
QueryBuilderField.Limit,
|
||||
QueryBuilderField.Legend,
|
||||
];
|
||||
|
||||
describe('resolveQueryBuilderField', () => {
|
||||
it('leaves an unconfigured field available', () => {
|
||||
expect(resolveQueryBuilderField(QueryBuilderField.Having)).toStrictEqual({
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
pinned: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides a field configured hidden', () => {
|
||||
const resolved = resolveQueryBuilderField(QueryBuilderField.Having, {
|
||||
[QueryBuilderField.Having]: { state: 'hidden' },
|
||||
});
|
||||
|
||||
expect(resolved.hidden).toBe(true);
|
||||
expect(resolved.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('carries the reason through on a disabled field', () => {
|
||||
const resolved = resolveQueryBuilderField(QueryBuilderField.Having, {
|
||||
[QueryBuilderField.Having]: {
|
||||
state: 'disabled',
|
||||
reason: 'Having filters aggregated results.',
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).toStrictEqual({
|
||||
hidden: false,
|
||||
disabled: true,
|
||||
reason: 'Having filters aggregated results.',
|
||||
pinned: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('pins a field configured pinned', () => {
|
||||
const resolved = resolveQueryBuilderField(QueryBuilderField.OrderBy, {
|
||||
[QueryBuilderField.OrderBy]: { state: 'pinned' },
|
||||
});
|
||||
|
||||
expect(resolved.pinned).toBe(true);
|
||||
expect(resolved.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('only ever resolves one state at a time', () => {
|
||||
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
|
||||
[QueryBuilderField.Limit]: { state: 'disabled', reason: 'why' },
|
||||
});
|
||||
|
||||
expect([resolved.hidden, resolved.disabled, resolved.pinned]).toStrictEqual([
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveQueryBuilderFields', () => {
|
||||
it('resolves every supported field and nothing else', () => {
|
||||
const resolved = resolveQueryBuilderFields(SUPPORTED);
|
||||
|
||||
expect([...resolved.keys()]).toStrictEqual(SUPPORTED);
|
||||
});
|
||||
|
||||
it('cannot widen beyond what the builder supports', () => {
|
||||
const resolved = resolveQueryBuilderFields([QueryBuilderField.Legend], {
|
||||
[QueryBuilderField.ReduceTo]: { state: 'pinned' },
|
||||
});
|
||||
|
||||
expect(resolved.has(QueryBuilderField.ReduceTo)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeQueryBuilderFieldsConfig', () => {
|
||||
it('returns the override when there is no baseline', () => {
|
||||
const override = { [QueryBuilderField.Limit]: { state: 'hidden' } } as const;
|
||||
|
||||
expect(mergeQueryBuilderFieldsConfig(undefined, override)).toBe(override);
|
||||
});
|
||||
|
||||
it('returns the baseline when there is no override', () => {
|
||||
expect(mergeQueryBuilderFieldsConfig(RAW_QUERY_FIELDS, undefined)).toBe(
|
||||
RAW_QUERY_FIELDS,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the override win per field, leaving the rest of the baseline intact', () => {
|
||||
const merged = mergeQueryBuilderFieldsConfig(RAW_QUERY_FIELDS, {
|
||||
[QueryBuilderField.Having]: { state: 'disabled', reason: 'no aggregation' },
|
||||
});
|
||||
|
||||
expect(merged?.[QueryBuilderField.Having]).toStrictEqual({
|
||||
state: 'disabled',
|
||||
reason: 'no aggregation',
|
||||
});
|
||||
expect(merged?.[QueryBuilderField.GroupBy]).toStrictEqual({
|
||||
state: 'hidden',
|
||||
});
|
||||
expect(merged?.[QueryBuilderField.OrderBy]).toStrictEqual({
|
||||
state: 'pinned',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RAW_QUERY_FIELDS', () => {
|
||||
it('reduces an aggregate surface to a pinned order by', () => {
|
||||
const resolved = resolveQueryBuilderFields(SUPPORTED, RAW_QUERY_FIELDS);
|
||||
|
||||
const visible = [...resolved.entries()]
|
||||
.filter(([, field]) => !field.hidden)
|
||||
.map(([key]) => key);
|
||||
|
||||
expect(visible).toStrictEqual([QueryBuilderField.OrderBy]);
|
||||
expect(resolved.get(QueryBuilderField.OrderBy)?.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves additional queries alone, so trace matching still allows several', () => {
|
||||
expect(
|
||||
resolveQueryBuilderField(
|
||||
QueryBuilderField.AdditionalQueries,
|
||||
RAW_QUERY_FIELDS,
|
||||
).hidden,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Everything the query builder can surface.
|
||||
*
|
||||
* The per-query values double as the add-on identities the builder renders
|
||||
* (`data-testid="query-add-on-<value>"`), so they are part of the DOM contract and must
|
||||
* not be renamed to match the member names.
|
||||
*/
|
||||
export enum QueryBuilderField {
|
||||
// Per query
|
||||
Aggregation = 'aggregation',
|
||||
StepInterval = 'step_interval',
|
||||
Functions = 'functions',
|
||||
GroupBy = 'group_by',
|
||||
Having = 'having',
|
||||
OrderBy = 'order_by',
|
||||
Limit = 'limit',
|
||||
Legend = 'legend_format',
|
||||
ReduceTo = 'reduce_to',
|
||||
// Builder level
|
||||
Formula = 'formula',
|
||||
AdditionalQueries = 'additional_queries',
|
||||
}
|
||||
|
||||
/** `reason` is required on `disabled`: an inert control the user can see has to explain itself. */
|
||||
export type QueryBuilderFieldRule =
|
||||
| { state: 'hidden' }
|
||||
| { state: 'disabled'; reason: string }
|
||||
| { state: 'pinned' };
|
||||
|
||||
/**
|
||||
* A caller's narrowing of the builder's surface. The builder works out which fields suit
|
||||
* the current data source and panel type first; this can only take away from that set.
|
||||
*/
|
||||
export type QueryBuilderFieldsConfig = Partial<
|
||||
Record<QueryBuilderField, QueryBuilderFieldRule>
|
||||
>;
|
||||
@@ -1,92 +0,0 @@
|
||||
import {
|
||||
QueryBuilderField,
|
||||
QueryBuilderFieldRule,
|
||||
QueryBuilderFieldsConfig,
|
||||
} from './queryBuilderFields.types';
|
||||
|
||||
export interface ResolvedQueryBuilderField {
|
||||
hidden: boolean;
|
||||
disabled: boolean;
|
||||
reason?: string;
|
||||
/** Rendered open, not dismissable, and kept out of the add-on toggle bar. */
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
const AVAILABLE: ResolvedQueryBuilderField = {
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
pinned: false,
|
||||
};
|
||||
|
||||
function fromRule(rule: QueryBuilderFieldRule): ResolvedQueryBuilderField {
|
||||
switch (rule.state) {
|
||||
case 'hidden':
|
||||
return { hidden: true, disabled: false, pinned: false };
|
||||
case 'disabled':
|
||||
return {
|
||||
hidden: false,
|
||||
disabled: true,
|
||||
reason: rule.reason,
|
||||
pinned: false,
|
||||
};
|
||||
case 'pinned':
|
||||
return { hidden: false, disabled: false, pinned: true };
|
||||
default:
|
||||
return AVAILABLE;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveQueryBuilderField(
|
||||
field: QueryBuilderField,
|
||||
config?: QueryBuilderFieldsConfig,
|
||||
): ResolvedQueryBuilderField {
|
||||
const rule = config?.[field];
|
||||
|
||||
return rule ? fromRule(rule) : AVAILABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields absent from `supported` are hidden whatever the config says, so a config can
|
||||
* only ever take away.
|
||||
*/
|
||||
export function resolveQueryBuilderFields(
|
||||
supported: readonly QueryBuilderField[],
|
||||
config?: QueryBuilderFieldsConfig,
|
||||
): Map<QueryBuilderField, ResolvedQueryBuilderField> {
|
||||
return new Map(
|
||||
supported.map((field) => [field, resolveQueryBuilderField(field, config)]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The surface a raw-row builder starts from, layered under a caller's own config.
|
||||
* `AdditionalQueries` is deliberately absent — a raw trace builder still takes several
|
||||
* queries when trace matching is on. Omit rather than Partial, so a field added to the
|
||||
* enum has to be placed on one side.
|
||||
*/
|
||||
export const RAW_QUERY_FIELDS: Omit<
|
||||
Record<QueryBuilderField, QueryBuilderFieldRule>,
|
||||
QueryBuilderField.AdditionalQueries
|
||||
> = {
|
||||
[QueryBuilderField.Aggregation]: { state: 'hidden' },
|
||||
[QueryBuilderField.StepInterval]: { state: 'hidden' },
|
||||
[QueryBuilderField.Functions]: { state: 'hidden' },
|
||||
[QueryBuilderField.GroupBy]: { state: 'hidden' },
|
||||
[QueryBuilderField.Having]: { state: 'hidden' },
|
||||
[QueryBuilderField.Limit]: { state: 'hidden' },
|
||||
[QueryBuilderField.Legend]: { state: 'hidden' },
|
||||
[QueryBuilderField.ReduceTo]: { state: 'hidden' },
|
||||
[QueryBuilderField.Formula]: { state: 'hidden' },
|
||||
[QueryBuilderField.OrderBy]: { state: 'pinned' },
|
||||
};
|
||||
|
||||
export function mergeQueryBuilderFieldsConfig(
|
||||
baseline: QueryBuilderFieldsConfig | undefined,
|
||||
override: QueryBuilderFieldsConfig | undefined,
|
||||
): QueryBuilderFieldsConfig | undefined {
|
||||
if (!baseline) {
|
||||
return override;
|
||||
}
|
||||
|
||||
return override ? { ...baseline, ...override } : baseline;
|
||||
}
|
||||
@@ -29,7 +29,6 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.LIST]:
|
||||
dataSource === DataSource.LOGS ? LogsPanelComponent : TracesTableComponent,
|
||||
[PANEL_TYPES.BAR]: Uplot,
|
||||
[PANEL_TYPES.AREA]: Uplot,
|
||||
[PANEL_TYPES.PIE]: null,
|
||||
[PANEL_TYPES.HISTOGRAM]: Uplot,
|
||||
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
MeterAggregateOperator,
|
||||
MetricAggregateOperator,
|
||||
NumberOperators,
|
||||
QueryAdditionalFilter,
|
||||
QueryBuilderData,
|
||||
ReduceOperators,
|
||||
StringOperators,
|
||||
@@ -103,6 +104,43 @@ export const metricsSpaceAggregationOperatorsByType = {
|
||||
ExponentialHistogram: metricsHistogramSpaceAggregateOperatorOptions,
|
||||
};
|
||||
|
||||
export const mapOfQueryFilters: Record<DataSource, QueryAdditionalFilter[]> = {
|
||||
metrics: [
|
||||
{ text: 'Aggregation interval', field: 'stepInterval' },
|
||||
{ text: 'Having', field: 'having' },
|
||||
],
|
||||
logs: [
|
||||
{ text: 'Order by', field: 'orderBy' },
|
||||
{ text: 'Limit', field: 'limit' },
|
||||
{ text: 'Having', field: 'having' },
|
||||
{ text: 'Aggregation interval', field: 'stepInterval' },
|
||||
],
|
||||
traces: [
|
||||
{ text: 'Order by', field: 'orderBy' },
|
||||
{ text: 'Limit', field: 'limit' },
|
||||
{ text: 'Having', field: 'having' },
|
||||
{ text: 'Aggregation interval', field: 'stepInterval' },
|
||||
],
|
||||
};
|
||||
|
||||
const commonFormulaFilters: QueryAdditionalFilter[] = [
|
||||
{
|
||||
text: 'Having',
|
||||
field: 'having',
|
||||
},
|
||||
{ text: 'Order by', field: 'orderBy' },
|
||||
{ text: 'Limit', field: 'limit' },
|
||||
];
|
||||
|
||||
export const mapOfFormulaToFilters: Record<
|
||||
DataSource,
|
||||
QueryAdditionalFilter[]
|
||||
> = {
|
||||
metrics: commonFormulaFilters,
|
||||
logs: commonFormulaFilters,
|
||||
traces: commonFormulaFilters,
|
||||
};
|
||||
|
||||
export const REDUCE_TO_VALUES: SelectOption<ReduceOperators, string>[] = [
|
||||
{ value: ReduceOperators.LAST, label: 'Latest of values in timeframe' },
|
||||
{ value: ReduceOperators.SUM, label: 'Sum of values in timeframe' },
|
||||
@@ -336,7 +374,6 @@ export enum PANEL_TYPES {
|
||||
LIST = 'list',
|
||||
TRACE = 'trace',
|
||||
BAR = 'bar',
|
||||
AREA = 'area',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
TEXT = 'text',
|
||||
|
||||
@@ -10,10 +10,16 @@ import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { initialQueryAIWithType } from 'constants/queryBuilder';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
@@ -22,6 +28,7 @@ import {
|
||||
useHandleExplorerTabChange,
|
||||
} from 'hooks/useHandleExplorerTabChange';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
@@ -30,7 +37,7 @@ import {
|
||||
tracesChangeViewAction,
|
||||
tracesRunQueryAction,
|
||||
tracesSaveViewAction,
|
||||
} from './aiActions';
|
||||
} from 'pages/TracesExplorer/aiActions';
|
||||
import { Warning } from 'types/api';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -38,10 +45,12 @@ import {
|
||||
explorerViewToPanelType,
|
||||
getExplorerViewFromUrl,
|
||||
} from 'utils/explorerUtils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import LeftToolbarActions from '../ToolbarActions/LeftToolbarActions';
|
||||
import { DEFAULT_PANEL_TYPE, TOOLBAR_VIEWS } from './constants';
|
||||
import { TOOLBAR_VIEWS } from './constants';
|
||||
import { getExportQueryData, getQueryByPanelType } from './explorerUtils';
|
||||
import ListView from './ListView/ListView';
|
||||
import { defaultSelectedColumns } from './ListView/configs';
|
||||
import QuerySection from './QuerySection/QuerySection';
|
||||
import TableView from './TableView/TableView';
|
||||
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
|
||||
@@ -51,6 +60,7 @@ import './Explorer.styles.scss';
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
const {
|
||||
panelType,
|
||||
updateAllQueriesOperators,
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
@@ -62,12 +72,20 @@ function Explorer(): JSX.Element {
|
||||
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const listQueryKeyRef = useRef<any>();
|
||||
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
@@ -98,13 +116,15 @@ function Explorer(): JSX.Element {
|
||||
(): Query =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueryAIWithType,
|
||||
DEFAULT_PANEL_TYPE,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.TRACES,
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const handleChangeSelectedView = useCallback(
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
@@ -119,7 +139,7 @@ function Explorer(): JSX.Element {
|
||||
},
|
||||
[handleExplorerTabChange, handleSetConfig],
|
||||
);
|
||||
//TODO: check if we need to enable AI Assistant page actions on LLM o11y
|
||||
|
||||
// ─── AI Assistant page actions (only when license feature is on) ───────────
|
||||
const aiActions = useMemo(
|
||||
() =>
|
||||
@@ -159,6 +179,59 @@ function Explorer(): JSX.Element {
|
||||
usePageActions('traces-explorer', aiActions);
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
getQueryByPanelType(
|
||||
stagedQuery || initialQueryAIWithType,
|
||||
panelType || PANEL_TYPES.LIST,
|
||||
),
|
||||
[stagedQuery, panelType],
|
||||
);
|
||||
|
||||
const handleExport = useCallback(
|
||||
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
|
||||
if (!dashboard || !panelType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
|
||||
? panelType
|
||||
: PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
const widgetId = v4();
|
||||
|
||||
const query = getExportQueryData(
|
||||
exportDefaultQuery,
|
||||
panelTypeParam,
|
||||
options,
|
||||
);
|
||||
|
||||
logEvent('Traces Explorer: Add to dashboard successful', {
|
||||
panelType,
|
||||
isNewDashboard,
|
||||
dashboardName: dashboard?.title,
|
||||
});
|
||||
|
||||
const dashboardEditView = getExportToDashboardLink({
|
||||
query,
|
||||
panelType: panelTypeParam,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId,
|
||||
});
|
||||
|
||||
if (dashboardEditView) {
|
||||
safeNavigate(dashboardEditView);
|
||||
}
|
||||
},
|
||||
[
|
||||
exportDefaultQuery,
|
||||
panelType,
|
||||
safeNavigate,
|
||||
options,
|
||||
getExportToDashboardLink,
|
||||
],
|
||||
);
|
||||
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
@@ -281,6 +354,14 @@ function Explorer(): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.TRACES}
|
||||
onExport={handleExport}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
@@ -12,17 +12,25 @@ import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { getTraceLink, transformSpanRows } from './utils';
|
||||
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import TracesTable from '../TracesTable/TracesTable';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import {
|
||||
getTraceLink,
|
||||
transformSpanRows,
|
||||
} from 'container/TracesExplorer/ListView/utils';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
@@ -34,7 +42,6 @@ import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import {
|
||||
defaultSelectedColumns,
|
||||
@@ -72,6 +79,14 @@ function ListView({
|
||||
loading: timeRangeUpdateLoading,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
@@ -83,6 +98,19 @@ function ListView({
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// Stable sorted-name signature for the queryKey.
|
||||
// - Drag updates selectColumns; raw queryKey would churn on reorder.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch.
|
||||
// - Sorted-name signature: stable on reorder, changes on add/remove.
|
||||
const selectColumnsSignature = useMemo(
|
||||
() =>
|
||||
(options?.selectColumns ?? [])
|
||||
.map((c) => c.name)
|
||||
.sort()
|
||||
.join(','),
|
||||
[options?.selectColumns],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
@@ -92,6 +120,7 @@ function ListView({
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
@@ -99,6 +128,7 @@ function ListView({
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
maxTime,
|
||||
minTime,
|
||||
orderBy,
|
||||
@@ -120,7 +150,7 @@ function ListView({
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
selectColumns: defaultSelectedColumns,
|
||||
selectColumns: options?.selectColumns,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
@@ -128,7 +158,10 @@ function ListView({
|
||||
queryKey,
|
||||
enabled:
|
||||
// don't make api call while the time range state in redux is loading
|
||||
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
|
||||
!timeRangeUpdateLoading &&
|
||||
!!stagedQuery &&
|
||||
panelType === PANEL_TYPES.LIST &&
|
||||
!!options?.selectColumns?.length,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -153,20 +186,28 @@ function ListView({
|
||||
[queryTableDataResult],
|
||||
);
|
||||
|
||||
// TODO(ai-explorer): static columns until the preferences framework lands.
|
||||
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(
|
||||
() =>
|
||||
[TIMESTAMP_FIELD, ...defaultSelectedColumns].map((field) =>
|
||||
getFieldColumn(field),
|
||||
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
|
||||
const fields = [
|
||||
TIMESTAMP_FIELD,
|
||||
...(options?.selectColumns ?? []).filter(
|
||||
(field) => field.name !== TIMESTAMP_FIELD.name,
|
||||
),
|
||||
[],
|
||||
);
|
||||
];
|
||||
return fields.map((field) => getFieldColumn(field));
|
||||
}, [options?.selectColumns]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => transformSpanRows(queryTableData),
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(reordered: TableColumnDef<TracesTableRow>[]): void => {
|
||||
config?.addColumn?.onReorder(reordered.map((column) => column.id));
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
@@ -194,9 +235,15 @@ function ListView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isFetching}
|
||||
totalCount={rows.length}
|
||||
config={config}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
@@ -204,8 +251,6 @@ function ListView({
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
|
||||
respectColumnOrder
|
||||
panelType="LIST"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
@@ -213,6 +258,8 @@ function ListView({
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,41 +1,19 @@
|
||||
import type { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const defaultSelectedColumns: string[] = [
|
||||
'service.name',
|
||||
'name',
|
||||
'duration_nano',
|
||||
'http_method',
|
||||
'response_status_code',
|
||||
'timestamp',
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
// Pinned timestamp column
|
||||
// The list query returns timestamp, trace_id and span_id whether or not they are selected.
|
||||
export const TIMESTAMP_FIELD = {
|
||||
name: 'timestamp',
|
||||
fieldContext: 'span',
|
||||
} as TelemetryFieldKey;
|
||||
|
||||
export const defaultSelectedColumns: TelemetryFieldKey[] = [
|
||||
{
|
||||
name: 'service.name',
|
||||
signal: 'traces',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
{
|
||||
name: 'duration_nano',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
{
|
||||
name: 'http_method',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
{
|
||||
name: 'response_status_code',
|
||||
signal: 'traces',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { formUrlParams } from 'container/TraceDetail/utils';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
export function BlockLink({
|
||||
children,
|
||||
to,
|
||||
openInNewTab,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to: string;
|
||||
openInNewTab: boolean;
|
||||
}): any {
|
||||
// Display block to make the whole cell clickable
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
style={{ display: 'block' }}
|
||||
target={openInNewTab ? '_blank' : '_self'}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export const transformDataWithDate = (
|
||||
data: QueryDataV3[],
|
||||
): Omit<ILog, 'timestamp'>[] =>
|
||||
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
|
||||
[];
|
||||
|
||||
export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
function readId(value: unknown): string {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
@@ -21,6 +60,95 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
})}`;
|
||||
};
|
||||
|
||||
export const getListColumns = (
|
||||
selectedColumns: TelemetryFieldKey[],
|
||||
formatTimezoneAdjustedTimestamp: (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
) => string | number,
|
||||
): ColumnsType<RowData> => {
|
||||
const initialColumns: ColumnsType<RowData> = [
|
||||
{
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
title: 'Timestamp',
|
||||
width: 145,
|
||||
render: (value, item): JSX.Element => {
|
||||
const date =
|
||||
typeof value === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
value,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
value / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography.Text>{date}</Typography.Text>
|
||||
</BlockLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const columns: ColumnsType<RowData> =
|
||||
selectedColumns.map((props) => {
|
||||
const name = props?.name || (props as any)?.key;
|
||||
const fieldContext = props?.fieldContext || (props as any)?.type;
|
||||
return {
|
||||
title: name,
|
||||
dataIndex: name,
|
||||
key: buildCompositeKey(name, fieldContext),
|
||||
width: 145,
|
||||
render: (value, item): JSX.Element => {
|
||||
if (value === '') {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography data-testid={name}>N/A</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
name === 'httpMethod' ||
|
||||
name === 'responseStatusCode' ||
|
||||
name === 'response_status_code' ||
|
||||
name === 'http_method'
|
||||
) {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Badge data-testid={name} color="sakura" variant="outline">
|
||||
{value}
|
||||
</Badge>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'durationNano' || name === 'duration_nano') {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography data-testid={name}>{getMs(value)}ms</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography data-testid={name}>
|
||||
<LineClampedText text={value} lines={3} />
|
||||
</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
},
|
||||
responsive: ['md'],
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return [...initialColumns, ...columns];
|
||||
};
|
||||
|
||||
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
|
||||
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
|
||||
// positional ids; `timestamp` is lifted from the wrapping ListItem.
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { DEFAULT_PANEL_TYPE } from '../constants';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const isRawQuery = useMemo(
|
||||
// Only reaches the builder for timeseries/table; list/trace panels use QueryBuilderV2's listViewTracesFilterConfigs.
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
|
||||
() => ({
|
||||
stepInterval: { isHidden: false, isDisabled: false },
|
||||
limit: { isHidden: false, isDisabled: true },
|
||||
having: { isHidden: false, isDisabled: true },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const isListViewPanel = useMemo(
|
||||
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
|
||||
[panelTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isRawQuery={isRawQuery}
|
||||
isListViewPanel={isListViewPanel}
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
panelType={panelTypes}
|
||||
showOnlyWhereClause={isRawQuery}
|
||||
filterConfigs={filterConfigs}
|
||||
showOnlyWhereClause={isListViewPanel}
|
||||
version="v3" // setting this to v3 as we this is rendered in logs explorer
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -107,7 +107,7 @@ function TableView({
|
||||
dataSource={DataSource.TRACES}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
fileName="ai-traces-table"
|
||||
fileName="traces-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -126,7 +126,6 @@ function TimeSeriesViewContainer({
|
||||
dataSource={dataSource}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
exportFileName="ai-traces-timeseries"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -55,9 +55,6 @@ function TracesTable({
|
||||
const isDataAbsent =
|
||||
!isLoading && !isFetching && !isError && data.length === 0;
|
||||
|
||||
// Rows can land before the field keys, and mounting then renders a partial column set.
|
||||
const canMountTable = !isError && !isLoading && data.length !== 0;
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: TracesTableRow): void => {
|
||||
history.push(getRowHref(row));
|
||||
@@ -86,7 +83,7 @@ function TracesTable({
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
|
||||
)}
|
||||
|
||||
{canMountTable && (
|
||||
{!isError && data.length !== 0 && (
|
||||
<div className={styles.tableWrapper}>
|
||||
<TanStackTable<TracesTableRow>
|
||||
data={data}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import { buildTraceViewColumns } from '../../TracesView/configs';
|
||||
import TracesTable from '../TracesTable';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
|
||||
|
||||
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
|
||||
|
||||
const COLUMNS = buildTraceViewColumns([
|
||||
{ name: 'trace_id' },
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'start_time' },
|
||||
]);
|
||||
|
||||
function RaceHarness(): JSX.Element {
|
||||
const [columnsReady, setColumnsReady] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={(): void => setColumnsReady(true)}>
|
||||
columns-ready
|
||||
</button>
|
||||
<TracesTable
|
||||
data={ROWS}
|
||||
columns={columnsReady ? COLUMNS : []}
|
||||
columnStorageKey={STORAGE_KEY}
|
||||
respectColumnOrder
|
||||
panelType="TRACE"
|
||||
getRowHref={(): string => '/trace/abc'}
|
||||
isLoading={!columnsReady}
|
||||
isFetching={false}
|
||||
isError={false}
|
||||
error={null}
|
||||
isFilterApplied={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const persistedState = (): { hiddenColumnIds: string[] } | null => {
|
||||
const raw = localStorage.getItem(PERSISTED_KEY);
|
||||
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
|
||||
};
|
||||
|
||||
describe('TracesTable column-init race', () => {
|
||||
beforeEach(() => {
|
||||
useColumnStore.setState({ tables: {} });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('does not persist empty defaults when rows land before columns', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<RaceHarness />);
|
||||
|
||||
expect(screen.getByText(/pending_data_placeholder/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
||||
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
|
||||
expect(persistedState()).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'columns-ready' }));
|
||||
|
||||
await expect(screen.findByRole('table')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('trace_id')).toBeInTheDocument();
|
||||
expect(screen.queryByText('start_time')).not.toBeInTheDocument();
|
||||
expect(persistedState()?.hiddenColumnIds).toStrictEqual(['start_time']);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,6 @@
|
||||
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
|
||||
// camelCase and snake_case variants are listed because the API has shipped both.
|
||||
|
||||
// start/end/last_activity_time come from the per-trace query, unlike span timestamp.
|
||||
export const TIMESTAMP_FIELD_NAMES = new Set([
|
||||
'timestamp',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'last_activity_time',
|
||||
]);
|
||||
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
|
||||
|
||||
export const STATUS_FIELD_NAMES = new Set([
|
||||
'httpMethod',
|
||||
@@ -20,12 +13,6 @@ export const STATUS_FIELD_NAMES = new Set([
|
||||
'http.response.status_code',
|
||||
]);
|
||||
|
||||
// trace_/max_llm_duration_nano are trace-level durations the per-trace query computes.
|
||||
export const DURATION_FIELD_NAMES = new Set([
|
||||
'durationNano',
|
||||
'duration_nano',
|
||||
'trace_duration_nano',
|
||||
'max_llm_duration_nano',
|
||||
]);
|
||||
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
|
||||
|
||||
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);
|
||||
|
||||
@@ -67,7 +67,6 @@ function TracesView({
|
||||
onFieldsChange,
|
||||
requiredFields,
|
||||
isLoading: isColumnsLoading,
|
||||
canPersistColumns,
|
||||
} = useTraceViewColumns();
|
||||
|
||||
const {
|
||||
@@ -169,22 +168,9 @@ function TracesView({
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
// Without the full column set there is no pool to pick from, so the control is dropped.
|
||||
const fieldsSelectorConfig = useMemo(
|
||||
() =>
|
||||
canPersistColumns
|
||||
? { fieldsSelector: { value: selectedFields, onFieldsChange } }
|
||||
: null,
|
||||
[canPersistColumns, selectedFields, onFieldsChange],
|
||||
);
|
||||
|
||||
// Rendering the pool unfiltered would surface columns the defaults keep hidden.
|
||||
const tableColumns = useMemo(
|
||||
() =>
|
||||
canPersistColumns
|
||||
? columns
|
||||
: columns.filter((column) => column.defaultVisibility !== false),
|
||||
[canPersistColumns, columns],
|
||||
() => ({ fieldsSelector: { value: selectedFields, onFieldsChange } }),
|
||||
[selectedFields, onFieldsChange],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -221,12 +207,8 @@ function TracesView({
|
||||
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={
|
||||
canPersistColumns
|
||||
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
|
||||
: undefined
|
||||
}
|
||||
columns={columns}
|
||||
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
|
||||
respectColumnOrder
|
||||
panelType="TRACE"
|
||||
getRowHref={getTraceLink}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import TracesView from '../TracesView';
|
||||
|
||||
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
|
||||
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
|
||||
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
|
||||
const FIELD_KEYS_URL = `${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`;
|
||||
|
||||
const OPTIONS_TRIGGER = 'options_menu.options';
|
||||
|
||||
const ROWS = [
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:58.735245Z',
|
||||
data: {
|
||||
'service.name': 'checkout',
|
||||
root_span_name: 'HTTP GET',
|
||||
trace_duration_nano: 55306000,
|
||||
span_count: 8,
|
||||
trace_id: '0000000000000000344ded1387b08a7e',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockRows = (): void => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: {
|
||||
type: 'trace',
|
||||
data: { results: [{ queryName: 'A', rows: ROWS }] },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const mockFieldKeys = (names: string[]): void => {
|
||||
server.use(
|
||||
rest.get(FIELD_KEYS_URL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
[
|
||||
{
|
||||
name,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.trace,
|
||||
fieldDataType: TelemetrytypesFieldDataTypeDTO.float64,
|
||||
},
|
||||
],
|
||||
]),
|
||||
),
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const mockFieldKeysFailure = (): void => {
|
||||
server.use(
|
||||
rest.get(FIELD_KEYS_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const persistedState = (): { hiddenColumnIds: string[] } | null => {
|
||||
const raw = localStorage.getItem(PERSISTED_KEY);
|
||||
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
|
||||
};
|
||||
|
||||
const renderTracesView = (): ReturnType<typeof render> =>
|
||||
render(
|
||||
<TracesView
|
||||
isFilterApplied={false}
|
||||
setWarning={jest.fn()}
|
||||
setIsLoadingQueries={jest.fn()}
|
||||
/>,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/llm-observability/traces',
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
stagedQuery: initialQueryAIWithType,
|
||||
currentQuery: initialQueryAIWithType,
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
describe('TracesView column persistence', () => {
|
||||
beforeEach(() => {
|
||||
useColumnStore.setState({ tables: {} });
|
||||
localStorage.clear();
|
||||
mockRows();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// Rows are virtualised, so a mounted table stands in for "rows arrived".
|
||||
const findTable = (): Promise<HTMLElement> => screen.findByRole('table');
|
||||
|
||||
it('seeds the persisted defaults once the field keys arrive', async () => {
|
||||
mockFieldKeys(['llm_call_count', 'tool_call_count']);
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(persistedState()?.hiddenColumnIds).toStrictEqual([
|
||||
'start_time',
|
||||
'end_time',
|
||||
'error_count',
|
||||
'input',
|
||||
'output',
|
||||
'trace:tool_call_count:float64',
|
||||
]);
|
||||
});
|
||||
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
|
||||
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists nothing when the field keys fail', async () => {
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
|
||||
expect(persistedState()).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the column picker when the field keys fail', async () => {
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders only the default-visible columns when the field keys fail', async () => {
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
expect(screen.getByText('root_span_name')).toBeInTheDocument();
|
||||
expect(screen.getByText('trace_id')).toBeInTheDocument();
|
||||
expect(screen.queryByText('input')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('output')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves an existing selection untouched while the field keys fail', async () => {
|
||||
const existing = {
|
||||
hiddenColumnIds: ['trace:tool_call_count:float64', 'input', 'output'],
|
||||
columnOrder: ['trace_id', 'resource:service.name'],
|
||||
columnSizing: {},
|
||||
};
|
||||
localStorage.setItem(PERSISTED_KEY, JSON.stringify(existing));
|
||||
|
||||
mockFieldKeysFailure();
|
||||
renderTracesView();
|
||||
|
||||
await findTable();
|
||||
|
||||
expect(persistedState()).toStrictEqual(existing);
|
||||
});
|
||||
});
|
||||
@@ -149,62 +149,6 @@ describe('useTraceViewColumns', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('when the keys fetch fails', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(
|
||||
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
|
||||
(_req, res, ctx) => res(ctx.status(500), ctx.json({ status: 'error' })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not persist defaults', async () => {
|
||||
await renderColumns();
|
||||
|
||||
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
|
||||
expect(
|
||||
localStorage.getItem(`@signoz/table-columns/${STORAGE_KEY}`),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('reports the column state as not persistable', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(result.current.canPersistColumns).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a selection change instead of persisting a partial set', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
act(() => {
|
||||
result.current.onFieldsChange([{ name: 'trace_id' }]);
|
||||
});
|
||||
|
||||
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('seeds the defaults once a later fetch succeeds', async () => {
|
||||
const { unmount } = await renderColumns();
|
||||
unmount();
|
||||
|
||||
mockAggregateKeys(AGGREGATE_KEYS);
|
||||
const { result } = await renderColumns();
|
||||
|
||||
expect(result.current.canPersistColumns).toBe(true);
|
||||
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
'trace_duration_nano',
|
||||
'span_count',
|
||||
'trace_id',
|
||||
'llm_call_count',
|
||||
'total_tokens',
|
||||
'estimated_total_cost',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the columns dropped from the selection', async () => {
|
||||
const { result } = await renderColumns();
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
/** Always visible: it is the row's link to the trace. */
|
||||
export const TRACE_ID_COLUMN_ID = 'trace_id';
|
||||
|
||||
/** Everything else starts hidden; only applied at first init, since the store persists hidden ids. */
|
||||
/** Everything else starts hidden, including any aggregate the endpoint adds later. */
|
||||
const DEFAULT_VISIBLE_FIELDS = new Set([
|
||||
'service.name',
|
||||
'root_span_name',
|
||||
|
||||
@@ -35,17 +35,11 @@ interface UseTraceViewColumns {
|
||||
onFieldsChange: (next: TelemetryFieldKey[]) => void;
|
||||
requiredFields: readonly string[];
|
||||
isLoading: boolean;
|
||||
/** False until the keys fetch lands; a partial set must not reach the persisted store. */
|
||||
canPersistColumns: boolean;
|
||||
}
|
||||
|
||||
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
|
||||
export function useTraceViewColumns(): UseTraceViewColumns {
|
||||
const {
|
||||
data: fetchedFields = [],
|
||||
isFetched,
|
||||
isSuccess,
|
||||
} = useFieldKeysSuggestion(
|
||||
const { data: fetchedFields = [], isFetched } = useFieldKeysSuggestion(
|
||||
{
|
||||
...TRACE_VIEW_FIELD_KEYS,
|
||||
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
|
||||
@@ -66,10 +60,10 @@ export function useTraceViewColumns(): UseTraceViewColumns {
|
||||
|
||||
// Defaults from a partial column set would persist as the user's own choice.
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
if (isFetched) {
|
||||
initializeFromDefaults(STORAGE_KEY, columns);
|
||||
}
|
||||
}, [isSuccess, columns]);
|
||||
}, [isFetched, columns]);
|
||||
|
||||
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
|
||||
const columnOrder = useColumnOrder(STORAGE_KEY);
|
||||
@@ -89,10 +83,6 @@ export function useTraceViewColumns(): UseTraceViewColumns {
|
||||
|
||||
const onFieldsChange = useCallback(
|
||||
(next: TelemetryFieldKey[]): void => {
|
||||
if (!isSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keptIds = new Set(next.map(columnIdOf));
|
||||
|
||||
columns.forEach((column) => {
|
||||
@@ -106,7 +96,7 @@ export function useTraceViewColumns(): UseTraceViewColumns {
|
||||
// Columns missing from the order sort last, so the visible ones suffice.
|
||||
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
|
||||
},
|
||||
[columns, isSuccess],
|
||||
[columns],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -115,6 +105,5 @@ export function useTraceViewColumns(): UseTraceViewColumns {
|
||||
onFieldsChange,
|
||||
requiredFields: [TRACE_ID_COLUMN_ID],
|
||||
isLoading: !isFetched,
|
||||
canPersistColumns: isSuccess,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
|
||||
|
||||
export const TOOLBAR_VIEWS = {
|
||||
trace: {
|
||||
name: 'trace',
|
||||
label: 'Trace',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'trace',
|
||||
},
|
||||
list: {
|
||||
name: 'list',
|
||||
label: 'List',
|
||||
@@ -25,6 +15,13 @@ export const TOOLBAR_VIEWS = {
|
||||
show: true,
|
||||
key: 'timeseries',
|
||||
},
|
||||
trace: {
|
||||
name: 'trace',
|
||||
label: 'Trace',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'trace',
|
||||
},
|
||||
table: {
|
||||
name: 'table',
|
||||
label: 'Table',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { cloneDeep, set } from 'lodash-es';
|
||||
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const getListViewQuery = (
|
||||
@@ -30,3 +31,31 @@ export const getListViewQuery = (
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const getQueryByPanelType = (
|
||||
stagedQuery: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
): Query => {
|
||||
if (panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE) {
|
||||
return getListViewQuery(stagedQuery);
|
||||
}
|
||||
return stagedQuery;
|
||||
};
|
||||
|
||||
export const getExportQueryData = (
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
options: OptionsQuery,
|
||||
): Query => {
|
||||
if (panelType === PANEL_TYPES.LIST) {
|
||||
const updatedQuery = cloneDeep(query);
|
||||
set(
|
||||
updatedQuery,
|
||||
'builder.queryData[0].selectColumns',
|
||||
options.selectColumns,
|
||||
);
|
||||
|
||||
return updatedQuery;
|
||||
}
|
||||
return query;
|
||||
};
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
import {
|
||||
ArrowUpToLine,
|
||||
Atom,
|
||||
Filter,
|
||||
SquareMousePointer,
|
||||
Terminal,
|
||||
Binoculars,
|
||||
} from '@signozhq/icons';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
|
||||
import { TOOLBAR_VIEW_CONFIG } from './toolbarViewsConfig';
|
||||
|
||||
import './ToolbarActions.styles.scss';
|
||||
|
||||
interface ToolbarViewItem {
|
||||
name: string;
|
||||
key: string;
|
||||
show?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface LeftToolbarActionsProps {
|
||||
items: Record<string, ToolbarViewItem>;
|
||||
items: any;
|
||||
selectedView: string;
|
||||
onChangeSelectedView: (view: ExplorerViews) => void;
|
||||
showFilter: boolean;
|
||||
@@ -31,6 +29,8 @@ export default function LeftToolbarActions({
|
||||
showFilter,
|
||||
handleFilterVisibilityChange,
|
||||
}: LeftToolbarActionsProps): JSX.Element {
|
||||
const { clickhouse, list, timeseries, table, trace } = items;
|
||||
|
||||
return (
|
||||
<div className="left-toolbar">
|
||||
{!showFilter && (
|
||||
@@ -41,34 +41,91 @@ export default function LeftToolbarActions({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Buttons render in the order the caller declares its views. */}
|
||||
<div className="left-toolbar-query-actions">
|
||||
{Object.values(items).map((item) => {
|
||||
const config = TOOLBAR_VIEW_CONFIG[item?.key];
|
||||
{list?.show && (
|
||||
<Tooltip title="List View">
|
||||
<Button
|
||||
disabled={list.disabled}
|
||||
className={cx(
|
||||
'list-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === list.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(list.key)}
|
||||
>
|
||||
<SquareMousePointer size={14} data-testid="search-view" />
|
||||
List View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
if (!item?.show || !config) {
|
||||
return null;
|
||||
}
|
||||
{trace?.show && (
|
||||
<Tooltip title="Trace View">
|
||||
<Button
|
||||
disabled={trace.disabled}
|
||||
className={cx(
|
||||
'trace-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === trace.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(trace.key)}
|
||||
>
|
||||
<SquareMousePointer size={14} data-testid="trace-view" />
|
||||
Trace View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
const { icon: Icon, label, className, testId } = config;
|
||||
{timeseries?.show && (
|
||||
<Tooltip title="Time Series">
|
||||
<Button
|
||||
disabled={timeseries.disabled}
|
||||
className={cx(
|
||||
'timeseries-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === timeseries.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(timeseries.key)}
|
||||
>
|
||||
<Atom size={14} data-testid="query-builder-view" />
|
||||
Time Series
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
return (
|
||||
<Tooltip key={item.key} title={label}>
|
||||
<Button
|
||||
disabled={item.disabled}
|
||||
className={cx(
|
||||
className,
|
||||
'explorer-view-option',
|
||||
selectedView === item.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(item.key as ExplorerViews)}
|
||||
>
|
||||
<Icon size={14} data-testid={testId} />
|
||||
{label}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{clickhouse?.show && (
|
||||
<Tooltip title="Clickhouse">
|
||||
<Button
|
||||
disabled={clickhouse.disabled}
|
||||
className={cx(
|
||||
'clickhouse-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === clickhouse.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(clickhouse.key)}
|
||||
>
|
||||
<Terminal size={14} data-testid="clickhouse-view" />
|
||||
Clickhouse
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{table?.show && (
|
||||
<Tooltip title="Table">
|
||||
<Button
|
||||
disabled={table.disabled}
|
||||
className={cx(
|
||||
'table-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === table.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(table.key)}
|
||||
>
|
||||
<Binoculars size={14} data-testid="query-builder-view-v2" />
|
||||
Table
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import {
|
||||
Atom,
|
||||
Binoculars,
|
||||
SquareMousePointer,
|
||||
Terminal,
|
||||
} from '@signozhq/icons';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
|
||||
export interface ToolbarViewConfig {
|
||||
icon: typeof Atom;
|
||||
label: string;
|
||||
className: string;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
export const TOOLBAR_VIEW_CONFIG: Record<string, ToolbarViewConfig> = {
|
||||
[ExplorerViews.LIST]: {
|
||||
icon: SquareMousePointer,
|
||||
label: 'List View',
|
||||
className: 'list-view-tab',
|
||||
testId: 'search-view',
|
||||
},
|
||||
[ExplorerViews.TRACE]: {
|
||||
icon: SquareMousePointer,
|
||||
label: 'Trace View',
|
||||
className: 'trace-view-tab',
|
||||
testId: 'trace-view',
|
||||
},
|
||||
[ExplorerViews.TIMESERIES]: {
|
||||
icon: Atom,
|
||||
label: 'Time Series',
|
||||
className: 'timeseries-view-tab',
|
||||
testId: 'query-builder-view',
|
||||
},
|
||||
[ExplorerViews.CLICKHOUSE]: {
|
||||
icon: Terminal,
|
||||
label: 'Clickhouse',
|
||||
className: 'clickhouse-view-tab',
|
||||
testId: 'clickhouse-view',
|
||||
},
|
||||
[ExplorerViews.TABLE]: {
|
||||
icon: Binoculars,
|
||||
label: 'Table',
|
||||
className: 'table-view-tab',
|
||||
testId: 'query-builder-view-v2',
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,13 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
OPERATORS,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ExplorerOrderBy from 'container/ExplorerOrderBy';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
@@ -29,11 +36,42 @@ function LogExplorerQuerySection({
|
||||
|
||||
useShareBuilderUrl({ defaultValue });
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
const isTable = panelTypes === PANEL_TYPES.TABLE;
|
||||
const isList = panelTypes === PANEL_TYPES.LIST;
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: isTable, isDisabled: false },
|
||||
having: { isHidden: isList, isDisabled: true },
|
||||
filters: {
|
||||
customKey: 'body',
|
||||
customOp: OPERATORS.CONTAINS,
|
||||
},
|
||||
};
|
||||
|
||||
return config;
|
||||
}, [panelTypes]);
|
||||
|
||||
const renderOrderBy = useCallback(
|
||||
({ query, onChange }: OrderByFilterProps): JSX.Element => (
|
||||
<ExplorerOrderBy query={query} onChange={onChange} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const queryComponents = useMemo(
|
||||
(): QueryBuilderProps['queryComponents'] => ({
|
||||
...(panelTypes === PANEL_TYPES.LIST ? { renderOrderBy } : {}),
|
||||
}),
|
||||
[panelTypes, renderOrderBy],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isRawQuery={panelTypes === PANEL_TYPES.LIST}
|
||||
isListViewPanel={panelTypes === PANEL_TYPES.LIST}
|
||||
config={{ initialDataSource: DataSource.LOGS, queryVariant: 'static' }}
|
||||
panelType={panelTypes}
|
||||
filterConfigs={filterConfigs}
|
||||
queryComponents={queryComponents}
|
||||
showOnlyWhereClause={selectedView === ExplorerViews.LIST}
|
||||
version="v3" // setting this to v3 as we this is rendered in logs explorer
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
@@ -119,6 +120,11 @@ function Explorer(): JSX.Element {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const queryComponents = useMemo(
|
||||
(): QueryBuilderProps['queryComponents'] => ({}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
@@ -175,6 +181,7 @@ function Explorer(): JSX.Element {
|
||||
signalSource: 'meter',
|
||||
}}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
queryComponents={queryComponents}
|
||||
showFunctions={false}
|
||||
version="v3"
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
@@ -322,6 +323,11 @@ function Explorer(): JSX.Element {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const queryComponents = useMemo(
|
||||
(): QueryBuilderProps['queryComponents'] => ({}),
|
||||
[],
|
||||
);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
|
||||
const oneChartPerQueryDisabledTooltip = useMemo(() => {
|
||||
@@ -375,6 +381,7 @@ function Explorer(): JSX.Element {
|
||||
<QueryBuilderV2
|
||||
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
queryComponents={queryComponents}
|
||||
showFunctions={false}
|
||||
version="v3"
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryBuilderFieldsConfig } from 'components/QueryBuilderV2/queryBuilderFields.types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { OrderByFilterProps } from './filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
|
||||
export type WhereClauseConfig = {
|
||||
customKey: string;
|
||||
customOp: string;
|
||||
};
|
||||
|
||||
type FilterConfigs = {
|
||||
[Key in keyof Omit<IBuilderQuery, 'filters'>]: {
|
||||
isHidden: boolean;
|
||||
isDisabled: boolean;
|
||||
};
|
||||
} & { filters: WhereClauseConfig };
|
||||
|
||||
export type QueryBuilderConfig =
|
||||
| {
|
||||
queryVariant: 'static';
|
||||
@@ -16,16 +29,9 @@ export type QueryBuilderProps = {
|
||||
config?: QueryBuilderConfig;
|
||||
panelType: PANEL_TYPES;
|
||||
actions?: ReactNode;
|
||||
fieldsConfig?: QueryBuilderFieldsConfig;
|
||||
/**
|
||||
* The builder edits raw rows rather than an aggregation: a single query unless trace
|
||||
* matching is on, no formulas, data-source switches reset to the raw-query template,
|
||||
* and order by resolves keys without an aggregate attribute. Supplies the defaults for
|
||||
* `fieldsConfig` and `allowedDataSources`, which override it per field.
|
||||
*/
|
||||
isRawQuery?: boolean;
|
||||
/** Defaults to every signal. */
|
||||
allowedDataSources?: TelemetrytypesSignalDTO[];
|
||||
filterConfigs?: Partial<FilterConfigs>;
|
||||
queryComponents?: { renderOrderBy?: (props: OrderByFilterProps) => ReactNode };
|
||||
isListViewPanel?: boolean;
|
||||
showFunctions?: boolean;
|
||||
showOnlyWhereClause?: boolean;
|
||||
showOnlyTraceOperator?: boolean;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type AdditionalFiltersProps = {
|
||||
listOfAdditionalFilter: string[];
|
||||
children: ReactNode;
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { SquareMinus, SquarePlus } from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Col } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
const IconCss = css`
|
||||
margin-right: 0.6875rem;
|
||||
transition: all 0.2s ease;
|
||||
`;
|
||||
|
||||
export const StyledIconOpen = styled(SquarePlus)`
|
||||
${IconCss}
|
||||
`;
|
||||
|
||||
export const StyledIconClose = styled(SquareMinus)`
|
||||
${IconCss}
|
||||
`;
|
||||
|
||||
export const StyledInner = styled(Col)`
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 0.875rem;
|
||||
min-height: 1.375rem;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
${StyledIconOpen}, ${StyledIconClose} {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const StyledLink = styled(Typography.Link)`
|
||||
pointer-events: none;
|
||||
color: ${Color.BG_ROBIN_400} !important;
|
||||
`;
|
||||
@@ -0,0 +1,15 @@
|
||||
.filter-toggler {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.additinal-filters-container {
|
||||
.action-btn {
|
||||
background: var(--primary-background);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Fragment, memo, ReactNode, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Col, Row } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Minus, Plus } from '@signozhq/icons';
|
||||
|
||||
// ** Types
|
||||
import { AdditionalFiltersProps } from './AdditionalFiltersToggler.interfaces';
|
||||
// ** Styles
|
||||
import { StyledInner, StyledLink } from './AdditionalFiltersToggler.styled';
|
||||
|
||||
import './AdditionalFiltersToggler.styles.scss';
|
||||
|
||||
export const AdditionalFiltersToggler = memo(function AdditionalFiltersToggler({
|
||||
children,
|
||||
listOfAdditionalFilter,
|
||||
}: AdditionalFiltersProps): JSX.Element {
|
||||
const [isOpenedFilters, setIsOpenedFilters] = useState<boolean>(false);
|
||||
|
||||
const handleToggleOpenFilters = (): void => {
|
||||
setIsOpenedFilters((prevState) => !prevState);
|
||||
};
|
||||
|
||||
const filtersTexts: ReactNode = listOfAdditionalFilter?.map((str, index) => {
|
||||
const isNextLast = index + 1 === listOfAdditionalFilter.length - 1;
|
||||
|
||||
if (index === listOfAdditionalFilter.length - 1) {
|
||||
return (
|
||||
<Fragment key={str}>
|
||||
{listOfAdditionalFilter?.length > 1 && 'and'}{' '}
|
||||
<StyledLink>{str.toUpperCase()}</StyledLink>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={str}>
|
||||
<StyledLink>{str.toUpperCase()}</StyledLink>
|
||||
{isNextLast ? ' ' : ', '}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Row className="additinal-filters-container">
|
||||
<Col span={24}>
|
||||
<StyledInner onClick={handleToggleOpenFilters} style={{ marginBottom: 0 }}>
|
||||
{isOpenedFilters ? (
|
||||
<span className="action-btn">
|
||||
<Minus size={14} color={Color.BG_INK_500} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="action-btn">
|
||||
<Plus size={14} color={Color.BG_INK_500} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isOpenedFilters && (
|
||||
<Typography>Add conditions for {filtersTexts}</Typography>
|
||||
)}
|
||||
</StyledInner>
|
||||
</Col>
|
||||
{isOpenedFilters && <Col span={24}>{children}</Col>}
|
||||
</Row>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { AdditionalFiltersToggler } from './AdditionalFiltersToggler';
|
||||
@@ -1,10 +1,8 @@
|
||||
import { SelectProps } from 'antd';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export type QueryLabelProps = {
|
||||
onChange: (value: DataSource) => void;
|
||||
/** Defaults to every signal. */
|
||||
allowedDataSources?: TelemetrytypesSignalDTO[];
|
||||
isListViewPanel?: boolean;
|
||||
'data-testid'?: string;
|
||||
} & Omit<SelectProps, 'onChange'>;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
// ** Helpers
|
||||
@@ -8,24 +7,25 @@ import { transformToUpperCase } from 'utils/transformToUpperCase';
|
||||
|
||||
// ** Types
|
||||
import { QueryLabelProps } from './DataSourceDropdown.interfaces';
|
||||
import { signalsToDataSources } from './DataSourceDropdown.utils';
|
||||
|
||||
const ALL_SIGNALS = [
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
];
|
||||
const dataSourceMap = [DataSource.LOGS, DataSource.METRICS, DataSource.TRACES];
|
||||
|
||||
const exploreDataSourceMap = [DataSource.LOGS, DataSource.TRACES];
|
||||
|
||||
export const DataSourceDropdown = memo(function DataSourceDropdown(
|
||||
props: QueryLabelProps,
|
||||
): JSX.Element {
|
||||
const { onChange, value, style, allowedDataSources = ALL_SIGNALS } = props;
|
||||
const { onChange, value, style, isListViewPanel = false } = props;
|
||||
|
||||
const dataSourceOptions: SelectOption<DataSource, string>[] =
|
||||
signalsToDataSources(allowedDataSources).map((source) => ({
|
||||
label: transformToUpperCase(source),
|
||||
value: source,
|
||||
}));
|
||||
const dataSourceOptions: SelectOption<DataSource, string>[] = isListViewPanel
|
||||
? exploreDataSourceMap.map((source) => ({
|
||||
label: transformToUpperCase(source),
|
||||
value: source,
|
||||
}))
|
||||
: dataSourceMap.map((source) => ({
|
||||
label: transformToUpperCase(source),
|
||||
value: source,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
// Total, not Partial, so a signal added to the generated enum has to be mapped here
|
||||
// before it compiles.
|
||||
const SIGNAL_TO_DATA_SOURCE: Record<
|
||||
TelemetrytypesSignalDTO,
|
||||
DataSource | undefined
|
||||
> = {
|
||||
[TelemetrytypesSignalDTO.logs]: DataSource.LOGS,
|
||||
[TelemetrytypesSignalDTO.metrics]: DataSource.METRICS,
|
||||
[TelemetrytypesSignalDTO.traces]: DataSource.TRACES,
|
||||
// The "unset" member: not a data source a query can be built against.
|
||||
[TelemetrytypesSignalDTO['']]: undefined,
|
||||
};
|
||||
|
||||
export function signalsToDataSources(
|
||||
signals: readonly TelemetrytypesSignalDTO[],
|
||||
): DataSource[] {
|
||||
return signals
|
||||
.map((signal) => SIGNAL_TO_DATA_SOURCE[signal])
|
||||
.filter((dataSource): dataSource is DataSource => Boolean(dataSource));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
export type FilterLabelProps = {
|
||||
label: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface Props {
|
||||
isDarkMode: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const StyledLabel = styled.div<Props>`
|
||||
padding: 0 0.6875rem;
|
||||
min-height: 2rem;
|
||||
min-width: 5.625rem;
|
||||
display: inline-flex;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
border-radius: 0.125rem;
|
||||
`;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { memo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
// ** Types
|
||||
import { FilterLabelProps } from './FilterLabel.interfaces';
|
||||
// ** Styles
|
||||
import { StyledLabel } from './FilterLabel.styled';
|
||||
|
||||
export const FilterLabel = memo(function FilterLabel({
|
||||
label,
|
||||
}: FilterLabelProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return (
|
||||
<StyledLabel isDarkMode={isDarkMode}>
|
||||
<Typography
|
||||
style={{
|
||||
color: 'var(--bg-vanilla-400)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</StyledLabel>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { FilterLabel } from './FilterLabel';
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
@@ -7,5 +8,7 @@ export type FormulaProps = {
|
||||
formula: IBuilderFormula;
|
||||
index: number;
|
||||
query: IBuilderQuery;
|
||||
filterConfigs: Partial<QueryBuilderProps['filterConfigs']>;
|
||||
isAdditionalFilterEnable: boolean;
|
||||
isQBV2?: boolean;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,11 @@ import { ChangeEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { Col, Input, Row, Select } from 'antd';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { LEGEND } from 'constants/global';
|
||||
// ** Components
|
||||
import { FilterLabel } from 'container/QueryBuilder/components';
|
||||
import HavingFilter from 'container/QueryBuilder/filters/Formula/Having/HavingFilter';
|
||||
import LimitFilter from 'container/QueryBuilder/filters/Formula/Limit/Limit';
|
||||
import OrderByFilter from 'container/QueryBuilder/filters/Formula/OrderBy/OrderByFilter';
|
||||
// ** Hooks
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
@@ -12,6 +17,7 @@ import {
|
||||
import { getFormatedLegend } from 'utils/getFormatedLegend';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { AdditionalFiltersToggler } from '../AdditionalFiltersToggler';
|
||||
import QBEntityOptions from '../QBEntityOptions/QBEntityOptions';
|
||||
// ** Types
|
||||
import { FormulaProps } from './Formula.interfaces';
|
||||
@@ -21,18 +27,22 @@ import './Formula.styles.scss';
|
||||
export function Formula({
|
||||
index,
|
||||
formula,
|
||||
filterConfigs,
|
||||
query,
|
||||
isAdditionalFilterEnable,
|
||||
isQBV2,
|
||||
}: FormulaProps): JSX.Element {
|
||||
const { removeQueryBuilderEntityByIndex, handleSetFormulaData } =
|
||||
useQueryBuilder();
|
||||
|
||||
const { handleChangeFormulaData } = useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
formula,
|
||||
entityVersion: '',
|
||||
});
|
||||
const { listOfAdditionalFormulaFilters, handleChangeFormulaData } =
|
||||
useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
filterConfigs,
|
||||
formula,
|
||||
entityVersion: '',
|
||||
});
|
||||
|
||||
const [isCollapse, setIsCollapsed] = useState(false);
|
||||
|
||||
@@ -73,6 +83,20 @@ export function Formula({
|
||||
[handleChangeFormulaData],
|
||||
);
|
||||
|
||||
const handleChangeHavingFilter = useCallback(
|
||||
(value: IBuilderFormula['having']) => {
|
||||
handleChangeFormulaData('having', value);
|
||||
},
|
||||
[handleChangeFormulaData],
|
||||
);
|
||||
|
||||
const handleChangeOrderByFilter = useCallback(
|
||||
(value: IBuilderFormula['orderBy']) => {
|
||||
handleChangeFormulaData('orderBy', value);
|
||||
},
|
||||
[handleChangeFormulaData],
|
||||
);
|
||||
|
||||
const handleQBV2OrderByChange = useCallback(
|
||||
(value: string) => {
|
||||
const [columnName, order] = value.split(' ');
|
||||
@@ -98,6 +122,54 @@ export function Formula({
|
||||
[formula.orderBy],
|
||||
);
|
||||
|
||||
const renderAdditionalFilters = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Col span={11}>
|
||||
<Row gutter={[11, 5]}>
|
||||
<Col flex="5.93rem">
|
||||
<FilterLabel label="Limit" />
|
||||
</Col>
|
||||
<Col flex="1 1 12.5rem">
|
||||
<LimitFilter formula={formula} onChange={handleChangeLimit} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col span={11}>
|
||||
<Row gutter={[11, 5]}>
|
||||
<Col flex="5.93rem">
|
||||
<FilterLabel label="HAVING" />
|
||||
</Col>
|
||||
<Col flex="1 1 12.5rem">
|
||||
<HavingFilter formula={formula} onChange={handleChangeHavingFilter} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
<Col span={11}>
|
||||
<Row gutter={[11, 5]}>
|
||||
<Col flex="5.93rem">
|
||||
<FilterLabel label="Order by" />
|
||||
</Col>
|
||||
<Col flex="1 1 12.5rem">
|
||||
<OrderByFilter
|
||||
query={query}
|
||||
formula={formula}
|
||||
onChange={handleChangeOrderByFilter}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</>
|
||||
),
|
||||
[
|
||||
formula,
|
||||
handleChangeHavingFilter,
|
||||
handleChangeLimit,
|
||||
handleChangeOrderByFilter,
|
||||
query,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Row gutter={[0, 15]}>
|
||||
<QBEntityOptions
|
||||
@@ -134,6 +206,17 @@ export function Formula({
|
||||
addonBefore="Legend Format"
|
||||
/>
|
||||
</Col>
|
||||
{isAdditionalFilterEnable && (
|
||||
<Col span={24}>
|
||||
<AdditionalFiltersToggler
|
||||
listOfAdditionalFilter={listOfAdditionalFormulaFilters}
|
||||
>
|
||||
<Row gutter={[0, 11]} justify="space-between">
|
||||
{renderAdditionalFilters}
|
||||
</Row>
|
||||
</AdditionalFiltersToggler>
|
||||
</Col>
|
||||
)}
|
||||
{isQBV2 && (
|
||||
<Col span={24}>
|
||||
<div className="formula-qbv2-container">
|
||||
|
||||
@@ -84,14 +84,5 @@
|
||||
.options-group {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.query-functions-container--disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button, Col, Tooltip } from 'antd';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -27,8 +26,6 @@ interface QBEntityOptionsProps {
|
||||
query?: IBuilderQuery;
|
||||
isMetricsDataSource?: boolean;
|
||||
showFunctions?: boolean;
|
||||
areFunctionsDisabled?: boolean;
|
||||
functionsDisabledReason?: string;
|
||||
isCollapsed: boolean;
|
||||
entityType: string;
|
||||
entityData: any;
|
||||
@@ -39,8 +36,7 @@ interface QBEntityOptionsProps {
|
||||
onQueryFunctionsUpdates?: (functions: QueryFunction[]) => void;
|
||||
showDeleteButton?: boolean;
|
||||
showCloneOption?: boolean;
|
||||
isRawQuery?: boolean;
|
||||
allowedDataSources?: TelemetrytypesSignalDTO[];
|
||||
isListViewPanel?: boolean;
|
||||
index?: number;
|
||||
showTraceOperator?: boolean;
|
||||
hasTraceOperator?: boolean;
|
||||
@@ -54,15 +50,12 @@ export default function QBEntityOptions({
|
||||
isMetricsDataSource,
|
||||
isCollapsed,
|
||||
showFunctions,
|
||||
areFunctionsDisabled,
|
||||
functionsDisabledReason,
|
||||
entityType,
|
||||
entityData,
|
||||
onToggleVisibility,
|
||||
onCollapseEntity,
|
||||
onQueryFunctionsUpdates,
|
||||
isRawQuery,
|
||||
allowedDataSources,
|
||||
isListViewPanel,
|
||||
onDelete,
|
||||
showDeleteButton,
|
||||
showCloneOption,
|
||||
@@ -107,7 +100,7 @@ export default function QBEntityOptions({
|
||||
value="query-builder"
|
||||
className="periscope-btn visibility-toggle"
|
||||
onClick={onToggleVisibility}
|
||||
disabled={isRawQuery && !showTraceOperator}
|
||||
disabled={isListViewPanel && !showTraceOperator}
|
||||
>
|
||||
{entityData.disabled ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</Button>
|
||||
@@ -126,7 +119,7 @@ export default function QBEntityOptions({
|
||||
'periscope-btn',
|
||||
entityType === 'query' ? 'query-name' : 'formula-name',
|
||||
query?.dataSource === DataSource.TRACES &&
|
||||
(hasTraceOperator || (showTraceOperator && isRawQuery))
|
||||
(hasTraceOperator || (showTraceOperator && isListViewPanel))
|
||||
? 'has-trace-operator'
|
||||
: '',
|
||||
isLogsExplorerPage && lastUsedQuery === index ? 'sync-btn' : '',
|
||||
@@ -145,33 +138,24 @@ export default function QBEntityOptions({
|
||||
}}
|
||||
data-testid={`query-data-source-selector-${index}`}
|
||||
value={query?.dataSource || DataSource.METRICS}
|
||||
allowedDataSources={allowedDataSources}
|
||||
isListViewPanel={isListViewPanel}
|
||||
className="query-data-source-dropdown"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFunctions &&
|
||||
!isRawQuery &&
|
||||
!isListViewPanel &&
|
||||
(isMetricsDataSource || isLogsDataSource) &&
|
||||
query &&
|
||||
onQueryFunctionsUpdates && (
|
||||
<Tooltip title={functionsDisabledReason}>
|
||||
<div
|
||||
className={cx('query-functions-container', {
|
||||
'query-functions-container--disabled': areFunctionsDisabled,
|
||||
})}
|
||||
aria-disabled={areFunctionsDisabled}
|
||||
>
|
||||
<QueryFunctions
|
||||
query={query}
|
||||
queryFunctions={query.functions || []}
|
||||
key={query.functions?.toString()}
|
||||
onChange={onQueryFunctionsUpdates}
|
||||
maxFunctions={isLogsDataSource ? 1 : 3}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<QueryFunctions
|
||||
query={query}
|
||||
queryFunctions={query.functions || []}
|
||||
key={query.functions?.toString()}
|
||||
onChange={onQueryFunctionsUpdates}
|
||||
maxFunctions={isLogsDataSource ? 1 : 3}
|
||||
/>
|
||||
)}
|
||||
</Button.Group>
|
||||
</div>
|
||||
@@ -184,7 +168,7 @@ export default function QBEntityOptions({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDeleteButton && !isRawQuery && (
|
||||
{showDeleteButton && !isListViewPanel && (
|
||||
<Button className="periscope-btn ghost" onClick={onDelete}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
@@ -195,14 +179,11 @@ export default function QBEntityOptions({
|
||||
}
|
||||
|
||||
QBEntityOptions.defaultProps = {
|
||||
isRawQuery: false,
|
||||
allowedDataSources: undefined,
|
||||
isListViewPanel: false,
|
||||
query: undefined,
|
||||
isMetricsDataSource: false,
|
||||
onQueryFunctionsUpdates: undefined,
|
||||
showFunctions: false,
|
||||
areFunctionsDisabled: false,
|
||||
functionsDisabledReason: undefined,
|
||||
onCloneQuery: noop,
|
||||
index: 0,
|
||||
onDelete: noop,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export { AdditionalFiltersToggler } from './AdditionalFiltersToggler';
|
||||
export { DataSourceDropdown } from './DataSourceDropdown';
|
||||
export { FilterLabel } from './FilterLabel';
|
||||
export { Formula } from './Formula';
|
||||
export { HavingFilterTag } from './HavingFilterTag';
|
||||
export { ListItemWrapper } from './ListItemWrapper';
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { HAVING_OPERATORS, initialHavingValues } from 'constants/queryBuilder';
|
||||
import { HavingFilterTag } from 'container/QueryBuilder/components';
|
||||
import { useTagValidation } from 'hooks/queryBuilder/useTagValidation';
|
||||
import {
|
||||
transformFromStringToHaving,
|
||||
transformHavingToStringValue,
|
||||
} from 'lib/query/transformQueryBuilderData';
|
||||
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { getHavingObject, isValidHavingValue } from '../../utils';
|
||||
import { HavingFilterProps, HavingTagRenderProps } from './types';
|
||||
|
||||
function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const { having } = formula;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [localValues, setLocalValues] = useState<string[]>([]);
|
||||
const [currentFormValue, setCurrentFormValue] =
|
||||
useState<HavingForm>(initialHavingValues);
|
||||
const [options, setOptions] = useState<SelectOption<string, string>[]>([]);
|
||||
|
||||
const { isMulti } = useTagValidation(
|
||||
currentFormValue.op,
|
||||
currentFormValue.value,
|
||||
);
|
||||
|
||||
const columnName = formula.expression.replace(/ /g, '').toUpperCase();
|
||||
|
||||
const aggregatorOptions: SelectOption<string, string>[] = useMemo(
|
||||
() => [{ label: columnName, value: columnName }],
|
||||
[columnName],
|
||||
);
|
||||
|
||||
const handleUpdateTag = useCallback(
|
||||
(value: string) => {
|
||||
const filteredValues = localValues.filter(
|
||||
(currentValue) => currentValue !== value,
|
||||
);
|
||||
const having: Having[] = filteredValues.map(transformFromStringToHaving);
|
||||
|
||||
onChange(having);
|
||||
setSearchText(value);
|
||||
},
|
||||
[localValues, onChange],
|
||||
);
|
||||
|
||||
const generateOptions = useCallback(
|
||||
(currentString: string) => {
|
||||
const [aggregator = '', op = '', ...restValue] = currentString.split(' ');
|
||||
let newOptions: SelectOption<string, string>[] = [];
|
||||
|
||||
const isAggregatorExist = columnName
|
||||
.toLowerCase()
|
||||
.includes(currentString.toLowerCase());
|
||||
|
||||
const isAggregatorChosen = aggregator === columnName;
|
||||
|
||||
if (isAggregatorExist || aggregator === '') {
|
||||
newOptions = aggregatorOptions;
|
||||
}
|
||||
|
||||
if ((isAggregatorChosen && op === '') || op) {
|
||||
const filteredOperators = HAVING_OPERATORS.filter((num) =>
|
||||
num.toLowerCase().includes(op.toLowerCase()),
|
||||
);
|
||||
|
||||
newOptions = filteredOperators.map((opt) => ({
|
||||
label: `${columnName} ${opt} ${restValue && restValue.join(' ')}`,
|
||||
value: `${columnName} ${opt} ${restValue && restValue.join(' ')}`,
|
||||
}));
|
||||
}
|
||||
|
||||
setOptions(newOptions);
|
||||
},
|
||||
[aggregatorOptions, columnName],
|
||||
);
|
||||
|
||||
const parseSearchText = useCallback(
|
||||
(text: string) => {
|
||||
const { columnName, op, value } = getHavingObject(text);
|
||||
setCurrentFormValue({ columnName, op, value });
|
||||
|
||||
generateOptions(text);
|
||||
},
|
||||
[generateOptions],
|
||||
);
|
||||
|
||||
const tagRender = ({
|
||||
label,
|
||||
value,
|
||||
closable,
|
||||
disabled,
|
||||
onClose,
|
||||
}: HavingTagRenderProps): JSX.Element => {
|
||||
const handleClose = (): void => {
|
||||
onClose();
|
||||
setSearchText('');
|
||||
};
|
||||
return (
|
||||
<HavingFilterTag
|
||||
label={label}
|
||||
value={value}
|
||||
closable={closable}
|
||||
disabled={disabled}
|
||||
onClose={handleClose}
|
||||
onUpdate={handleUpdateTag}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const handleSearch = (search: string): void => {
|
||||
const trimmedSearch = search.replace(/\s\s+/g, ' ').trimStart();
|
||||
|
||||
const currentSearch = isMulti
|
||||
? trimmedSearch
|
||||
: trimmedSearch.split(' ').slice(0, 3).join(' ');
|
||||
|
||||
const isValidSearch = isValidHavingValue(currentSearch);
|
||||
|
||||
if (isValidSearch) {
|
||||
setSearchText(currentSearch);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValues(transformHavingToStringValue(having || []));
|
||||
}, [having]);
|
||||
|
||||
useEffect(() => {
|
||||
parseSearchText(searchText);
|
||||
}, [searchText, parseSearchText]);
|
||||
|
||||
const resetChanges = (): void => {
|
||||
setSearchText('');
|
||||
setCurrentFormValue(initialHavingValues);
|
||||
setOptions(aggregatorOptions);
|
||||
};
|
||||
|
||||
const handleDeselect = (value: string): void => {
|
||||
const result = localValues.filter((item) => item !== value);
|
||||
const having: Having[] = result.map(transformFromStringToHaving);
|
||||
onChange(having);
|
||||
resetChanges();
|
||||
};
|
||||
|
||||
const handleSelect = (currentValue: string): void => {
|
||||
const { columnName, op, value } = getHavingObject(currentValue);
|
||||
|
||||
const isCompletedValue = value.every((item) => !!item);
|
||||
|
||||
const isClearSearch = isCompletedValue && columnName && op;
|
||||
|
||||
setSearchText(isClearSearch ? '' : currentValue);
|
||||
};
|
||||
|
||||
const handleChange = (values: string[]): void => {
|
||||
const having: Having[] = values.map(transformFromStringToHaving);
|
||||
|
||||
const isSelectable =
|
||||
currentFormValue.value.length > 0 &&
|
||||
currentFormValue.value.every((value) => !!value);
|
||||
|
||||
if (isSelectable) {
|
||||
onChange(having);
|
||||
resetChanges();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
getPopupContainer={getPopupContainer}
|
||||
autoClearSearchValue={false}
|
||||
mode="multiple"
|
||||
onSearch={handleSearch}
|
||||
searchValue={searchText}
|
||||
data-testid="havingSelectFormula"
|
||||
placeholder="Count(operation) > 5"
|
||||
style={{ width: '100%' }}
|
||||
tagRender={tagRender}
|
||||
onDeselect={handleDeselect}
|
||||
onSelect={handleSelect}
|
||||
onChange={handleChange}
|
||||
value={localValues}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<Select.Option key={opt.value} value={opt.value} title="havingOption">
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export default HavingFilter;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { HavingFilterTagProps } from 'container/QueryBuilder/components/HavingFilterTag/HavingFilterTag.interfaces';
|
||||
import {
|
||||
Having,
|
||||
IBuilderFormula,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export type HavingFilterProps = {
|
||||
formula: IBuilderFormula;
|
||||
onChange: (having: Having[]) => void;
|
||||
};
|
||||
|
||||
export type HavingTagRenderProps = Omit<HavingFilterTagProps, 'onUpdate'>;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { InputNumber } from 'antd';
|
||||
|
||||
import { selectStyle } from '../../QueryBuilderSearchV2/config';
|
||||
import { handleKeyDownLimitFilter } from '../../utils';
|
||||
import { LimitFilterProps } from './types';
|
||||
|
||||
function LimitFilter({ onChange, formula }: LimitFilterProps): JSX.Element {
|
||||
return (
|
||||
<InputNumber
|
||||
min={1}
|
||||
type="number"
|
||||
value={formula.limit}
|
||||
style={selectStyle}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDownLimitFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default LimitFilter;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface LimitFilterProps {
|
||||
onChange: (values: number | null) => void;
|
||||
formula: IBuilderFormula;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../../QueryBuilderSearchV2/config';
|
||||
import { OrderByProps } from './types';
|
||||
import { useOrderByFormulaFilter } from './useOrderByFormulaFilter';
|
||||
|
||||
function OrderByFilter({
|
||||
formula,
|
||||
onChange,
|
||||
query,
|
||||
}: OrderByProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const {
|
||||
debouncedSearchText,
|
||||
createOptions,
|
||||
aggregationOptions,
|
||||
handleChange,
|
||||
handleSearchKeys,
|
||||
selectedValue,
|
||||
generateOptions,
|
||||
} = useOrderByFormulaFilter({
|
||||
query,
|
||||
onChange,
|
||||
formula,
|
||||
});
|
||||
|
||||
const { data, isFetching } = useGetAggregateKeys(
|
||||
{
|
||||
aggregateAttribute: query.aggregateAttribute?.key || '',
|
||||
dataSource: query.dataSource,
|
||||
aggregateOperator: query.aggregateOperator || '',
|
||||
searchText: debouncedSearchText,
|
||||
},
|
||||
{
|
||||
enabled: !!query.aggregateAttribute?.key,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const optionsData = useMemo(() => {
|
||||
const keyOptions = createOptions(data?.payload?.attributeKeys || []);
|
||||
const groupByOptions = createOptions(query.groupBy);
|
||||
const options =
|
||||
query.aggregateOperator === MetricAggregateOperator.NOOP
|
||||
? keyOptions
|
||||
: [...groupByOptions, ...aggregationOptions];
|
||||
|
||||
return generateOptions(options);
|
||||
}, [
|
||||
aggregationOptions,
|
||||
createOptions,
|
||||
data?.payload?.attributeKeys,
|
||||
generateOptions,
|
||||
query.aggregateOperator,
|
||||
query.groupBy,
|
||||
]);
|
||||
|
||||
const isDisabledSelect =
|
||||
!query.aggregateAttribute?.key ||
|
||||
query.aggregateOperator === MetricAggregateOperator.NOOP;
|
||||
|
||||
return (
|
||||
<Select
|
||||
getPopupContainer={getPopupContainer}
|
||||
mode="tags"
|
||||
style={selectStyle}
|
||||
onSearch={handleSearchKeys}
|
||||
showSearch
|
||||
disabled={isDisabledSelect}
|
||||
showArrow={false}
|
||||
value={selectedValue}
|
||||
labelInValue
|
||||
filterOption={false}
|
||||
options={optionsData}
|
||||
notFoundContent={isFetching ? <Spin size="small" /> : null}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrderByFilter;
|
||||
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface OrderByProps {
|
||||
formula: IBuilderFormula;
|
||||
query: IBuilderQuery;
|
||||
onChange: (value: IBuilderFormula['orderBy']) => void;
|
||||
}
|
||||
|
||||
export type IOrderByFormulaFilterProps = OrderByProps;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { IOption } from 'hooks/useResourceAttribute/types';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import uniqWith from 'lodash-es/uniqWith';
|
||||
import { parse } from 'papaparse';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { ORDERBY_FILTERS } from '../../OrderByFilter/config';
|
||||
import { SIGNOZ_VALUE } from '../../OrderByFilter/constants';
|
||||
import { UseOrderByFilterResult } from '../../OrderByFilter/useOrderByFilter';
|
||||
import {
|
||||
getLabelFromValue,
|
||||
mapLabelValuePairs,
|
||||
orderByValueDelimiter,
|
||||
} from '../../OrderByFilter/utils';
|
||||
import { getRemoveOrderFromValue } from '../../QueryBuilderSearchV2/utils';
|
||||
import { getUniqueOrderByValues, getValidOrderByResult } from '../../utils';
|
||||
import { IOrderByFormulaFilterProps } from './types';
|
||||
import { transformToOrderByStringValuesByFormula } from './utils';
|
||||
|
||||
export const useOrderByFormulaFilter = ({
|
||||
onChange,
|
||||
formula,
|
||||
}: IOrderByFormulaFilterProps): UseOrderByFilterResult => {
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
|
||||
const debouncedSearchText = useDebounce(searchText, DEBOUNCE_DELAY);
|
||||
|
||||
const handleSearchKeys = (searchText: string): void =>
|
||||
setSearchText(searchText);
|
||||
|
||||
const handleChange = (values: IOption[]): void => {
|
||||
const validResult = getValidOrderByResult(values);
|
||||
const result = getUniqueOrderByValues(validResult);
|
||||
|
||||
const orderByValues: OrderByPayload[] = result.map((item) => {
|
||||
const match = parse(item.value, { delimiter: orderByValueDelimiter });
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
columnName: item.value,
|
||||
order: ORDERBY_FILTERS.ASC,
|
||||
};
|
||||
}
|
||||
|
||||
const [columnName, order] = match.data.flat() as string[];
|
||||
|
||||
const columnNameValue =
|
||||
columnName === SIGNOZ_VALUE ? SIGNOZ_VALUE : columnName;
|
||||
|
||||
const orderValue = order ?? ORDERBY_FILTERS.ASC;
|
||||
|
||||
return {
|
||||
columnName: columnNameValue,
|
||||
order: orderValue,
|
||||
};
|
||||
});
|
||||
|
||||
setSearchText('');
|
||||
onChange(orderByValues);
|
||||
};
|
||||
|
||||
const aggregationOptions = [
|
||||
{
|
||||
label: `${formula.expression} ${ORDERBY_FILTERS.ASC}`,
|
||||
value: `${SIGNOZ_VALUE}${orderByValueDelimiter}${ORDERBY_FILTERS.ASC}`,
|
||||
},
|
||||
{
|
||||
label: `${formula.expression} ${ORDERBY_FILTERS.DESC}`,
|
||||
value: `${SIGNOZ_VALUE}${orderByValueDelimiter}${ORDERBY_FILTERS.DESC}`,
|
||||
},
|
||||
];
|
||||
|
||||
const selectedValue = transformToOrderByStringValuesByFormula(formula);
|
||||
|
||||
const createOptions = (data: BaseAutocompleteData[]): IOption[] =>
|
||||
mapLabelValuePairs(data).flat();
|
||||
|
||||
const customValue: IOption[] = useMemo(() => {
|
||||
if (!searchText) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: `${searchText} ${ORDERBY_FILTERS.ASC}`,
|
||||
value: `${searchText}${orderByValueDelimiter}${ORDERBY_FILTERS.ASC}`,
|
||||
},
|
||||
{
|
||||
label: `${searchText} ${ORDERBY_FILTERS.DESC}`,
|
||||
value: `${searchText}${orderByValueDelimiter}${ORDERBY_FILTERS.DESC}`,
|
||||
},
|
||||
];
|
||||
}, [searchText]);
|
||||
|
||||
const generateOptions = (options: IOption[]): IOption[] => {
|
||||
const currentCustomValue = options.find(
|
||||
(keyOption) =>
|
||||
getRemoveOrderFromValue(keyOption.value) === debouncedSearchText,
|
||||
)
|
||||
? []
|
||||
: customValue;
|
||||
|
||||
const result = [...currentCustomValue, ...options];
|
||||
|
||||
const uniqResult = uniqWith(result, isEqual);
|
||||
|
||||
return uniqResult.filter(
|
||||
(option) =>
|
||||
!getLabelFromValue(selectedValue).includes(
|
||||
getRemoveOrderFromValue(option.value),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
searchText,
|
||||
debouncedSearchText,
|
||||
selectedValue,
|
||||
aggregationOptions,
|
||||
createOptions,
|
||||
handleChange,
|
||||
handleSearchKeys,
|
||||
generateOptions,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IOption } from 'hooks/useResourceAttribute/types';
|
||||
import { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { SIGNOZ_VALUE } from '../../OrderByFilter/constants';
|
||||
import { orderByValueDelimiter } from '../../OrderByFilter/utils';
|
||||
|
||||
export const transformToOrderByStringValuesByFormula = (
|
||||
formula: IBuilderFormula,
|
||||
): IOption[] => {
|
||||
const prepareSelectedValue: IOption[] =
|
||||
formula?.orderBy?.map((item) => {
|
||||
if (item.columnName === SIGNOZ_VALUE) {
|
||||
return {
|
||||
label: `${formula.expression} ${item.order}`,
|
||||
value: `${item.columnName}${orderByValueDelimiter}${item.order}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: `${item.columnName} ${item.order}`,
|
||||
value: `${item.columnName}${orderByValueDelimiter}${item.order}`,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return prepareSelectedValue;
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
export type OrderByFilterProps = {
|
||||
query: IBuilderQuery;
|
||||
onChange: (values: OrderByPayload[]) => void;
|
||||
isRawQuery?: boolean;
|
||||
isListViewPanel?: boolean;
|
||||
entityVersion?: string;
|
||||
isNewQueryV2?: boolean;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useOrderByFilter } from './useOrderByFilter';
|
||||
export function OrderByFilter({
|
||||
query,
|
||||
onChange,
|
||||
isRawQuery = false,
|
||||
isListViewPanel = false,
|
||||
entityVersion,
|
||||
isNewQueryV2 = false,
|
||||
}: OrderByFilterProps): JSX.Element {
|
||||
@@ -35,7 +35,7 @@ export function OrderByFilter({
|
||||
searchText: debouncedSearchText,
|
||||
},
|
||||
{
|
||||
enabled: !!query.aggregateAttribute?.key || isRawQuery,
|
||||
enabled: !!query.aggregateAttribute?.key || isListViewPanel,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
QUERY_BUILDER_SEARCH_VALUES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import type { WhereClauseConfig } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
|
||||
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
|
||||
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
@@ -87,6 +88,7 @@ interface CustomTagProps {
|
||||
interface QueryBuilderSearchV2Props {
|
||||
query: IBuilderQuery;
|
||||
onChange: (value: TagFilter) => void;
|
||||
whereClauseConfig?: WhereClauseConfig;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
suffixIcon?: React.ReactNode;
|
||||
@@ -143,6 +145,7 @@ function QueryBuilderSearchV2(
|
||||
placeholder,
|
||||
className,
|
||||
suffixIcon,
|
||||
whereClauseConfig,
|
||||
hardcodedAttributeKeys,
|
||||
hasPopupContainer,
|
||||
rootClassName,
|
||||
@@ -474,7 +477,31 @@ function QueryBuilderSearchV2(
|
||||
if (searchValue) {
|
||||
const operatorType =
|
||||
operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID';
|
||||
// if key is added and operator is not present then convert to body CONTAINS key
|
||||
if (
|
||||
currentFilterItem?.key &&
|
||||
isEmpty(currentFilterItem?.op) &&
|
||||
whereClauseConfig?.customKey === 'body' &&
|
||||
whereClauseConfig?.customOp === OPERATORS.CONTAINS
|
||||
) {
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
setTags((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: {
|
||||
key: 'body',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
id: 'body--string----true',
|
||||
},
|
||||
op: OPERATORS.CONTAINS,
|
||||
value: currentFilterItem?.key?.key,
|
||||
},
|
||||
]);
|
||||
setCurrentFilterItem(undefined);
|
||||
setSearchValue('');
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
} else if (
|
||||
currentFilterItem?.op === OPERATORS.EXISTS ||
|
||||
currentFilterItem?.op === OPERATORS.NOT_EXISTS
|
||||
) {
|
||||
@@ -516,6 +543,8 @@ function QueryBuilderSearchV2(
|
||||
currentFilterItem?.op,
|
||||
currentFilterItem?.value,
|
||||
searchValue,
|
||||
whereClauseConfig?.customKey,
|
||||
whereClauseConfig?.customOp,
|
||||
]);
|
||||
|
||||
// this useEffect takes care of tokenisation based on the search state
|
||||
@@ -1056,6 +1085,7 @@ QueryBuilderSearchV2.defaultProps = {
|
||||
placeholder: PLACEHOLDER,
|
||||
className: '',
|
||||
suffixIcon: null,
|
||||
whereClauseConfig: {},
|
||||
hasPopupContainer: true,
|
||||
rootClassName: '',
|
||||
hardcodedAttributeKeys: undefined,
|
||||
|
||||
@@ -26,7 +26,7 @@ export type QueryProps = {
|
||||
isAvailableToDisable: boolean;
|
||||
query: IBuilderQuery;
|
||||
queryVariant?: 'static' | 'dropdown';
|
||||
isRawQuery?: boolean;
|
||||
isListViewPanel?: boolean;
|
||||
showFunctions?: boolean;
|
||||
version: string;
|
||||
showSpanScopeSelector?: boolean;
|
||||
@@ -35,4 +35,4 @@ export type QueryProps = {
|
||||
hasTraceOperator?: boolean;
|
||||
signalSource?: string;
|
||||
isMultiQueryAllowed?: boolean;
|
||||
} & Pick<QueryBuilderProps, 'fieldsConfig' | 'allowedDataSources'>;
|
||||
} & Pick<QueryBuilderProps, 'filterConfigs' | 'queryComponents'>;
|
||||
|
||||
@@ -64,7 +64,6 @@ function TimeSeriesView({
|
||||
panelType = PANEL_TYPES.TIME_SERIES,
|
||||
stackBarChart = false,
|
||||
allowExport = false,
|
||||
exportFileName,
|
||||
onYAxisUnitChange,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
@@ -271,7 +270,7 @@ function TimeSeriesView({
|
||||
yAxisUnit={yAxisUnit}
|
||||
data={data}
|
||||
query={currentQuery}
|
||||
fileName={exportFileName ?? `${dataSource}-timeseries`}
|
||||
fileName={`${dataSource}-timeseries`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -340,7 +339,6 @@ interface TimeSeriesViewProps {
|
||||
stackBarChart?: boolean;
|
||||
// Opt-in: render the client-side export menu (Logs explorer for now).
|
||||
allowExport?: boolean;
|
||||
exportFileName?: string;
|
||||
// Opt-in: render the y-axis unit selector in the header (views without their
|
||||
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
|
||||
onYAxisUnitChange?: (value: string) => void;
|
||||
@@ -353,7 +351,6 @@ TimeSeriesView.defaultProps = {
|
||||
setWarning: undefined,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
stackBarChart: false,
|
||||
exportFileName: undefined,
|
||||
};
|
||||
|
||||
export default TimeSeriesView;
|
||||
|
||||
@@ -1,23 +1,55 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExplorerOrderBy from 'container/ExplorerOrderBy';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const isRawQuery = useMemo(
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
const isList = panelTypes === PANEL_TYPES.LIST;
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: false, isDisabled: false },
|
||||
limit: { isHidden: isList, isDisabled: true },
|
||||
having: { isHidden: isList, isDisabled: true },
|
||||
};
|
||||
|
||||
return config;
|
||||
}, [panelTypes]);
|
||||
|
||||
const renderOrderBy = useCallback(
|
||||
({ query, onChange }: OrderByFilterProps) => (
|
||||
<ExplorerOrderBy query={query} onChange={onChange} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
|
||||
const shouldRenderCustomOrderBy =
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
|
||||
|
||||
return {
|
||||
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
|
||||
};
|
||||
}, [panelTypes, renderOrderBy]);
|
||||
|
||||
const isListViewPanel = useMemo(
|
||||
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
|
||||
[panelTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isRawQuery={isRawQuery}
|
||||
isListViewPanel={isListViewPanel}
|
||||
showTraceOperator
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
queryComponents={queryComponents}
|
||||
panelType={panelTypes}
|
||||
filterConfigs={filterConfigs}
|
||||
showOnlyWhereClause={
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
LIST: false,
|
||||
TRACE: false,
|
||||
BAR: true,
|
||||
AREA: true,
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
TEXT: false,
|
||||
|
||||
@@ -316,7 +316,7 @@ function FullView({
|
||||
<QueryBuilderV2
|
||||
panelType={selectedPanelType}
|
||||
version="v3"
|
||||
isRawQuery={selectedPanelType === PANEL_TYPES.LIST}
|
||||
isListViewPanel={selectedPanelType === PANEL_TYPES.LIST}
|
||||
signalSourceChangeEnabled
|
||||
// filterConfigs={filterConfigs}
|
||||
// queryComponents={queryComponents}
|
||||
|
||||
@@ -19,6 +19,5 @@ export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
[PANEL_TYPES.PIE]: PiePanelWrapper,
|
||||
[PANEL_TYPES.BAR]: BarPanel,
|
||||
[PANEL_TYPES.AREA]: TimeSeriesPanel,
|
||||
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
listViewInitialLogQuery,
|
||||
listViewInitialTraceQuery,
|
||||
mapOfFormulaToFilters,
|
||||
mapOfQueryFilters,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import {
|
||||
@@ -57,8 +59,9 @@ import { getFormatedLegend } from 'utils/getFormatedLegend';
|
||||
export const useQueryOperations: UseQueryOperations = ({
|
||||
query,
|
||||
index,
|
||||
filterConfigs,
|
||||
formula,
|
||||
isRawQuery = false,
|
||||
isListViewPanel = false,
|
||||
entityVersion,
|
||||
isForTraceOperator = false,
|
||||
savePreviousQuery = false,
|
||||
@@ -102,7 +105,46 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
const { dataSource } = query;
|
||||
const { dataSource, aggregateOperator } = query;
|
||||
|
||||
const getNewListOfAdditionalFilters = useCallback(
|
||||
(dataSource: DataSource, isQuery: boolean): string[] => {
|
||||
const additionalFiltersKeys: (keyof Pick<
|
||||
IBuilderQuery,
|
||||
'orderBy' | 'limit' | 'having' | 'stepInterval'
|
||||
>)[] = ['having', 'limit', 'orderBy', 'stepInterval'];
|
||||
|
||||
const mapsOfFilters = isQuery ? mapOfQueryFilters : mapOfFormulaToFilters;
|
||||
|
||||
const result: string[] = mapsOfFilters[dataSource]?.reduce<string[]>(
|
||||
(acc, item) => {
|
||||
if (
|
||||
filterConfigs &&
|
||||
filterConfigs[item.field as (typeof additionalFiltersKeys)[number]]
|
||||
?.isHidden
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push(item.text);
|
||||
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
[filterConfigs],
|
||||
);
|
||||
|
||||
const [listOfAdditionalFilters, setListOfAdditionalFilters] = useState<
|
||||
string[]
|
||||
>(getNewListOfAdditionalFilters(dataSource, true));
|
||||
|
||||
const [listOfAdditionalFormulaFilters, setListOfAdditionalFormulaFilters] =
|
||||
useState<string[]>(getNewListOfAdditionalFilters(dataSource, false));
|
||||
|
||||
const handleChangeOperator = useCallback(
|
||||
(value: string): void => {
|
||||
@@ -418,7 +460,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
removeKeyFromPreviousQuery(newKey);
|
||||
}
|
||||
|
||||
if (isRawQuery) {
|
||||
if (isListViewPanel) {
|
||||
let listPanelQuery: Query | null = null;
|
||||
|
||||
if (nextSource === DataSource.LOGS) {
|
||||
@@ -464,7 +506,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
handleSetQueryData(index, newQueryData);
|
||||
},
|
||||
[
|
||||
isRawQuery,
|
||||
isListViewPanel,
|
||||
panelType,
|
||||
query,
|
||||
handleSetQueryData,
|
||||
@@ -583,18 +625,32 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
handleMetricAggregateAtributeTypes,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const additionalFilters = getNewListOfAdditionalFilters(dataSource, true);
|
||||
|
||||
setListOfAdditionalFilters(additionalFilters);
|
||||
}, [dataSource, aggregateOperator, getNewListOfAdditionalFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
const additionalFilters = getNewListOfAdditionalFilters(dataSource, false);
|
||||
|
||||
setListOfAdditionalFormulaFilters(additionalFilters);
|
||||
}, [dataSource, aggregateOperator, getNewListOfAdditionalFilters]);
|
||||
|
||||
return {
|
||||
isTracePanelType,
|
||||
isMetricsDataSource,
|
||||
isLogsDataSource,
|
||||
operators,
|
||||
spaceAggregationOptions,
|
||||
listOfAdditionalFilters,
|
||||
handleChangeOperator,
|
||||
handleSpaceAggregationChange,
|
||||
handleChangeAggregatorAttribute,
|
||||
handleChangeDataSource,
|
||||
handleDeleteQuery,
|
||||
handleChangeQueryData,
|
||||
listOfAdditionalFormulaFilters,
|
||||
handleChangeFormulaData,
|
||||
handleQueryFunctionsUpdates,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
|
||||
import uPlot, { Series } from 'uplot';
|
||||
|
||||
import { resolveFillOpacity, toAlphaHex } from '../utils/fillOpacity';
|
||||
import { generateGradientFill } from '../utils/generateGradientFill';
|
||||
import { isolatedPointFilter } from '../utils/seriesPointsFilter';
|
||||
import {
|
||||
@@ -59,8 +58,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
}: {
|
||||
resolvedLineColor: string;
|
||||
}): Partial<Series> {
|
||||
const { lineWidth, lineStyle, lineCap, fillColor, fillMode, fillOpacity } =
|
||||
this.props;
|
||||
const { lineWidth, lineStyle, lineCap, fillColor, fillMode } = this.props;
|
||||
const lineConfig: Partial<Series> = {
|
||||
stroke: resolvedLineColor,
|
||||
width: lineWidth ?? DEFAULT_LINE_WIDTH,
|
||||
@@ -88,17 +86,11 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
} else if (this.props.drawStyle === DrawStyle.Histogram) {
|
||||
lineConfig.fill = `${finalFillColor}40`;
|
||||
} else if (fillMode && fillMode !== FillMode.None) {
|
||||
const resolvedOpacity = resolveFillOpacity(fillOpacity);
|
||||
if (fillMode === FillMode.Solid) {
|
||||
lineConfig.fill = `${finalFillColor}${toAlphaHex(resolvedOpacity)}`;
|
||||
lineConfig.fill = `${finalFillColor}70`;
|
||||
} else if (fillMode === FillMode.Gradient) {
|
||||
lineConfig.fill = (self: uPlot): CanvasGradient =>
|
||||
generateGradientFill(
|
||||
self,
|
||||
finalFillColor,
|
||||
'rgba(0, 0, 0, 0)',
|
||||
resolvedOpacity,
|
||||
);
|
||||
generateGradientFill(self, finalFillColor, 'rgba(0, 0, 0, 0)');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import uPlot from 'uplot';
|
||||
|
||||
import { isolatedPointFilter } from '../../utils/seriesPointsFilter';
|
||||
import type { SeriesProps } from '../types';
|
||||
import { DrawStyle, FillMode, LineInterpolation, LineStyle } from '../types';
|
||||
import { DrawStyle, LineInterpolation, LineStyle } from '../types';
|
||||
import { POINT_SIZE_FACTOR, UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
|
||||
|
||||
const createBaseProps = (
|
||||
@@ -362,40 +362,4 @@ describe('UPlotSeriesBuilder', () => {
|
||||
expect(config.points?.filter).toBeUndefined();
|
||||
expect(config.points?.show).toBe(true);
|
||||
});
|
||||
|
||||
// Pins the alpha the solid fill hardcoded before opacity was configurable.
|
||||
it('fills a solid series at the default opacity', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
fillColor: '#112233',
|
||||
fillMode: FillMode.Solid,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(builder.getConfig().fill).toBe('#11223370');
|
||||
});
|
||||
|
||||
it('fills a solid series at the declared opacity', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
fillColor: '#112233',
|
||||
fillMode: FillMode.Solid,
|
||||
fillOpacity: 0.5,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(builder.getConfig().fill).toBe('#11223380');
|
||||
});
|
||||
|
||||
it('leaves an unfilled series without a fill', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
fillColor: '#112233',
|
||||
fillMode: FillMode.None,
|
||||
fillOpacity: 0.5,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(builder.getConfig().fill).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,8 +222,6 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
spanGaps?: boolean | number;
|
||||
fillColor?: string;
|
||||
fillMode?: FillMode;
|
||||
/** 0–1, for `Solid` and `Gradient`; unset uses `DEFAULT_FILL_OPACITY`. */
|
||||
fillOpacity?: number;
|
||||
isDarkMode?: boolean;
|
||||
stepInterval?: number;
|
||||
metric?: { [key: string]: string };
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import {
|
||||
DEFAULT_FILL_OPACITY,
|
||||
GRADIENT_MID_STOP_RATIO,
|
||||
resolveFillOpacity,
|
||||
toAlphaHex,
|
||||
} from '../fillOpacity';
|
||||
|
||||
describe('resolveFillOpacity', () => {
|
||||
it('falls back to the default for a missing or unusable value', () => {
|
||||
expect(resolveFillOpacity(undefined)).toBe(DEFAULT_FILL_OPACITY);
|
||||
expect(resolveFillOpacity(null)).toBe(DEFAULT_FILL_OPACITY);
|
||||
expect(resolveFillOpacity(NaN)).toBe(DEFAULT_FILL_OPACITY);
|
||||
});
|
||||
|
||||
it('keeps 0 rather than treating it as absent', () => {
|
||||
expect(resolveFillOpacity(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps to 0–1', () => {
|
||||
expect(resolveFillOpacity(-0.5)).toBe(0);
|
||||
expect(resolveFillOpacity(2)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAlphaHex', () => {
|
||||
// The alphas hardcoded before opacity was configurable.
|
||||
it('reproduces the legacy solid alpha at the default opacity', () => {
|
||||
expect(toAlphaHex(DEFAULT_FILL_OPACITY)).toBe('70');
|
||||
});
|
||||
|
||||
it('reproduces the legacy gradient mid-stop alpha at the default opacity', () => {
|
||||
expect(toAlphaHex(DEFAULT_FILL_OPACITY * GRADIENT_MID_STOP_RATIO)).toBe('40');
|
||||
});
|
||||
|
||||
it('pads a single-digit alpha', () => {
|
||||
expect(toAlphaHex(0)).toBe('00');
|
||||
expect(toAlphaHex(0.02)).toBe('05');
|
||||
});
|
||||
|
||||
it('maps a full opacity to ff', () => {
|
||||
expect(toAlphaHex(1)).toBe('ff');
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* `0x70 / 255` reproduces the alpha the fill modes hardcoded before opacity was
|
||||
* configurable, so a series that declares none renders byte-identically.
|
||||
*/
|
||||
export const DEFAULT_FILL_OPACITY = 0x70 / 255;
|
||||
|
||||
/** Alpha ratio between a gradient's two stops, so it keeps its falloff at any opacity. */
|
||||
export const GRADIENT_MID_STOP_RATIO = 0x40 / 0x70;
|
||||
|
||||
/** Gradient stop offsets, top to bottom of the plot area. */
|
||||
export const GRADIENT_START_STOP = 0;
|
||||
export const GRADIENT_MID_STOP = 0.6;
|
||||
export const GRADIENT_END_STOP = 1;
|
||||
|
||||
/** Clamps into 0–1; missing or non-finite falls back to the default. */
|
||||
export function resolveFillOpacity(opacity?: number | null): number {
|
||||
if (typeof opacity !== 'number' || !Number.isFinite(opacity)) {
|
||||
return DEFAULT_FILL_OPACITY;
|
||||
}
|
||||
return Math.min(1, Math.max(0, opacity));
|
||||
}
|
||||
|
||||
/** 0–1 opacity → the two-digit hex alpha suffix appended to an `#rrggbb` colour. */
|
||||
export function toAlphaHex(opacity: number): string {
|
||||
const alpha = Math.round(resolveFillOpacity(opacity) * 255);
|
||||
return alpha.toString(16).padStart(2, '0');
|
||||
}
|
||||
@@ -1,19 +1,9 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
DEFAULT_FILL_OPACITY,
|
||||
GRADIENT_END_STOP,
|
||||
GRADIENT_MID_STOP,
|
||||
GRADIENT_MID_STOP_RATIO,
|
||||
GRADIENT_START_STOP,
|
||||
toAlphaHex,
|
||||
} from './fillOpacity';
|
||||
|
||||
export function generateGradientFill(
|
||||
uPlotInstance: uPlot,
|
||||
startColor: string,
|
||||
endColor: string,
|
||||
opacity: number = DEFAULT_FILL_OPACITY,
|
||||
): CanvasGradient {
|
||||
const g = uPlotInstance.ctx.createLinearGradient(
|
||||
0,
|
||||
@@ -21,11 +11,8 @@ export function generateGradientFill(
|
||||
0,
|
||||
uPlotInstance.bbox.height,
|
||||
);
|
||||
g.addColorStop(GRADIENT_START_STOP, `${startColor}${toAlphaHex(opacity)}`);
|
||||
g.addColorStop(
|
||||
GRADIENT_MID_STOP,
|
||||
`${startColor}${toAlphaHex(opacity * GRADIENT_MID_STOP_RATIO)}`,
|
||||
);
|
||||
g.addColorStop(GRADIENT_END_STOP, endColor);
|
||||
g.addColorStop(0, `${startColor}70`);
|
||||
g.addColorStop(0.6, `${startColor}40`);
|
||||
g.addColorStop(1, endColor);
|
||||
return g;
|
||||
}
|
||||
|
||||
@@ -137,15 +137,6 @@ describe('stackSeries', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('tops out at exactly 100 for values that do not divide cleanly', () => {
|
||||
// Accumulating each slice's share drifts past 100 and stretches the y axis.
|
||||
const data: AlignedData = [[1], [63], [78], [44]];
|
||||
|
||||
const [, top] = stackSeries(data, includeAll, StackMode.Percent).data;
|
||||
|
||||
expect(top[0]).toBe(100);
|
||||
});
|
||||
|
||||
it('yields 0 across a column whose signed total cancels to zero', () => {
|
||||
const data: AlignedData = [[1], [10], [-10]];
|
||||
|
||||
|
||||
@@ -42,11 +42,45 @@ interface BuildStackedSeriesParams {
|
||||
mode: StackMode;
|
||||
}
|
||||
|
||||
/** Per-point total. Mixed-sign columns sum signed, as "share of total" implies. */
|
||||
function columnTotals({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
}: Omit<BuildStackedSeriesParams, 'mode'>): number[] {
|
||||
const totals = Array(pointCount).fill(0) as number[];
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex <= valueSeriesCount; seriesIndex++) {
|
||||
if (omit(seriesIndex)) {
|
||||
continue;
|
||||
}
|
||||
const rawValues = data[seriesIndex] as (number | null)[];
|
||||
rawValues.forEach((rawValue, pointIndex) => {
|
||||
totals[pointIndex] += rawValue == null ? 0 : Number(rawValue);
|
||||
});
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** A column whose participating series sum to 0 has no share to divide, so every slice is 0. */
|
||||
function toPercent(value: number, total: number): number {
|
||||
return total === 0 ? 0 : (value / total) * 100;
|
||||
}
|
||||
|
||||
/** What a raw value adds to the stack at a given point. */
|
||||
type Contribution = (value: number, pointIndex: number) => number;
|
||||
|
||||
function contributionForMode(params: BuildStackedSeriesParams): Contribution {
|
||||
if (params.mode !== StackMode.Percent) {
|
||||
return (value): number => value;
|
||||
}
|
||||
// Resolved up front: totals span series the accumulation below has not reached yet.
|
||||
const totals = columnTotals(params);
|
||||
return (value, pointIndex): number => toPercent(value, totals[pointIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate from last series upward: last series = raw values, first = total.
|
||||
* Omitted series are copied as-is (no accumulation).
|
||||
@@ -59,7 +93,14 @@ function buildStackedSeries({
|
||||
mode,
|
||||
}: BuildStackedSeriesParams): (number | null)[][] {
|
||||
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
|
||||
const columnTotals = Array(pointCount).fill(0) as number[];
|
||||
const cumulativeSums = Array(pointCount).fill(0) as number[];
|
||||
const contributionOf = contributionForMode({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
mode,
|
||||
});
|
||||
|
||||
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
|
||||
const rawValues = data[seriesIndex] as (number | null)[];
|
||||
@@ -69,27 +110,14 @@ function buildStackedSeries({
|
||||
} else {
|
||||
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
|
||||
const numericValue = rawValue == null ? 0 : Number(rawValue);
|
||||
return (columnTotals[pointIndex] += numericValue);
|
||||
return (cumulativeSums[pointIndex] += contributionOf(
|
||||
numericValue,
|
||||
pointIndex,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mode !== StackMode.Percent) {
|
||||
return stackedSeries;
|
||||
}
|
||||
|
||||
// Scale the running totals once they are final rather than accumulating per-slice
|
||||
// percentages: the topmost series then divides the total by itself and lands on
|
||||
// exactly 100, where accumulated shares drift past it and stretch the y axis.
|
||||
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
|
||||
if (omit(seriesIndex)) {
|
||||
continue;
|
||||
}
|
||||
stackedSeries[seriesIndex - 1] = stackedSeries[seriesIndex - 1].map(
|
||||
(value, pointIndex) => toPercent(value as number, columnTotals[pointIndex]),
|
||||
);
|
||||
}
|
||||
|
||||
return stackedSeries;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
// The design-system slider insets its track by half a thumb on each side so the
|
||||
// fill follows the thumb's centre. Pull that inset back off the row so the track
|
||||
// lines up with the other controls; the thumb never paints past the track edge.
|
||||
margin-inline: calc(var(--slider-thumb-width, 18px) / -2);
|
||||
}
|
||||
|
||||
.value {
|
||||
flex-shrink: 0;
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Slider } from '@signozhq/ui/slider';
|
||||
|
||||
import styles from './ConfigSlider.module.scss';
|
||||
|
||||
interface ConfigSliderProps {
|
||||
testId: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
/** Renders the current value beside the track (e.g. as a percentage). */
|
||||
formatValue?: (value: number) => string;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric slider for the config sections. The design-system Slider is multi-thumb
|
||||
* capable, so its callback hands back `number | number[]`; this narrows to one thumb.
|
||||
*/
|
||||
function ConfigSlider({
|
||||
testId,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
formatValue,
|
||||
onChange,
|
||||
}: ConfigSliderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<Slider
|
||||
testId={testId}
|
||||
className={styles.slider}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(next): void => onChange(Array.isArray(next) ? next[0] : next)}
|
||||
/>
|
||||
<span className={styles.value}>
|
||||
{formatValue ? formatValue(value) : value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigSlider;
|
||||
@@ -2,16 +2,16 @@ import type { ComponentType } from 'react';
|
||||
import type {
|
||||
DashboardtypesLinkDTO,
|
||||
DashboardtypesAxesDTO,
|
||||
DashboardtypesBarChartVisualizationDTO,
|
||||
DashboardtypesHistogramBucketsDTO,
|
||||
DashboardtypesLegendDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesTimeSeriesChartAppearanceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
SectionKind,
|
||||
type AnyThreshold,
|
||||
type PanelChartAppearanceSlice,
|
||||
type PanelFormattingSlice,
|
||||
type PanelVisualizationSlice,
|
||||
type SectionEditorProps,
|
||||
type SectionSpecMap,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
@@ -94,15 +94,21 @@ export const SECTION_REGISTRY: {
|
||||
},
|
||||
[SectionKind.ChartAppearance]: {
|
||||
Component: ChartAppearanceSection,
|
||||
get: (spec): PanelChartAppearanceSlice | undefined =>
|
||||
getPluginSlice<PanelChartAppearanceSlice>(spec, 'chartAppearance'),
|
||||
get: (spec): DashboardtypesTimeSeriesChartAppearanceDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesTimeSeriesChartAppearanceDTO>(
|
||||
spec,
|
||||
'chartAppearance',
|
||||
),
|
||||
update: (spec, chartAppearance): PanelSpec =>
|
||||
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
|
||||
},
|
||||
[SectionKind.Visualization]: {
|
||||
Component: VisualizationSection,
|
||||
get: (spec): PanelVisualizationSlice | undefined =>
|
||||
getPluginSlice<PanelVisualizationSlice>(spec, 'visualization'),
|
||||
get: (spec): DashboardtypesBarChartVisualizationDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesBarChartVisualizationDTO>(
|
||||
spec,
|
||||
'visualization',
|
||||
),
|
||||
update: (spec, visualization): PanelSpec =>
|
||||
updatePluginSlice(spec, 'visualization', visualization),
|
||||
},
|
||||
|
||||
@@ -9,11 +9,8 @@ import type {
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import { resolveFillOpacity } from 'lib/uPlotV2/utils/fillOpacity';
|
||||
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import ConfigSlider from '../../controls/ConfigSlider/ConfigSlider';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import { SegmentIcon } from '../../controls/segmentIcons';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
@@ -75,21 +72,10 @@ const FILL_MODE_OPTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
// An always-filled kind's wire enum (`AreaFillMode`) has no `none`.
|
||||
const FILLED_FILL_MODE_OPTIONS = FILL_MODE_OPTIONS.filter(
|
||||
(option) => option.value !== DashboardtypesFillModeDTO.none,
|
||||
);
|
||||
|
||||
const FILL_OPACITY_STEP = 0.01;
|
||||
|
||||
function formatOpacity(opacity: number): string {
|
||||
return `${Math.round(opacity * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits the `chartAppearance` slice of a TimeSeries panel spec: line style /
|
||||
* interpolation, fill mode, fill opacity, point markers, and the connect-null-gaps
|
||||
* threshold. Each control is gated by its `controls` flag.
|
||||
* interpolation, fill mode, point markers, and the connect-null-gaps threshold. Each
|
||||
* control is gated by its `controls` flag.
|
||||
*/
|
||||
function ChartAppearanceSection({
|
||||
value,
|
||||
@@ -138,9 +124,7 @@ function ChartAppearanceSection({
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-fill-mode"
|
||||
value={value?.fillMode}
|
||||
items={
|
||||
controls.fillOpacity ? FILLED_FILL_MODE_OPTIONS : FILL_MODE_OPTIONS
|
||||
}
|
||||
items={FILL_MODE_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({ ...value, fillMode: next as DashboardtypesFillModeDTO })
|
||||
}
|
||||
@@ -148,22 +132,6 @@ function ChartAppearanceSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.fillOpacity && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Fill opacity</Typography.Text>
|
||||
<ConfigSlider
|
||||
testId="panel-editor-v2-fill-opacity"
|
||||
// The chart's own default, so the thumb starts where an unset fill renders.
|
||||
value={resolveFillOpacity(value?.fillOpacity)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={FILL_OPACITY_STEP}
|
||||
formatValue={formatOpacity}
|
||||
onChange={(fillOpacity): void => onChange({ ...value, fillOpacity })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.showPoints && (
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-show-points"
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
DashboardtypesLineStyleDTO,
|
||||
type DashboardtypesTimeSeriesChartAppearanceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DEFAULT_FILL_OPACITY } from 'lib/uPlotV2/utils/fillOpacity';
|
||||
|
||||
import ChartAppearanceSection from '../ChartAppearanceSection';
|
||||
|
||||
@@ -105,66 +104,6 @@ describe('ChartAppearanceSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the fill opacity at the chart default when the spec omits it', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ fillOpacity: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-fill-opacity'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(`${Math.round(DEFAULT_FILL_OPACITY * 100)}%`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the stored fill opacity as a percentage', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ fillOpacity: 0.25 }}
|
||||
controls={{ fillOpacity: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('25%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers no None fill mode to a kind that declares fill opacity', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ fillMode: true, fillOpacity: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Solid')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gradient')).toBeInTheDocument();
|
||||
expect(screen.queryByText('None')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-fill-opacity'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers all three fill modes to a kind that can be unfilled', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ fillMode: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('None')).toBeInTheDocument();
|
||||
expect(screen.getByText('Solid')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gradient')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes the chosen line interpolation through the dropdown', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesStackModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import PanelTypeSwitcher from '../../PanelTypeSwitcher/PanelTypeSwitcher';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
import { STACK_MODE_OPTIONS } from './stackModeOptions';
|
||||
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
|
||||
|
||||
import styles from './VisualizationSection.module.scss';
|
||||
@@ -24,10 +21,9 @@ type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
|
||||
|
||||
/**
|
||||
* Edits the `visualization` slice: the panel-type switcher (`switchPanelKind`, every
|
||||
* kind), the per-panel time preference, bar stacking (`stackedBarChart`, Bar only),
|
||||
* area stacking (`stack`, Area only) and gap filling (`fillSpans`). Each control is
|
||||
* gated by its `controls` flag, so a kind only renders — and only writes — the fields
|
||||
* its spec supports.
|
||||
* kind), the per-panel time preference, bar stacking (`stackedBarChart`, Bar only), and
|
||||
* gap filling (`fillSpans`, TimeSeries only). Each control is gated by its `controls`
|
||||
* flag, so a kind only renders — and only writes — the fields its spec supports.
|
||||
*/
|
||||
function VisualizationSection({
|
||||
value,
|
||||
@@ -81,20 +77,6 @@ function VisualizationSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.stackMode && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Stack series</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-stack-mode"
|
||||
value={value?.stack}
|
||||
items={STACK_MODE_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({ ...value, stack: next as DashboardtypesStackModeDTO })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.fillSpans && (
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-fill-spans"
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
DashboardtypesStackModeDTO,
|
||||
DashboardtypesTimePreferenceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import VisualizationSection from '../VisualizationSection';
|
||||
|
||||
@@ -118,43 +115,6 @@ describe('VisualizationSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('writes the chosen stack mode through the segmented control', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={{ fillSpans: true }}
|
||||
controls={{ switchPanelKind: true, stackMode: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-stack-mode')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Percent'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
fillSpans: true,
|
||||
stack: DashboardtypesStackModeDTO.percent,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders no stack-mode control for a kind that declares bar stacking', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ switchPanelKind: true, stacking: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-stacked-bar-chart'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-stack-mode'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles fill spans through onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user