mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-18 09:20:41 +01:00
Compare commits
166 Commits
nv/slack-m
...
feat/heatm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f04962e73 | ||
|
|
ff8ec64c2e | ||
|
|
6360da7d7c | ||
|
|
d5a9f5f022 | ||
|
|
02069e0a5e | ||
|
|
a09d5338b3 | ||
|
|
0241b8081c | ||
|
|
4260363b45 | ||
|
|
19c4aed27c | ||
|
|
f7b68ee687 | ||
|
|
232122ecda | ||
|
|
42fdcdefef | ||
|
|
30ad2a303d | ||
|
|
d39938d462 | ||
|
|
e81e9a2fd3 | ||
|
|
00718c0662 | ||
|
|
55a0c8dd35 | ||
|
|
9f1b1476f4 | ||
|
|
0bea671a2d | ||
|
|
0b1695c9c2 | ||
|
|
b4f42cf388 | ||
|
|
65a8a58600 | ||
|
|
a3c90ab132 | ||
|
|
da4c5578d3 | ||
|
|
bf01867c3c | ||
|
|
41187a5fca | ||
|
|
d04dc6f7db | ||
|
|
71badcf65c | ||
|
|
68fe15d5fc | ||
|
|
f8aded8c8e | ||
|
|
f6a6793e96 | ||
|
|
452046d70c | ||
|
|
2e9c9067f3 | ||
|
|
2ddd45cb8c | ||
|
|
8809d8a7ac | ||
|
|
21edb05f2b | ||
|
|
f659205866 | ||
|
|
215f6f8e0f | ||
|
|
802f7e4a3d | ||
|
|
81f1105fbc | ||
|
|
575aa57426 | ||
|
|
f75d3d8724 | ||
|
|
43538b92db | ||
|
|
ef65c2c08c | ||
|
|
8e41d3d30d | ||
|
|
b249ab8275 | ||
|
|
61bca035a3 | ||
|
|
9983b3344b | ||
|
|
1313a289b6 | ||
|
|
82a789c19e | ||
|
|
ec09873573 | ||
|
|
00cc773792 | ||
|
|
cf5dd79216 | ||
|
|
6546a0abc6 | ||
|
|
3874f3a6ec | ||
|
|
86db0fd26b | ||
|
|
f382a958db | ||
|
|
7c3481b230 | ||
|
|
24f3116527 | ||
|
|
17f0f49398 | ||
|
|
f681bacdcb | ||
|
|
ded4ed3d76 | ||
|
|
1f84f9c166 | ||
|
|
87127b580c | ||
|
|
fc4bd13fce | ||
|
|
075a4b164d | ||
|
|
2170189d14 | ||
|
|
b7720b655e | ||
|
|
f34973b897 | ||
|
|
d9d041a8fa | ||
|
|
a0e66e3592 | ||
|
|
a0dff26fbc | ||
|
|
db0b1b2b2a | ||
|
|
fd8ed18592 | ||
|
|
392b5826fd | ||
|
|
4b4023d706 | ||
|
|
f8d803d787 | ||
|
|
a33320c665 | ||
|
|
695a481989 | ||
|
|
3fc763b95b | ||
|
|
027b65574d | ||
|
|
43ec98b309 | ||
|
|
a196a436be | ||
|
|
3f456b6602 | ||
|
|
f777e068c3 | ||
|
|
5a690695e6 | ||
|
|
bf362d15db | ||
|
|
7e0f70ffa7 | ||
|
|
590046adf3 | ||
|
|
106cb9f6c2 | ||
|
|
f95699b1f7 | ||
|
|
1e75b66379 | ||
|
|
b80013d075 | ||
|
|
4a6e25708d | ||
|
|
3b8f9a7830 | ||
|
|
68bff5b053 | ||
|
|
732c8ae18a | ||
|
|
6911630c2a | ||
|
|
2d78d8add9 | ||
|
|
8e47c7d337 | ||
|
|
991e734c33 | ||
|
|
40c881b8f5 | ||
|
|
a2c8dd5275 | ||
|
|
32ae7a26d5 | ||
|
|
4067b04fa2 | ||
|
|
70409f5ee3 | ||
|
|
5d9953bb7e | ||
|
|
edfff0ee72 | ||
|
|
44556060a3 | ||
|
|
545e0342d2 | ||
|
|
2f559f34d3 | ||
|
|
e4d90b91b7 | ||
|
|
f33616a598 | ||
|
|
2ac94fc2fc | ||
|
|
7292a6946b | ||
|
|
dff49a4135 | ||
|
|
690c207519 | ||
|
|
d9b6d1f425 | ||
|
|
1eb974d91a | ||
|
|
1141f6bf14 | ||
|
|
a3fb926726 | ||
|
|
fe1e86f385 | ||
|
|
e30d9a1a14 | ||
|
|
c9a345713c | ||
|
|
22f8eba44a | ||
|
|
e8ad9044d5 | ||
|
|
576cd5052a | ||
|
|
4214ccf93d | ||
|
|
63f5c17238 | ||
|
|
fd6abd67dd | ||
|
|
a78c35d61c | ||
|
|
b2c4916a86 | ||
|
|
feff1a0179 | ||
|
|
eafac1be69 | ||
|
|
510d6985d0 | ||
|
|
758aacb065 | ||
|
|
5599c352d0 | ||
|
|
96d0a2340c | ||
|
|
c6b415ad04 | ||
|
|
9b922f297f | ||
|
|
e81a4c4046 | ||
|
|
9ba13848ea | ||
|
|
2578f25dd4 | ||
|
|
82352973bb | ||
|
|
3eb55692f1 | ||
|
|
61ceb6d300 | ||
|
|
e33fc20579 | ||
|
|
b130a61049 | ||
|
|
132e9bb740 | ||
|
|
9d577731e1 | ||
|
|
4d54568fe1 | ||
|
|
6f82624523 | ||
|
|
da0bc4f438 | ||
|
|
2dcb764632 | ||
|
|
1bc25e16b8 | ||
|
|
eebc6b400d | ||
|
|
30c4508113 | ||
|
|
c4fe619920 | ||
|
|
d597adddda | ||
|
|
9a05f33e7a | ||
|
|
3812937c6f | ||
|
|
34aa719cba | ||
|
|
cdfce4c4ab | ||
|
|
d8ef672f83 | ||
|
|
00efffc127 | ||
|
|
dec922a83f |
@@ -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:
|
||||
@@ -3616,6 +3552,79 @@ components:
|
||||
hide:
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesHeatmapAxes:
|
||||
properties:
|
||||
yScale:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapYScale'
|
||||
type: object
|
||||
DashboardtypesHeatmapChartAppearance:
|
||||
properties:
|
||||
colors:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColors'
|
||||
type: object
|
||||
DashboardtypesHeatmapColorMode:
|
||||
enum:
|
||||
- palette
|
||||
- opacity
|
||||
type: string
|
||||
DashboardtypesHeatmapColorScale:
|
||||
enum:
|
||||
- log
|
||||
- sqrt
|
||||
- linear
|
||||
type: string
|
||||
DashboardtypesHeatmapColors:
|
||||
properties:
|
||||
fill:
|
||||
type: string
|
||||
maxCount:
|
||||
nullable: true
|
||||
type: number
|
||||
minCount:
|
||||
nullable: true
|
||||
type: number
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
|
||||
palette:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapPalette'
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
|
||||
steps:
|
||||
type: integer
|
||||
type: object
|
||||
DashboardtypesHeatmapPalette:
|
||||
enum:
|
||||
- ice
|
||||
- moss
|
||||
- rust
|
||||
- graphite
|
||||
- ember
|
||||
- lagoon
|
||||
- orchid
|
||||
- verdant
|
||||
- lava
|
||||
- beacon
|
||||
type: string
|
||||
DashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapChartAppearance'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesHeatmapYScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -3968,6 +3977,7 @@ components:
|
||||
discriminator:
|
||||
mapping:
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
@@ -3985,6 +3995,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -3996,6 +4007,7 @@ components:
|
||||
- signoz/HistogramPanel
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
@@ -4009,6 +4021,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/HeatmapPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
|
||||
181
docs/contributing/go/dslfilteringtosql.md
Normal file
181
docs/contributing/go/dslfilteringtosql.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# 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.
|
||||
@@ -21,4 +21,5 @@ 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/`
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
// Mock for uplot library used in tests
|
||||
export interface MockUPlotInstance {
|
||||
/** Consumers read `root.parentElement` to detect a re-mounted container. */
|
||||
root: HTMLDivElement;
|
||||
setData: jest.Mock;
|
||||
setSize: jest.Mock;
|
||||
destroy: jest.Mock;
|
||||
@@ -17,13 +19,20 @@ export interface MockUPlotPaths {
|
||||
}
|
||||
|
||||
// Create mock instance methods
|
||||
const createMockUPlotInstance = (): MockUPlotInstance => ({
|
||||
setData: jest.fn(),
|
||||
setSize: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
setSeries: jest.fn(),
|
||||
});
|
||||
const createMockUPlotInstance = (target?: HTMLElement): MockUPlotInstance => {
|
||||
const root = document.createElement('div');
|
||||
// Real uPlot mounts its root inside the target; without it a re-render reads
|
||||
// `root.parentElement` off undefined and throws.
|
||||
target?.appendChild(root);
|
||||
return {
|
||||
root,
|
||||
setData: jest.fn(),
|
||||
setSize: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
setSeries: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
|
||||
const createMockPathBuilder = (name: string): jest.Mock =>
|
||||
@@ -53,14 +62,16 @@ const mockTzDate = jest.fn(
|
||||
function MockUPlot(
|
||||
_options: unknown,
|
||||
_data: unknown,
|
||||
_target: HTMLElement,
|
||||
target: HTMLElement,
|
||||
): MockUPlotInstance {
|
||||
return createMockUPlotInstance();
|
||||
return createMockUPlotInstance(target);
|
||||
}
|
||||
|
||||
// Add static methods to the constructor
|
||||
MockUPlot.tzDate = mockTzDate;
|
||||
MockUPlot.paths = mockPaths;
|
||||
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
|
||||
MockUPlot.pxRatio = 1;
|
||||
|
||||
// Export the constructor as default
|
||||
export default MockUPlot;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,6 +17,7 @@ function InputWithLabel({
|
||||
onChange,
|
||||
className,
|
||||
closeIcon,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
initialValue?: string | number | null;
|
||||
@@ -27,6 +28,7 @@ function InputWithLabel({
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
closeIcon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}): JSX.Element {
|
||||
const [inputValue, setInputValue] = useState<string>(
|
||||
initialValue ? initialValue.toString() : '',
|
||||
@@ -53,6 +55,7 @@ function InputWithLabel({
|
||||
type={type}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
name={label.toLowerCase()}
|
||||
data-testid={`input-${label}`}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { OPERATORS, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { 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';
|
||||
@@ -14,12 +21,18 @@ 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,
|
||||
filterConfigs = {},
|
||||
queryComponents,
|
||||
isListViewPanel = false,
|
||||
fieldsConfig,
|
||||
allowedDataSources,
|
||||
isRawQuery = false,
|
||||
showOnlyWhereClause = false,
|
||||
showTraceOperator = false,
|
||||
version,
|
||||
@@ -71,55 +84,48 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isMultiQueryAllowed = useMemo(
|
||||
() => !isListViewPanel || showTraceOperator,
|
||||
[showTraceOperator, isListViewPanel],
|
||||
const resolvedConfig = useMemo(
|
||||
() =>
|
||||
mergeQueryBuilderFieldsConfig(
|
||||
isRawQuery ? RAW_QUERY_FIELDS : undefined,
|
||||
fieldsConfig,
|
||||
),
|
||||
[isRawQuery, fieldsConfig],
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
const additionalQueries = useMemo(
|
||||
() =>
|
||||
resolveQueryBuilderField(
|
||||
QueryBuilderField.AdditionalQueries,
|
||||
resolvedConfig,
|
||||
),
|
||||
[resolvedConfig],
|
||||
);
|
||||
|
||||
return config;
|
||||
}, []);
|
||||
const formula = useMemo(
|
||||
() => resolveQueryBuilderField(QueryBuilderField.Formula, resolvedConfig),
|
||||
[resolvedConfig],
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
const isMultiQueryAllowed = useMemo(
|
||||
() => !additionalQueries.hidden && (!isRawQuery || showTraceOperator),
|
||||
[additionalQueries.hidden, showTraceOperator, isRawQuery],
|
||||
);
|
||||
|
||||
return config;
|
||||
}, []);
|
||||
const queryDataSources = useMemo(
|
||||
() => allowedDataSources ?? (isRawQuery ? RAW_QUERY_SIGNALS : undefined),
|
||||
[allowedDataSources, isRawQuery],
|
||||
);
|
||||
|
||||
const queryFilterConfigs = useMemo(() => {
|
||||
if (isListViewPanel) {
|
||||
return currentQuery.builder.queryData[0].dataSource === DataSource.TRACES
|
||||
? listViewTracesFilterConfigs
|
||||
: listViewLogFilterConfigs;
|
||||
}
|
||||
|
||||
return filterConfigs;
|
||||
}, [
|
||||
isListViewPanel,
|
||||
filterConfigs,
|
||||
currentQuery.builder.queryData,
|
||||
listViewLogFilterConfigs,
|
||||
listViewTracesFilterConfigs,
|
||||
]);
|
||||
// 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],
|
||||
);
|
||||
|
||||
const traceOperator = useMemo((): IBuilderTraceOperator | undefined => {
|
||||
if (
|
||||
@@ -145,31 +151,46 @@ 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 && !isListViewPanel) || showTraceOperator,
|
||||
[isListViewPanel, showOnlyWhereClause, showTraceOperator],
|
||||
() => (!showOnlyWhereClause && !isRawQuery) || showTraceOperator,
|
||||
[isRawQuery, showOnlyWhereClause, showTraceOperator],
|
||||
);
|
||||
|
||||
const showFormula = useMemo(() => {
|
||||
if (formula.hidden) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentDataSource === DataSource.TRACES) {
|
||||
return !isListViewPanel;
|
||||
return !isRawQuery;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [isListViewPanel, currentDataSource]);
|
||||
}, [formula.hidden, isRawQuery, 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;
|
||||
@@ -199,8 +220,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
key={currentQuery.builder.queryData[0].queryName}
|
||||
index={0}
|
||||
query={currentQuery.builder.queryData[0]}
|
||||
filterConfigs={queryFilterConfigs}
|
||||
queryComponents={queryComponents}
|
||||
fieldsConfig={fieldsConfig}
|
||||
allowedDataSources={queryDataSources}
|
||||
isMultiQueryAllowed={isMultiQueryAllowed}
|
||||
showTraceOperator={showTraceOperator}
|
||||
hasTraceOperator={hasTraceOperator}
|
||||
@@ -208,7 +229,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
isAvailableToDisable={false}
|
||||
queryVariant={config?.queryVariant || 'dropdown'}
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
isListViewPanel={isListViewPanel}
|
||||
isRawQuery={isRawQuery}
|
||||
signalSource={currentQuery.builder.queryData[0].source as 'meter' | ''}
|
||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
@@ -216,14 +237,14 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
savePreviousQuery={savePreviousQuery}
|
||||
/>
|
||||
) : (
|
||||
currentQuery.builder.queryData.map((query, index) => (
|
||||
renderedQueries.map((query, index) => (
|
||||
<QueryV2
|
||||
ref={containerRef}
|
||||
key={query.queryName}
|
||||
index={index}
|
||||
query={query}
|
||||
filterConfigs={queryFilterConfigs}
|
||||
queryComponents={queryComponents}
|
||||
fieldsConfig={fieldsConfig}
|
||||
allowedDataSources={queryDataSources}
|
||||
version={version}
|
||||
isMultiQueryAllowed={isMultiQueryAllowed}
|
||||
isAvailableToDisable={false}
|
||||
@@ -231,7 +252,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
hasTraceOperator={hasTraceOperator}
|
||||
queryVariant={config?.queryVariant || 'dropdown'}
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
isListViewPanel={isListViewPanel}
|
||||
isRawQuery={isRawQuery}
|
||||
signalSource={query.source as 'meter' | ''}
|
||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
@@ -251,14 +272,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
|
||||
return (
|
||||
<div key={formula.queryName} className="qb-formula">
|
||||
<Formula
|
||||
filterConfigs={filterConfigs}
|
||||
query={query}
|
||||
formula={formula}
|
||||
index={index}
|
||||
isAdditionalFilterEnable={false}
|
||||
isQBV2
|
||||
/>
|
||||
<Formula query={query} formula={formula} index={index} isQBV2 />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -267,8 +281,13 @@ 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}
|
||||
@@ -277,7 +296,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
|
||||
{hasTraceOperator && (
|
||||
<TraceOperator
|
||||
isListViewPanel={isListViewPanel}
|
||||
isRawQuery={isRawQuery}
|
||||
fieldsConfig={resolvedConfig}
|
||||
traceOperator={traceOperator as IBuilderTraceOperator}
|
||||
/>
|
||||
)}
|
||||
@@ -285,7 +305,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
|
||||
{showQueryList && (
|
||||
<div className="query-names-section">
|
||||
{currentQuery.builder.queryData.map((query) => (
|
||||
{renderedQueries.map((query) => (
|
||||
<div key={query.queryName} className="query-name">
|
||||
{query.queryName}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--margin-2);
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, 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';
|
||||
@@ -14,6 +15,16 @@ 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';
|
||||
|
||||
@@ -22,34 +33,37 @@ import './QueryAddOns.styles.scss';
|
||||
interface AddOn {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
key: string;
|
||||
key: QueryBuilderField;
|
||||
description?: string;
|
||||
docLink?: string;
|
||||
}
|
||||
|
||||
const ADD_ONS_KEYS = {
|
||||
GROUP_BY: 'group_by',
|
||||
HAVING: 'having',
|
||||
ORDER_BY: 'order_by',
|
||||
LIMIT: 'limit',
|
||||
LEGEND_FORMAT: 'legend_format',
|
||||
REDUCE_TO: 'reduce_to',
|
||||
/** 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_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 = [
|
||||
const ADD_ONS: AddOn[] = [
|
||||
{
|
||||
icon: <BarChart size={14} />,
|
||||
label: 'Group By',
|
||||
key: ADD_ONS_KEYS.GROUP_BY,
|
||||
key: QueryBuilderField.GroupBy,
|
||||
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',
|
||||
@@ -57,7 +71,7 @@ const ADD_ONS = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Having',
|
||||
key: ADD_ONS_KEYS.HAVING,
|
||||
key: QueryBuilderField.Having,
|
||||
description:
|
||||
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
|
||||
docLink:
|
||||
@@ -66,7 +80,7 @@ const ADD_ONS = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Order By',
|
||||
key: ADD_ONS_KEYS.ORDER_BY,
|
||||
key: QueryBuilderField.OrderBy,
|
||||
description:
|
||||
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
|
||||
docLink:
|
||||
@@ -75,7 +89,7 @@ const ADD_ONS = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Limit',
|
||||
key: ADD_ONS_KEYS.LIMIT,
|
||||
key: QueryBuilderField.Limit,
|
||||
description:
|
||||
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
|
||||
docLink:
|
||||
@@ -84,7 +98,7 @@ const ADD_ONS = [
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Legend format',
|
||||
key: ADD_ONS_KEYS.LEGEND_FORMAT,
|
||||
key: QueryBuilderField.Legend,
|
||||
description:
|
||||
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
|
||||
docLink:
|
||||
@@ -92,10 +106,10 @@ const ADD_ONS = [
|
||||
},
|
||||
];
|
||||
|
||||
const REDUCE_TO = {
|
||||
const REDUCE_TO: AddOn = {
|
||||
icon: <ScrollText size={14} />,
|
||||
label: 'Reduce to',
|
||||
key: ADD_ONS_KEYS.REDUCE_TO,
|
||||
key: QueryBuilderField.ReduceTo,
|
||||
description:
|
||||
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
|
||||
docLink:
|
||||
@@ -154,26 +168,26 @@ function TooltipContent({
|
||||
function QueryAddOns({
|
||||
query,
|
||||
version,
|
||||
isListViewPanel,
|
||||
isRawQuery,
|
||||
showReduceTo,
|
||||
panelType,
|
||||
index,
|
||||
fieldsConfig,
|
||||
isForTraceOperator = false,
|
||||
}: {
|
||||
query: IBuilderQuery;
|
||||
version: string;
|
||||
isListViewPanel: boolean;
|
||||
isRawQuery: 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<string> | null>(null);
|
||||
const prevAvailableKeysRef = useRef<Set<QueryBuilderField> | null>(null);
|
||||
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
index,
|
||||
@@ -184,40 +198,62 @@ function QueryAddOns({
|
||||
|
||||
const { handleSetQueryData } = useQueryBuilder();
|
||||
|
||||
useEffect(() => {
|
||||
if (isListViewPanel) {
|
||||
setAddOns([]);
|
||||
const supportedAddOns = useMemo((): AddOn[] => {
|
||||
let addOns: AddOn[];
|
||||
|
||||
setSelectedViews([
|
||||
ADD_ONS.find((addOn) => addOn.key === ADD_ONS_KEYS.ORDER_BY) as AddOn,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let filteredAddOns: AddOn[];
|
||||
if (panelType === PANEL_TYPES.VALUE) {
|
||||
// Filter out all add-ons except legend format
|
||||
filteredAddOns = ADD_ONS.filter(
|
||||
(addOn) => addOn.key === ADD_ONS_KEYS.LEGEND_FORMAT,
|
||||
);
|
||||
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 {
|
||||
filteredAddOns = Object.values(ADD_ONS);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
addOns = [...ADD_ONS];
|
||||
}
|
||||
|
||||
if (showReduceTo) {
|
||||
filteredAddOns = [...filteredAddOns, REDUCE_TO];
|
||||
}
|
||||
setAddOns(filteredAddOns);
|
||||
return showReduceTo ? [...addOns, REDUCE_TO] : addOns;
|
||||
}, [panelType, query.dataSource, showReduceTo]);
|
||||
|
||||
const availableAddOnKeys = new Set(filteredAddOns.map((a) => a.key));
|
||||
const resolvedFields = useMemo(
|
||||
() =>
|
||||
resolveQueryBuilderFields(
|
||||
supportedAddOns.map((addOn) => addOn.key),
|
||||
mergeQueryBuilderFieldsConfig(
|
||||
isRawQuery ? RAW_QUERY_FIELDS : undefined,
|
||||
fieldsConfig,
|
||||
),
|
||||
),
|
||||
[supportedAddOns, fieldsConfig, isRawQuery],
|
||||
);
|
||||
|
||||
const offeredAddOns = useMemo(
|
||||
() =>
|
||||
supportedAddOns.filter((addOn) => !resolvedFields.get(addOn.key)?.hidden),
|
||||
[supportedAddOns, resolvedFields],
|
||||
);
|
||||
|
||||
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 previousKeys = prevAvailableKeysRef.current;
|
||||
const hasAvailabilityItemsChanged =
|
||||
previousKeys !== null &&
|
||||
@@ -231,27 +267,39 @@ function QueryAddOns({
|
||||
const activeAddOnKeys = new Set(
|
||||
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
|
||||
.filter(([, path]) => hasValue(get(query, path)))
|
||||
.map(([key]) => key),
|
||||
.map(([key]) => key as QueryBuilderField),
|
||||
);
|
||||
|
||||
// Initial seeding from query values on mount
|
||||
// Initial seeding from query values on mount. A disabled field never opens.
|
||||
setSelectedViews(
|
||||
filteredAddOns.filter(
|
||||
(addOn) =>
|
||||
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
|
||||
),
|
||||
offeredAddOns.filter((addOn) => {
|
||||
const resolved = resolvedFields.get(addOn.key);
|
||||
|
||||
return (
|
||||
resolved?.pinned ||
|
||||
(activeAddOnKeys.has(addOn.key) && !resolved?.disabled)
|
||||
);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) =>
|
||||
filteredAddOns.some((addOn) => addOn.key === view.key),
|
||||
),
|
||||
);
|
||||
}, [panelType, isListViewPanel, query, showReduceTo]);
|
||||
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]);
|
||||
|
||||
const handleOptionClick = (clickedAddOn: AddOn): void => {
|
||||
if (isDisabled(clickedAddOn.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isAlreadySelected = selectedViews.some(
|
||||
(view) => view.key === clickedAddOn.key,
|
||||
);
|
||||
@@ -265,7 +313,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 === ADD_ONS_KEYS.LEGEND_FORMAT &&
|
||||
clickedAddOn.key === QueryBuilderField.Legend &&
|
||||
isEmpty(query?.legend) &&
|
||||
Array.isArray(query.groupBy) &&
|
||||
query.groupBy.length > 0
|
||||
@@ -310,9 +358,16 @@ function QueryAddOns({
|
||||
[handleSetQueryData, index, query],
|
||||
);
|
||||
|
||||
const handleRemoveView = useCallback((key: string): void => {
|
||||
setSelectedViews((prev) => prev.filter((view) => view.key !== key));
|
||||
}, []);
|
||||
const handleRemoveView = useCallback(
|
||||
(key: QueryBuilderField): void => {
|
||||
if (isPinned(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedViews((prev) => prev.filter((view) => view.key !== key));
|
||||
},
|
||||
[isPinned],
|
||||
);
|
||||
|
||||
const handleChangeQueryLegend = useCallback(
|
||||
(value: string) => {
|
||||
@@ -341,7 +396,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 === 'group_by') && (
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.GroupBy) && (
|
||||
<div className="add-on-content" data-testid="group-by-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<Tooltip
|
||||
@@ -369,15 +424,17 @@ function QueryAddOns({
|
||||
onChange={handleChangeGroupByKeys}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView('group_by')}
|
||||
/>
|
||||
{!isPinned(QueryBuilderField.GroupBy) && (
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView(QueryBuilderField.GroupBy)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedViews.find((view) => view.key === 'having') && (
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.Having) && (
|
||||
<div className="add-on-content" data-testid="having-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<Tooltip
|
||||
@@ -397,11 +454,7 @@ function QueryAddOns({
|
||||
</Tooltip>
|
||||
<div className="input">
|
||||
<HavingFilter
|
||||
onClose={(): void => {
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) => view.key !== 'having'),
|
||||
);
|
||||
}}
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Having)}
|
||||
onChange={handleChangeHaving}
|
||||
queryData={query}
|
||||
/>
|
||||
@@ -409,7 +462,7 @@ function QueryAddOns({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedViews.find((view) => view.key === 'limit') && (
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.Limit) && (
|
||||
<div className="add-on-content" data-testid="limit-content">
|
||||
<InputWithLabel
|
||||
label="Limit"
|
||||
@@ -417,16 +470,12 @@ function QueryAddOns({
|
||||
onChange={handleChangeLimit}
|
||||
initialValue={query?.limit ?? undefined}
|
||||
placeholder="Enter limit"
|
||||
onClose={(): void => {
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) => view.key !== 'limit'),
|
||||
);
|
||||
}}
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
|
||||
closeIcon={<ChevronUp size={16} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedViews.find((view) => view.key === 'order_by') && (
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.OrderBy) && (
|
||||
<div className="add-on-content" data-testid="order-by-content">
|
||||
<div className="periscope-input-with-label">
|
||||
<Tooltip
|
||||
@@ -449,22 +498,22 @@ function QueryAddOns({
|
||||
entityVersion={version}
|
||||
query={query}
|
||||
onChange={handleChangeOrderByKeys}
|
||||
isListViewPanel={isListViewPanel}
|
||||
isRawQuery={isRawQuery}
|
||||
isNewQueryV2
|
||||
/>
|
||||
</div>
|
||||
{!isListViewPanel && (
|
||||
{!isPinned(QueryBuilderField.OrderBy) && (
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView('order_by')}
|
||||
onClick={(): void => handleRemoveView(QueryBuilderField.OrderBy)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedViews.find((view) => view.key === 'reduce_to') &&
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.ReduceTo) &&
|
||||
showReduceTo && (
|
||||
<div className="add-on-content" data-testid="reduce-to-content">
|
||||
<div className="periscope-input-with-label">
|
||||
@@ -487,27 +536,25 @@ function QueryAddOns({
|
||||
<ReduceToFilter query={query} onChange={handleChangeReduceToV5} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView('reduce_to')}
|
||||
/>
|
||||
{!isPinned(QueryBuilderField.ReduceTo) && (
|
||||
<Button
|
||||
className="close-btn periscope-btn ghost"
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={(): void => handleRemoveView(QueryBuilderField.ReduceTo)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedViews.find((view) => view.key === 'legend_format') && (
|
||||
{selectedViews.find((view) => view.key === QueryBuilderField.Legend) && (
|
||||
<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 => {
|
||||
setSelectedViews((prev) =>
|
||||
prev.filter((view) => view.key !== 'legend_format'),
|
||||
);
|
||||
}}
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Legend)}
|
||||
closeIcon={<ChevronUp size={16} />}
|
||||
/>
|
||||
</div>
|
||||
@@ -520,42 +567,49 @@ function QueryAddOns({
|
||||
className="add-ons-tabs"
|
||||
value={selectedViews.map((view) => view.key)}
|
||||
onChange={(newKeys: string[]): void => {
|
||||
const oldKeys = selectedViews.map((view) => view.key);
|
||||
const oldKeys: string[] = selectedViews.map((view) => view.key);
|
||||
const toggledKey =
|
||||
newKeys.find((k) => !oldKeys.includes(k)) ??
|
||||
oldKeys.find((k) => !newKeys.includes(k));
|
||||
newKeys.find((key) => !oldKeys.includes(key)) ??
|
||||
oldKeys.find((key) => !newKeys.includes(key));
|
||||
if (!toggledKey) {
|
||||
return;
|
||||
}
|
||||
const clickedAddOn = addOns.find((a) => a.key === toggledKey);
|
||||
const clickedAddOn = togglableAddOns.find((a) => a.key === toggledKey);
|
||||
if (clickedAddOn) {
|
||||
handleOptionClick(clickedAddOn);
|
||||
}
|
||||
}}
|
||||
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}`}
|
||||
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}
|
||||
>
|
||||
{addOn.icon}
|
||||
{addOn.label}
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
}))}
|
||||
<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>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,12 @@ 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';
|
||||
@@ -18,24 +24,32 @@ 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 showAggregationInterval = useMemo(() => {
|
||||
const stepInterval = useMemo(() => {
|
||||
if (panelType === PANEL_TYPES.VALUE) {
|
||||
return false;
|
||||
return { hidden: true, disabled: false, reason: undefined };
|
||||
}
|
||||
|
||||
if (dataSource === DataSource.TRACES || dataSource === DataSource.LOGS) {
|
||||
return !(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE);
|
||||
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 };
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [dataSource, panelType]);
|
||||
return resolveQueryBuilderField(QueryBuilderField.StepInterval, fieldsConfig);
|
||||
}, [dataSource, panelType, fieldsConfig]);
|
||||
|
||||
const handleAggregationIntervalChange = (value: string): void => {
|
||||
onAggregationIntervalChange(Number(value));
|
||||
@@ -57,22 +71,24 @@ function QueryAggregationOptions({
|
||||
}
|
||||
/>
|
||||
|
||||
{showAggregationInterval && (
|
||||
{!stepInterval.hidden && (
|
||||
<div className="query-aggregation-interval">
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
>
|
||||
Learn about step intervals
|
||||
</a>
|
||||
</div>
|
||||
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>
|
||||
)
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
@@ -92,6 +108,7 @@ function QueryAggregationOptions({
|
||||
placeholder="Auto"
|
||||
type="number"
|
||||
onChange={handleAggregationIntervalChange}
|
||||
disabled={stepInterval.disabled}
|
||||
labelAfter
|
||||
/>
|
||||
</div>
|
||||
@@ -105,6 +122,7 @@ 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 isListViewPanel =
|
||||
const isRawQueryPanel =
|
||||
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 isListViewPanel && hasMultipleQueries && !hasTraceOperator;
|
||||
return isRawQueryPanel && hasMultipleQueries && !hasTraceOperator;
|
||||
}, [
|
||||
currentQuery?.builder?.queryData,
|
||||
currentQuery?.builder?.queryTraceOperator,
|
||||
@@ -77,50 +77,74 @@ 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">
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{showAddFormula && (
|
||||
<div className="qb-add-formula">
|
||||
<Tooltip
|
||||
title={
|
||||
<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>
|
||||
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>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="add-formula-button periscope-btn "
|
||||
data-testid="add-formula-button"
|
||||
icon={<Sigma size={16} />}
|
||||
onClick={addNewFormula}
|
||||
disabled={isAddFormulaDisabled}
|
||||
>
|
||||
Add Formula
|
||||
</Button>
|
||||
|
||||
@@ -20,6 +20,13 @@ 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';
|
||||
@@ -31,8 +38,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
index,
|
||||
queryVariant,
|
||||
query,
|
||||
filterConfigs,
|
||||
isListViewPanel = false,
|
||||
isRawQuery = false,
|
||||
showTraceOperator = false,
|
||||
hasTraceOperator = false,
|
||||
version,
|
||||
@@ -43,6 +49,8 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
signalSourceChangeEnabled = false,
|
||||
queriesCount = 1,
|
||||
savePreviousQuery = false,
|
||||
fieldsConfig,
|
||||
allowedDataSources,
|
||||
}: QueryProps & {
|
||||
onSignalSourceChange: (value: string) => void;
|
||||
signalSourceChangeEnabled: boolean;
|
||||
@@ -53,7 +61,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
): JSX.Element {
|
||||
const { cloneQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const showFunctions = query?.functions?.length > 0;
|
||||
const hasQueryFunctions = query?.functions?.length > 0;
|
||||
const { dataSource, builderQueryType } = query;
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
@@ -66,8 +74,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
} = useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
filterConfigs,
|
||||
isListViewPanel,
|
||||
isRawQuery,
|
||||
entityVersion: version,
|
||||
savePreviousQuery,
|
||||
});
|
||||
@@ -99,14 +106,31 @@ 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 || isListViewPanel)
|
||||
);
|
||||
}, [hasTraceOperator, isListViewPanel, showTraceOperator, dataSource]);
|
||||
return dataSource === DataSource.TRACES && (hasTraceOperator || isRawQuery);
|
||||
}, [hasTraceOperator, isRawQuery, showTraceOperator, dataSource]);
|
||||
|
||||
const handleChangeAggregateEvery = useCallback(
|
||||
(value: IBuilderQuery['stepInterval']) => {
|
||||
@@ -149,12 +173,15 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
hasTraceOperator={hasTraceOperator}
|
||||
isMetricsDataSource={dataSource === DataSource.METRICS}
|
||||
showFunctions={
|
||||
(version && version === ENTITY_VERSION_V4) ||
|
||||
query.dataSource === DataSource.LOGS ||
|
||||
query.dataSource === DataSource.METRICS ||
|
||||
showFunctions ||
|
||||
false
|
||||
!functions.hidden &&
|
||||
((version && version === ENTITY_VERSION_V4) ||
|
||||
query.dataSource === DataSource.LOGS ||
|
||||
query.dataSource === DataSource.METRICS ||
|
||||
hasQueryFunctions ||
|
||||
false)
|
||||
}
|
||||
areFunctionsDisabled={functions.disabled}
|
||||
functionsDisabledReason={functions.reason}
|
||||
isCollapsed={isCollapsed}
|
||||
showTraceOperator={showTraceOperator}
|
||||
entityType="query"
|
||||
@@ -167,7 +194,8 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
onQueryFunctionsUpdates={handleQueryFunctionsUpdates}
|
||||
showDeleteButton={false}
|
||||
showCloneOption={false}
|
||||
isListViewPanel={isListViewPanel}
|
||||
isRawQuery={isRawQuery}
|
||||
allowedDataSources={allowedDataSources}
|
||||
index={index}
|
||||
queryVariant={queryVariant}
|
||||
onChangeDataSource={handleChangeDataSource}
|
||||
@@ -267,7 +295,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
</div>
|
||||
|
||||
{!showOnlyWhereClause &&
|
||||
!isListViewPanel &&
|
||||
!aggregation.hidden &&
|
||||
!(hasTraceOperator && dataSource === DataSource.TRACES) &&
|
||||
dataSource !== DataSource.METRICS && (
|
||||
<QueryAggregation
|
||||
@@ -277,6 +305,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
onAggregationIntervalChange={handleChangeAggregateEvery}
|
||||
onChange={handleChangeAggregation}
|
||||
queryData={query}
|
||||
fieldsConfig={fieldsConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -297,9 +326,10 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
index={index}
|
||||
query={query}
|
||||
version="v3"
|
||||
isListViewPanel={isListViewPanel}
|
||||
isRawQuery={isRawQuery}
|
||||
showReduceTo={showReduceTo}
|
||||
panelType={panelType}
|
||||
fieldsConfig={fieldsConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
@@ -19,10 +20,12 @@ import './TraceOperator.styles.scss';
|
||||
|
||||
export default function TraceOperator({
|
||||
traceOperator,
|
||||
isListViewPanel = false,
|
||||
isRawQuery = false,
|
||||
fieldsConfig,
|
||||
}: {
|
||||
traceOperator: IBuilderTraceOperator;
|
||||
isListViewPanel?: boolean;
|
||||
isRawQuery?: boolean;
|
||||
fieldsConfig?: QueryBuilderFieldsConfig;
|
||||
}): JSX.Element {
|
||||
const { panelType, removeTraceOperator } = useQueryBuilder();
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
@@ -58,12 +61,12 @@ export default function TraceOperator({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx('qb-trace-operator', !isListViewPanel && 'non-list-view')}>
|
||||
<div className={cx('qb-trace-operator', !isRawQuery && 'non-list-view')}>
|
||||
<div className="qb-trace-operator-container">
|
||||
<div
|
||||
className={cx(
|
||||
'qb-trace-operator-label-with-input',
|
||||
!isListViewPanel && 'qb-trace-operator-arrow',
|
||||
!isRawQuery && 'qb-trace-operator-arrow',
|
||||
)}
|
||||
>
|
||||
<Typography.Text className="label">Trace Operator</Typography.Text>
|
||||
@@ -76,9 +79,9 @@ export default function TraceOperator({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isListViewPanel && (
|
||||
{!isRawQuery && (
|
||||
<div className="qb-trace-operator-aggregation-container">
|
||||
<div className={cx(!isListViewPanel && 'qb-trace-operator-arrow')}>
|
||||
<div className={cx(!isRawQuery && 'qb-trace-operator-arrow')}>
|
||||
<QueryAggregation
|
||||
dataSource={DataSource.TRACES}
|
||||
key={`query-search-${traceOperator.queryName}`}
|
||||
@@ -86,12 +89,13 @@ export default function TraceOperator({
|
||||
onAggregationIntervalChange={handleChangeAggregateEvery}
|
||||
onChange={handleChangeAggregation}
|
||||
queryData={traceOperator}
|
||||
fieldsConfig={fieldsConfig}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
'qb-trace-operator-add-ons-container',
|
||||
!isListViewPanel && 'qb-trace-operator-arrow',
|
||||
!isRawQuery && 'qb-trace-operator-arrow',
|
||||
)}
|
||||
>
|
||||
<QueryAddOns
|
||||
@@ -99,9 +103,10 @@ export default function TraceOperator({
|
||||
query={traceOperator}
|
||||
version="v3"
|
||||
isForTraceOperator
|
||||
isListViewPanel={false}
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={panelType}
|
||||
fieldsConfig={fieldsConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -142,7 +142,6 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
|
||||
isMetricsDataSource: false,
|
||||
operators: [],
|
||||
spaceAggregationOptions: [],
|
||||
listOfAdditionalFilters: [],
|
||||
handleChangeOperator: jest.fn(),
|
||||
handleSpaceAggregationChange: jest.fn(),
|
||||
handleChangeAggregatorAttribute: jest.fn(),
|
||||
@@ -152,7 +151,6 @@ 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"
|
||||
isListViewPanel={false}
|
||||
isRawQuery={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.VALUE}
|
||||
index={0}
|
||||
@@ -119,7 +119,7 @@ describe('QueryAddOns', () => {
|
||||
groupBy: ['service.name'],
|
||||
})}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -135,7 +135,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={baseQuery()}
|
||||
version="v5"
|
||||
isListViewPanel
|
||||
isRawQuery
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.LIST}
|
||||
index={0}
|
||||
@@ -151,7 +151,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={baseQuery({ limit: 5 })}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -176,7 +176,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -195,7 +195,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={baseQuery()}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
isRawQuery={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -211,7 +211,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={baseQuery({ reduceTo: ReduceOperators.SUM })}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
isRawQuery={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -234,7 +234,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
isRawQuery={false}
|
||||
showReduceTo
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -286,7 +286,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
@@ -314,7 +314,7 @@ describe('QueryAddOns', () => {
|
||||
<QueryAddOns
|
||||
query={query}
|
||||
version="v5"
|
||||
isListViewPanel={false}
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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>
|
||||
>;
|
||||
@@ -0,0 +1,92 @@
|
||||
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;
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
MeterAggregateOperator,
|
||||
MetricAggregateOperator,
|
||||
NumberOperators,
|
||||
QueryAdditionalFilter,
|
||||
QueryBuilderData,
|
||||
ReduceOperators,
|
||||
StringOperators,
|
||||
@@ -104,43 +103,6 @@ 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' },
|
||||
|
||||
@@ -1,35 +1,23 @@
|
||||
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';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
// 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(
|
||||
const isRawQuery = useMemo(
|
||||
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
|
||||
[panelTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isListViewPanel={isListViewPanel}
|
||||
isRawQuery={isRawQuery}
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
panelType={panelTypes}
|
||||
filterConfigs={filterConfigs}
|
||||
showOnlyWhereClause={isListViewPanel}
|
||||
showOnlyWhereClause={isRawQuery}
|
||||
version="v3" // setting this to v3 as we this is rendered in logs explorer
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
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 { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
@@ -36,42 +29,11 @@ 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
|
||||
isListViewPanel={panelTypes === PANEL_TYPES.LIST}
|
||||
isRawQuery={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,7 +12,6 @@ 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';
|
||||
@@ -120,11 +119,6 @@ function Explorer(): JSX.Element {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const queryComponents = useMemo(
|
||||
(): QueryBuilderProps['queryComponents'] => ({}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
@@ -181,7 +175,6 @@ function Explorer(): JSX.Element {
|
||||
signalSource: 'meter',
|
||||
}}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
queryComponents={queryComponents}
|
||||
showFunctions={false}
|
||||
version="v3"
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,6 @@ 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';
|
||||
@@ -323,11 +322,6 @@ function Explorer(): JSX.Element {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const queryComponents = useMemo(
|
||||
(): QueryBuilderProps['queryComponents'] => ({}),
|
||||
[],
|
||||
);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
|
||||
const oneChartPerQueryDisabledTooltip = useMemo(() => {
|
||||
@@ -381,7 +375,6 @@ function Explorer(): JSX.Element {
|
||||
<QueryBuilderV2
|
||||
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
queryComponents={queryComponents}
|
||||
showFunctions={false}
|
||||
version="v3"
|
||||
/>
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
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';
|
||||
@@ -29,9 +16,16 @@ export type QueryBuilderProps = {
|
||||
config?: QueryBuilderConfig;
|
||||
panelType: PANEL_TYPES;
|
||||
actions?: ReactNode;
|
||||
filterConfigs?: Partial<FilterConfigs>;
|
||||
queryComponents?: { renderOrderBy?: (props: OrderByFilterProps) => ReactNode };
|
||||
isListViewPanel?: boolean;
|
||||
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[];
|
||||
showFunctions?: boolean;
|
||||
showOnlyWhereClause?: boolean;
|
||||
showOnlyTraceOperator?: boolean;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type AdditionalFiltersProps = {
|
||||
listOfAdditionalFilter: string[];
|
||||
children: ReactNode;
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
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;
|
||||
`;
|
||||
@@ -1,15 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { AdditionalFiltersToggler } from './AdditionalFiltersToggler';
|
||||
@@ -1,8 +1,10 @@
|
||||
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;
|
||||
isListViewPanel?: boolean;
|
||||
/** Defaults to every signal. */
|
||||
allowedDataSources?: TelemetrytypesSignalDTO[];
|
||||
'data-testid'?: string;
|
||||
} & Omit<SelectProps, 'onChange'>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -7,25 +8,24 @@ import { transformToUpperCase } from 'utils/transformToUpperCase';
|
||||
|
||||
// ** Types
|
||||
import { QueryLabelProps } from './DataSourceDropdown.interfaces';
|
||||
import { signalsToDataSources } from './DataSourceDropdown.utils';
|
||||
|
||||
const dataSourceMap = [DataSource.LOGS, DataSource.METRICS, DataSource.TRACES];
|
||||
|
||||
const exploreDataSourceMap = [DataSource.LOGS, DataSource.TRACES];
|
||||
const ALL_SIGNALS = [
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
];
|
||||
|
||||
export const DataSourceDropdown = memo(function DataSourceDropdown(
|
||||
props: QueryLabelProps,
|
||||
): JSX.Element {
|
||||
const { onChange, value, style, isListViewPanel = false } = props;
|
||||
const { onChange, value, style, allowedDataSources = ALL_SIGNALS } = props;
|
||||
|
||||
const dataSourceOptions: SelectOption<DataSource, string>[] = isListViewPanel
|
||||
? exploreDataSourceMap.map((source) => ({
|
||||
label: transformToUpperCase(source),
|
||||
value: source,
|
||||
}))
|
||||
: dataSourceMap.map((source) => ({
|
||||
label: transformToUpperCase(source),
|
||||
value: source,
|
||||
}));
|
||||
const dataSourceOptions: SelectOption<DataSource, string>[] =
|
||||
signalsToDataSources(allowedDataSources).map((source) => ({
|
||||
label: transformToUpperCase(source),
|
||||
value: source,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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));
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
export type FilterLabelProps = {
|
||||
label: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
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;
|
||||
`;
|
||||
@@ -1,26 +0,0 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { FilterLabel } from './FilterLabel';
|
||||
@@ -1,4 +1,3 @@
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
@@ -8,7 +7,5 @@ export type FormulaProps = {
|
||||
formula: IBuilderFormula;
|
||||
index: number;
|
||||
query: IBuilderQuery;
|
||||
filterConfigs: Partial<QueryBuilderProps['filterConfigs']>;
|
||||
isAdditionalFilterEnable: boolean;
|
||||
isQBV2?: boolean;
|
||||
};
|
||||
|
||||
@@ -2,11 +2,6 @@ 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';
|
||||
@@ -17,7 +12,6 @@ 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';
|
||||
@@ -27,22 +21,18 @@ import './Formula.styles.scss';
|
||||
export function Formula({
|
||||
index,
|
||||
formula,
|
||||
filterConfigs,
|
||||
query,
|
||||
isAdditionalFilterEnable,
|
||||
isQBV2,
|
||||
}: FormulaProps): JSX.Element {
|
||||
const { removeQueryBuilderEntityByIndex, handleSetFormulaData } =
|
||||
useQueryBuilder();
|
||||
|
||||
const { listOfAdditionalFormulaFilters, handleChangeFormulaData } =
|
||||
useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
filterConfigs,
|
||||
formula,
|
||||
entityVersion: '',
|
||||
});
|
||||
const { handleChangeFormulaData } = useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
formula,
|
||||
entityVersion: '',
|
||||
});
|
||||
|
||||
const [isCollapse, setIsCollapsed] = useState(false);
|
||||
|
||||
@@ -83,20 +73,6 @@ 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(' ');
|
||||
@@ -122,54 +98,6 @@ 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
|
||||
@@ -206,17 +134,6 @@ 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,5 +84,14 @@
|
||||
.options-group {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.query-functions-container--disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -26,6 +27,8 @@ interface QBEntityOptionsProps {
|
||||
query?: IBuilderQuery;
|
||||
isMetricsDataSource?: boolean;
|
||||
showFunctions?: boolean;
|
||||
areFunctionsDisabled?: boolean;
|
||||
functionsDisabledReason?: string;
|
||||
isCollapsed: boolean;
|
||||
entityType: string;
|
||||
entityData: any;
|
||||
@@ -36,7 +39,8 @@ interface QBEntityOptionsProps {
|
||||
onQueryFunctionsUpdates?: (functions: QueryFunction[]) => void;
|
||||
showDeleteButton?: boolean;
|
||||
showCloneOption?: boolean;
|
||||
isListViewPanel?: boolean;
|
||||
isRawQuery?: boolean;
|
||||
allowedDataSources?: TelemetrytypesSignalDTO[];
|
||||
index?: number;
|
||||
showTraceOperator?: boolean;
|
||||
hasTraceOperator?: boolean;
|
||||
@@ -50,12 +54,15 @@ export default function QBEntityOptions({
|
||||
isMetricsDataSource,
|
||||
isCollapsed,
|
||||
showFunctions,
|
||||
areFunctionsDisabled,
|
||||
functionsDisabledReason,
|
||||
entityType,
|
||||
entityData,
|
||||
onToggleVisibility,
|
||||
onCollapseEntity,
|
||||
onQueryFunctionsUpdates,
|
||||
isListViewPanel,
|
||||
isRawQuery,
|
||||
allowedDataSources,
|
||||
onDelete,
|
||||
showDeleteButton,
|
||||
showCloneOption,
|
||||
@@ -100,7 +107,7 @@ export default function QBEntityOptions({
|
||||
value="query-builder"
|
||||
className="periscope-btn visibility-toggle"
|
||||
onClick={onToggleVisibility}
|
||||
disabled={isListViewPanel && !showTraceOperator}
|
||||
disabled={isRawQuery && !showTraceOperator}
|
||||
>
|
||||
{entityData.disabled ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</Button>
|
||||
@@ -119,7 +126,7 @@ export default function QBEntityOptions({
|
||||
'periscope-btn',
|
||||
entityType === 'query' ? 'query-name' : 'formula-name',
|
||||
query?.dataSource === DataSource.TRACES &&
|
||||
(hasTraceOperator || (showTraceOperator && isListViewPanel))
|
||||
(hasTraceOperator || (showTraceOperator && isRawQuery))
|
||||
? 'has-trace-operator'
|
||||
: '',
|
||||
isLogsExplorerPage && lastUsedQuery === index ? 'sync-btn' : '',
|
||||
@@ -138,24 +145,33 @@ export default function QBEntityOptions({
|
||||
}}
|
||||
data-testid={`query-data-source-selector-${index}`}
|
||||
value={query?.dataSource || DataSource.METRICS}
|
||||
isListViewPanel={isListViewPanel}
|
||||
allowedDataSources={allowedDataSources}
|
||||
className="query-data-source-dropdown"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFunctions &&
|
||||
!isListViewPanel &&
|
||||
!isRawQuery &&
|
||||
(isMetricsDataSource || isLogsDataSource) &&
|
||||
query &&
|
||||
onQueryFunctionsUpdates && (
|
||||
<QueryFunctions
|
||||
query={query}
|
||||
queryFunctions={query.functions || []}
|
||||
key={query.functions?.toString()}
|
||||
onChange={onQueryFunctionsUpdates}
|
||||
maxFunctions={isLogsDataSource ? 1 : 3}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</Button.Group>
|
||||
</div>
|
||||
@@ -168,7 +184,7 @@ export default function QBEntityOptions({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDeleteButton && !isListViewPanel && (
|
||||
{showDeleteButton && !isRawQuery && (
|
||||
<Button className="periscope-btn ghost" onClick={onDelete}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
@@ -179,11 +195,14 @@ export default function QBEntityOptions({
|
||||
}
|
||||
|
||||
QBEntityOptions.defaultProps = {
|
||||
isListViewPanel: false,
|
||||
isRawQuery: false,
|
||||
allowedDataSources: undefined,
|
||||
query: undefined,
|
||||
isMetricsDataSource: false,
|
||||
onQueryFunctionsUpdates: undefined,
|
||||
showFunctions: false,
|
||||
areFunctionsDisabled: false,
|
||||
functionsDisabledReason: undefined,
|
||||
onCloneQuery: noop,
|
||||
index: 0,
|
||||
onDelete: noop,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
export { AdditionalFiltersToggler } from './AdditionalFiltersToggler';
|
||||
export { DataSourceDropdown } from './DataSourceDropdown';
|
||||
export { FilterLabel } from './FilterLabel';
|
||||
export { Formula } from './Formula';
|
||||
export { HavingFilterTag } from './HavingFilterTag';
|
||||
export { ListItemWrapper } from './ListItemWrapper';
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
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;
|
||||
@@ -1,12 +0,0 @@
|
||||
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'>;
|
||||
@@ -1,20 +0,0 @@
|
||||
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;
|
||||
@@ -1,6 +0,0 @@
|
||||
import { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface LimitFilterProps {
|
||||
onChange: (values: number | null) => void;
|
||||
formula: IBuilderFormula;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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;
|
||||
@@ -1,12 +0,0 @@
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface OrderByProps {
|
||||
formula: IBuilderFormula;
|
||||
query: IBuilderQuery;
|
||||
onChange: (value: IBuilderFormula['orderBy']) => void;
|
||||
}
|
||||
|
||||
export type IOrderByFormulaFilterProps = OrderByProps;
|
||||
@@ -1,129 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
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;
|
||||
isListViewPanel?: boolean;
|
||||
isRawQuery?: boolean;
|
||||
entityVersion?: string;
|
||||
isNewQueryV2?: boolean;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useOrderByFilter } from './useOrderByFilter';
|
||||
export function OrderByFilter({
|
||||
query,
|
||||
onChange,
|
||||
isListViewPanel = false,
|
||||
isRawQuery = false,
|
||||
entityVersion,
|
||||
isNewQueryV2 = false,
|
||||
}: OrderByFilterProps): JSX.Element {
|
||||
@@ -35,7 +35,7 @@ export function OrderByFilter({
|
||||
searchText: debouncedSearchText,
|
||||
},
|
||||
{
|
||||
enabled: !!query.aggregateAttribute?.key || isListViewPanel,
|
||||
enabled: !!query.aggregateAttribute?.key || isRawQuery,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -19,7 +19,6 @@ 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';
|
||||
@@ -88,7 +87,6 @@ interface CustomTagProps {
|
||||
interface QueryBuilderSearchV2Props {
|
||||
query: IBuilderQuery;
|
||||
onChange: (value: TagFilter) => void;
|
||||
whereClauseConfig?: WhereClauseConfig;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
suffixIcon?: React.ReactNode;
|
||||
@@ -145,7 +143,6 @@ function QueryBuilderSearchV2(
|
||||
placeholder,
|
||||
className,
|
||||
suffixIcon,
|
||||
whereClauseConfig,
|
||||
hardcodedAttributeKeys,
|
||||
hasPopupContainer,
|
||||
rootClassName,
|
||||
@@ -477,31 +474,7 @@ 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
|
||||
) {
|
||||
@@ -543,8 +516,6 @@ function QueryBuilderSearchV2(
|
||||
currentFilterItem?.op,
|
||||
currentFilterItem?.value,
|
||||
searchValue,
|
||||
whereClauseConfig?.customKey,
|
||||
whereClauseConfig?.customOp,
|
||||
]);
|
||||
|
||||
// this useEffect takes care of tokenisation based on the search state
|
||||
@@ -1085,7 +1056,6 @@ 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';
|
||||
isListViewPanel?: boolean;
|
||||
isRawQuery?: boolean;
|
||||
showFunctions?: boolean;
|
||||
version: string;
|
||||
showSpanScopeSelector?: boolean;
|
||||
@@ -35,4 +35,4 @@ export type QueryProps = {
|
||||
hasTraceOperator?: boolean;
|
||||
signalSource?: string;
|
||||
isMultiQueryAllowed?: boolean;
|
||||
} & Pick<QueryBuilderProps, 'filterConfigs' | 'queryComponents'>;
|
||||
} & Pick<QueryBuilderProps, 'fieldsConfig' | 'allowedDataSources'>;
|
||||
|
||||
@@ -1,55 +1,23 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { memo, 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 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(
|
||||
const isRawQuery = useMemo(
|
||||
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
|
||||
[panelTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isListViewPanel={isListViewPanel}
|
||||
isRawQuery={isRawQuery}
|
||||
showTraceOperator
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
queryComponents={queryComponents}
|
||||
panelType={panelTypes}
|
||||
filterConfigs={filterConfigs}
|
||||
showOnlyWhereClause={
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ function FullView({
|
||||
<QueryBuilderV2
|
||||
panelType={selectedPanelType}
|
||||
version="v3"
|
||||
isListViewPanel={selectedPanelType === PANEL_TYPES.LIST}
|
||||
isRawQuery={selectedPanelType === PANEL_TYPES.LIST}
|
||||
signalSourceChangeEnabled
|
||||
// filterConfigs={filterConfigs}
|
||||
// queryComponents={queryComponents}
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
listViewInitialLogQuery,
|
||||
listViewInitialTraceQuery,
|
||||
mapOfFormulaToFilters,
|
||||
mapOfQueryFilters,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import {
|
||||
@@ -59,9 +57,8 @@ import { getFormatedLegend } from 'utils/getFormatedLegend';
|
||||
export const useQueryOperations: UseQueryOperations = ({
|
||||
query,
|
||||
index,
|
||||
filterConfigs,
|
||||
formula,
|
||||
isListViewPanel = false,
|
||||
isRawQuery = false,
|
||||
entityVersion,
|
||||
isForTraceOperator = false,
|
||||
savePreviousQuery = false,
|
||||
@@ -105,46 +102,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
}
|
||||
}, [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 { dataSource } = query;
|
||||
|
||||
const handleChangeOperator = useCallback(
|
||||
(value: string): void => {
|
||||
@@ -460,7 +418,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
removeKeyFromPreviousQuery(newKey);
|
||||
}
|
||||
|
||||
if (isListViewPanel) {
|
||||
if (isRawQuery) {
|
||||
let listPanelQuery: Query | null = null;
|
||||
|
||||
if (nextSource === DataSource.LOGS) {
|
||||
@@ -506,7 +464,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
handleSetQueryData(index, newQueryData);
|
||||
},
|
||||
[
|
||||
isListViewPanel,
|
||||
isRawQuery,
|
||||
panelType,
|
||||
query,
|
||||
handleSetQueryData,
|
||||
@@ -625,32 +583,18 @@ 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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 4px 12px 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--text-vanilla-400);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 2px;
|
||||
transform: translateX(-1px);
|
||||
background: var(--text-vanilla-100);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.caption {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.keys {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.swatch,
|
||||
.hatchSwatch {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// Approximates the canvas hatch painted over null cells.
|
||||
.hatchSwatch {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent 0 2px,
|
||||
var(--text-vanilla-400) 2px 3px
|
||||
);
|
||||
}
|
||||
81
frontend/src/lib/uPlotV2/components/ColorBar/ColorBar.tsx
Normal file
81
frontend/src/lib/uPlotV2/components/ColorBar/ColorBar.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Styles from './ColorBar.module.scss';
|
||||
|
||||
export interface ColorBarProps {
|
||||
/** Low to high, drawn as hard-edged segments so the bar shows the same set of
|
||||
* colours as the cells. */
|
||||
ramp: string[];
|
||||
minLabel: string;
|
||||
maxLabel: string;
|
||||
/** 0..1. `null` hides the marker. */
|
||||
markerPosition?: number | null;
|
||||
/** What the colour encodes, e.g. "count". */
|
||||
label?: string;
|
||||
/** Keys for the two states a ramp cannot express: a hatched data gap, and a
|
||||
* genuine zero at the bottom. Without them the difference is guesswork. */
|
||||
showStateKeys?: boolean;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/** What a colour means, plus a marker for the value under the cursor. */
|
||||
export default function ColorBar({
|
||||
ramp,
|
||||
minLabel,
|
||||
maxLabel,
|
||||
markerPosition = null,
|
||||
label,
|
||||
showStateKeys = true,
|
||||
'data-testid': testId = 'color-bar',
|
||||
}: ColorBarProps): JSX.Element | null {
|
||||
const gradient = useMemo(() => {
|
||||
if (ramp.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (ramp.length === 1) {
|
||||
return ramp[0];
|
||||
}
|
||||
const stops = ramp.flatMap((color, index) => {
|
||||
const from = (index / ramp.length) * 100;
|
||||
const to = ((index + 1) / ramp.length) * 100;
|
||||
return [`${color} ${from}%`, `${color} ${to}%`];
|
||||
});
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||
}, [ramp]);
|
||||
|
||||
if (gradient === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clampedMarker =
|
||||
markerPosition === null ? null : Math.min(Math.max(markerPosition, 0), 1);
|
||||
|
||||
return (
|
||||
<div className={Styles.container} data-testid={testId}>
|
||||
{label && <span className={Styles.caption}>{label}</span>}
|
||||
<span className={Styles.label}>{minLabel}</span>
|
||||
<div className={Styles.track} style={{ background: gradient }}>
|
||||
{clampedMarker !== null && (
|
||||
<span
|
||||
className={Styles.marker}
|
||||
style={{ left: `${clampedMarker * 100}%` }}
|
||||
data-testid={`${testId}-marker`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className={Styles.label}>{maxLabel}</span>
|
||||
{showStateKeys && (
|
||||
<div className={Styles.keys} data-testid={`${testId}-state-keys`}>
|
||||
<span className={Styles.key}>
|
||||
<span className={Styles.hatchSwatch} />
|
||||
no data
|
||||
</span>
|
||||
<span className={Styles.key}>
|
||||
<span className={Styles.swatch} style={{ background: ramp[0] }} />
|
||||
count 0
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import ColorBar from '../ColorBar';
|
||||
|
||||
const RAMP = ['#111111', '#555555', '#999999', '#dddddd'];
|
||||
|
||||
describe('ColorBar', () => {
|
||||
it('renders the domain labels', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="1,204" />);
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,204')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing without a ramp', () => {
|
||||
const { container } = render(
|
||||
<ColorBar ramp={[]} minLabel="0" maxLabel="0" />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('hides the marker when nothing is hovered', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
|
||||
|
||||
expect(screen.queryByTestId('color-bar-marker')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('positions the marker at the hovered value', () => {
|
||||
render(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={0.25} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '25%' });
|
||||
});
|
||||
|
||||
it('clamps a marker outside the ramp to its ends', () => {
|
||||
const { rerender } = render(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={-2} />,
|
||||
);
|
||||
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '0%' });
|
||||
|
||||
rerender(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={4} />,
|
||||
);
|
||||
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '100%' });
|
||||
});
|
||||
|
||||
it('keys the two states a colour ramp cannot express', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
|
||||
|
||||
expect(screen.getByText('no data')).toBeInTheDocument();
|
||||
expect(screen.getByText('count 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('draws the count-0 key with the bottom of the ramp', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
|
||||
|
||||
expect(screen.getByText('count 0').firstChild).toHaveStyle({
|
||||
background: RAMP[0],
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the state keys when asked', () => {
|
||||
render(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" showStateKeys={false} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('no data')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('captions what the colour encodes', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" label="count" />);
|
||||
|
||||
expect(screen.getByText('count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders hard-edged segments so the bar matches the drawn cells', () => {
|
||||
render(
|
||||
<ColorBar
|
||||
ramp={['#111111', '#dddddd']}
|
||||
minLabel="0"
|
||||
maxLabel="10"
|
||||
data-testid="scale"
|
||||
/>,
|
||||
);
|
||||
|
||||
const track = screen.getByTestId('scale').querySelector('div');
|
||||
expect(track).toHaveStyle({
|
||||
background:
|
||||
'linear-gradient(to right, #111111 0%, #111111 50%, #dddddd 50%, #dddddd 100%)',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import cx from 'classnames';
|
||||
|
||||
import { formatCount, HeatmapBucketRow } from './heatmapTooltipContent';
|
||||
|
||||
import Styles from './HeatmapTooltip.module.scss';
|
||||
|
||||
/** The buckets either side of the hovered one, so a mode reads as a shape rather
|
||||
* than a single number. */
|
||||
export default function HeatmapBucketList({
|
||||
rows,
|
||||
}: {
|
||||
rows: HeatmapBucketRow[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={Styles.rows} data-testid="heatmap-tooltip-buckets">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={cx(Styles.row, { [Styles.rowHovered]: row.isHovered })}
|
||||
data-hovered={row.isHovered}
|
||||
data-testid="heatmap-tooltip-bucket-row"
|
||||
>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowValue}>{formatCount(row.count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
formatCount,
|
||||
formatPercent,
|
||||
HeatmapContributionRow,
|
||||
} from './heatmapTooltipContent';
|
||||
|
||||
import Styles from './HeatmapTooltip.module.scss';
|
||||
|
||||
/** Only shown when the cell sums more than one group. */
|
||||
export default function HeatmapContributionList({
|
||||
rows,
|
||||
groupByLabel,
|
||||
}: {
|
||||
rows: HeatmapContributionRow[];
|
||||
/** The `groupBy` keys these rows are by. */
|
||||
groupByLabel: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={Styles.rows} data-testid="heatmap-tooltip-contribution">
|
||||
{groupByLabel && <span className={Styles.section}>{groupByLabel}</span>}
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={Styles.row}
|
||||
data-testid="heatmap-tooltip-contribution-row"
|
||||
>
|
||||
<span
|
||||
className={Styles.marker}
|
||||
style={{ background: row.color }}
|
||||
data-is-legend-marker={true}
|
||||
/>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowValue}>{formatCount(row.count)}</span>
|
||||
<span className={Styles.rowPercent}>{formatPercent(row.percent)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Surface matches the shared Tooltip exactly — same tokens, same radius, no
|
||||
// shadow (the plugin's portal wrapper is transparent and paints nothing).
|
||||
//
|
||||
// Padding lives on the sections rather than here, also matching the shared
|
||||
// tooltip: TooltipFooter draws its own dashed top border, background and bottom
|
||||
// corner radius, so it has to reach the container edges.
|
||||
.container {
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--l2-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
}
|
||||
|
||||
// Separates the cell identity from whichever question the second block answers.
|
||||
.divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-4) var(--spacing-4) var(--spacing-3);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Hollow ring, matching the legend's unselected marker — this names the filter the
|
||||
// grid is under, it is not a colour key.
|
||||
.filterMarker {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.titleBucket {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.titleCount {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-vanilla-100);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
}
|
||||
|
||||
.section {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-vanilla-400);
|
||||
padding: 0 var(--spacing-2) var(--spacing-1);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--text-vanilla-400);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// The hovered bucket is the one the cursor is on; lift it out of the neighbours.
|
||||
.rowHovered {
|
||||
background: var(--l3-background);
|
||||
color: var(--text-vanilla-100);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowValue {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rowPercent {
|
||||
flex: 0 0 auto;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
color: var(--text-vanilla-400);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.marker {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
resolveColumnIndex,
|
||||
resolveRowIndex,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { HeatmapTooltipProps } from '../../../types';
|
||||
import HeatmapBucketList from './HeatmapBucketList';
|
||||
import HeatmapContributionList from './HeatmapContributionList';
|
||||
import {
|
||||
buildBucketRows,
|
||||
buildContributionRows,
|
||||
formatBucketLabel,
|
||||
formatColumnRange,
|
||||
formatCount,
|
||||
formatGroupFilter,
|
||||
HeatmapTooltipBody,
|
||||
resolveGroupByLabel,
|
||||
resolveTooltipBody,
|
||||
} from './heatmapTooltipContent';
|
||||
|
||||
import Styles from './HeatmapTooltip.module.scss';
|
||||
|
||||
/**
|
||||
* The cell identity is the same in every state; the second block answers whichever
|
||||
* question the panel state leaves open (see `resolveTooltipBody`). Purpose-built
|
||||
* rather than composed from the shared `Tooltip`, which renders a flat list of
|
||||
* series values — none of these states is that shape.
|
||||
*
|
||||
* The cell comes from the live cursor, not a prop: uPlot's `cursor.idx` snaps to
|
||||
* the nearest timestamp, so half of every column would report its neighbour.
|
||||
*/
|
||||
export default function HeatmapTooltip({
|
||||
uPlotInstance,
|
||||
yAxis,
|
||||
step,
|
||||
series,
|
||||
visibleGroups,
|
||||
groupColor,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
isPinned,
|
||||
dismiss,
|
||||
renderTooltipFooter,
|
||||
}: HeatmapTooltipProps): JSX.Element | null {
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
|
||||
// Read outside the memo: uPlot mutates the same instance on every move, so
|
||||
// keying off the instance alone would freeze the cell.
|
||||
const { left = -10, top = -10 } = uPlotInstance.cursor;
|
||||
|
||||
const cell = useMemo(() => {
|
||||
if (left < 0 || top < 0) {
|
||||
return null;
|
||||
}
|
||||
const timestamps = uPlotInstance.data[0] as ArrayLike<number>;
|
||||
const column = resolveColumnIndex(
|
||||
timestamps,
|
||||
uPlotInstance.posToVal(left, 'x'),
|
||||
step,
|
||||
);
|
||||
const row = resolveRowIndex(yAxis.edges, uPlotInstance.posToVal(top, 'y'));
|
||||
if (column === null || row === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
row,
|
||||
column,
|
||||
timestamp: timestamps[column],
|
||||
count:
|
||||
(uPlotInstance.data[row + 1] as Array<number | null> | undefined)?.[
|
||||
column
|
||||
] ?? null,
|
||||
};
|
||||
}, [left, top, uPlotInstance, yAxis, step]);
|
||||
|
||||
// The cell sums the enabled groups, so those are what a breakdown must cover.
|
||||
const visible = useMemo(
|
||||
() => series.filter((entry) => visibleGroups.includes(entry.label)),
|
||||
[series, visibleGroups],
|
||||
);
|
||||
const body = resolveTooltipBody(visible.length);
|
||||
|
||||
const bucketRows = useMemo(() => {
|
||||
if (!cell || body !== HeatmapTooltipBody.Buckets) {
|
||||
return [];
|
||||
}
|
||||
return buildBucketRows({
|
||||
counts: uPlotInstance.data.slice(1) as Array<
|
||||
ArrayLike<number | null> | undefined
|
||||
>,
|
||||
yAxis,
|
||||
row: cell.row,
|
||||
column: cell.column,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
});
|
||||
}, [cell, body, uPlotInstance, yAxis, yAxisUnit, decimalPrecision]);
|
||||
|
||||
const contributionRows = useMemo(() => {
|
||||
if (!cell || body !== HeatmapTooltipBody.Contribution) {
|
||||
return [];
|
||||
}
|
||||
return buildContributionRows({
|
||||
series: visible,
|
||||
timestamp: cell.timestamp,
|
||||
row: cell.row,
|
||||
color: groupColor,
|
||||
});
|
||||
}, [cell, body, visible, groupColor]);
|
||||
|
||||
if (!cell) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A single enabled group out of several means the legend has isolated it.
|
||||
const isolated =
|
||||
series.length > 1 && visible.length === 1 ? visible[0] : undefined;
|
||||
const filterLabel = formatGroupFilter(isolated);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
|
||||
data-pinned={isPinned}
|
||||
data-testid="heatmap-tooltip"
|
||||
>
|
||||
<div className={Styles.identity}>
|
||||
<div className={Styles.header}>
|
||||
<span data-testid="heatmap-tooltip-range">
|
||||
{formatColumnRange({
|
||||
start: cell.timestamp,
|
||||
step,
|
||||
timezone: resolvedTimezone,
|
||||
})}
|
||||
</span>
|
||||
{filterLabel && (
|
||||
<span
|
||||
className={Styles.filter}
|
||||
style={{ color: groupColor }}
|
||||
data-testid="heatmap-tooltip-filter"
|
||||
>
|
||||
<span className={Styles.filterMarker} />
|
||||
<span className={Styles.filterLabel}>{filterLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={Styles.title}>
|
||||
<span className={Styles.titleBucket} data-testid="heatmap-tooltip-bucket">
|
||||
{formatBucketLabel({
|
||||
yAxis,
|
||||
row: cell.row,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
})}
|
||||
</span>
|
||||
<span className={Styles.titleCount} data-testid="heatmap-tooltip-count">
|
||||
{formatCount(cell.count)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} data-testid="heatmap-tooltip-divider" />
|
||||
|
||||
{body === HeatmapTooltipBody.Contribution ? (
|
||||
<HeatmapContributionList
|
||||
rows={contributionRows}
|
||||
groupByLabel={resolveGroupByLabel(series)}
|
||||
/>
|
||||
) : (
|
||||
<HeatmapBucketList rows={bucketRows} />
|
||||
)}
|
||||
|
||||
{renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import { render, RenderResult, screen } from 'tests/test-utils';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import HeatmapTooltip from '../HeatmapTooltip';
|
||||
|
||||
const BOUNDS = [100, 500, 1000, 2500];
|
||||
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
const TIMESTAMPS = [1_700_000_000, 1_700_000_300];
|
||||
const STEP = 300;
|
||||
const PLOT_SIZE = 500;
|
||||
const ROW_COUNT = BOUNDS.length + 1;
|
||||
|
||||
/** Row 2 is the 500ms–1s bucket the design mock hovers. */
|
||||
const HOVERED_ROW = 2;
|
||||
|
||||
function seriesFor(
|
||||
group: string,
|
||||
countsAtHoveredRow: [number, number],
|
||||
): HeatmapSeries {
|
||||
return {
|
||||
label: `service.name=${group}`,
|
||||
labels: [{ key: 'service.name', value: group }],
|
||||
points: TIMESTAMPS.map((timestamp, column) => ({
|
||||
timestamp,
|
||||
counts: Array.from({ length: ROW_COUNT }, (_, row) =>
|
||||
row === HOVERED_ROW ? countsAtHoveredRow[column] : row * 10,
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const GROUPED: HeatmapSeries[] = [
|
||||
seriesFor('checkout', [355, 300]),
|
||||
seriesFor('frontend', [86, 80]),
|
||||
seriesFor('cart', [14, 10]),
|
||||
seriesFor('payments', [0, 0]),
|
||||
];
|
||||
|
||||
/** Grid counts, matching what the renderer would have been handed. */
|
||||
function gridData(rowTotals: number[]): uPlot.AlignedData {
|
||||
return [
|
||||
TIMESTAMPS,
|
||||
...Array.from({ length: ROW_COUNT }, (_, row) => [
|
||||
rowTotals[row] ?? row * 40,
|
||||
rowTotals[row] ?? row * 40,
|
||||
]),
|
||||
] as unknown as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
// Totals chosen to match the mock: 2 / 92 / 455 / 269 / 10 bottom-up.
|
||||
const ROW_TOTALS = [10, 269, 455, 92, 2];
|
||||
|
||||
function createFakePlot(): uPlot {
|
||||
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
|
||||
const ySpan = Y_AXIS.max - Y_AXIS.min;
|
||||
// Aim the cursor at the middle of the hovered row, first column.
|
||||
const rowMid = (Y_AXIS.edges[HOVERED_ROW] + Y_AXIS.edges[HOVERED_ROW + 1]) / 2;
|
||||
const top = PLOT_SIZE * (1 - (rowMid - Y_AXIS.min) / ySpan);
|
||||
|
||||
return {
|
||||
data: gridData(ROW_TOTALS),
|
||||
cursor: { left: PLOT_SIZE * 0.25, top },
|
||||
posToVal: (pos: number, scaleKey: string): number =>
|
||||
scaleKey === 'x'
|
||||
? TIMESTAMPS[0] + (pos / PLOT_SIZE) * xSpan
|
||||
: Y_AXIS.min + ((PLOT_SIZE - pos) / PLOT_SIZE) * ySpan,
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
function renderTooltip(
|
||||
overrides: Partial<React.ComponentProps<typeof HeatmapTooltip>> = {},
|
||||
): RenderResult {
|
||||
return render(
|
||||
<HeatmapTooltip
|
||||
id="panel-1"
|
||||
uPlotInstance={createFakePlot()}
|
||||
dataIndexes={[]}
|
||||
seriesIndex={null}
|
||||
isPinned={false}
|
||||
dismiss={jest.fn()}
|
||||
viaSync={false}
|
||||
yAxis={Y_AXIS}
|
||||
step={STEP}
|
||||
series={GROUPED}
|
||||
visibleGroups={GROUPED.map((entry) => entry.label)}
|
||||
groupColor="#fcfdbf"
|
||||
yAxisUnit="ms"
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('HeatmapTooltip — cell identity', () => {
|
||||
it('heads with the time span the column covers, not a single instant', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-range').textContent).toMatch(
|
||||
/^\d{2}:\d{2} → \d{2}:\d{2}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('names the hovered bucket and its count', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-bucket')).toHaveTextContent(
|
||||
'500 ms – 1 s',
|
||||
);
|
||||
expect(screen.getByTestId('heatmap-tooltip-count')).toHaveTextContent('455');
|
||||
});
|
||||
|
||||
it('marks the surface as pinned so the border picks up the ring', () => {
|
||||
renderTooltip({ isPinned: true });
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
|
||||
'data-pinned',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('is unpinned by default', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
|
||||
'data-pinned',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('separates the cell identity from the block below it', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-divider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a footer when the panel supplies one', () => {
|
||||
renderTooltip({
|
||||
renderTooltipFooter: ({ isPinned }): JSX.Element => (
|
||||
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('footer')).toHaveTextContent('press P');
|
||||
});
|
||||
|
||||
it('tells the footer when the tooltip is pinned', () => {
|
||||
renderTooltip({
|
||||
isPinned: true,
|
||||
renderTooltipFooter: ({ isPinned }): JSX.Element => (
|
||||
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('footer')).toHaveTextContent('pinned');
|
||||
});
|
||||
|
||||
it('renders nothing when the cursor is off the plot', () => {
|
||||
const plot = createFakePlot();
|
||||
(plot as { cursor: unknown }).cursor = { left: -10, top: -10 };
|
||||
|
||||
const { container } = renderTooltip({ uPlotInstance: plot });
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — grouped, nothing selected', () => {
|
||||
it('breaks the cell down by group instead of showing neighbours', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('heatmap-tooltip-contribution'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-buckets'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('heads the breakdown with the groupBy key', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByText('service.name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names each row by value alone and orders by contribution', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen
|
||||
.getAllByTestId('heatmap-tooltip-contribution-row')
|
||||
.map((row) => row.textContent);
|
||||
|
||||
expect(rows[0]).toContain('checkout');
|
||||
expect(rows[0]).toContain('355');
|
||||
expect(rows[1]).toContain('frontend');
|
||||
expect(rows[2]).toContain('cart');
|
||||
});
|
||||
|
||||
it('shows each group"s share of the cell', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen.getAllByTestId('heatmap-tooltip-contribution-row');
|
||||
// 355 / 455 = 78%, 86 / 455 = 19%, 14 / 455 = 3.1%
|
||||
expect(rows[0]).toHaveTextContent('78%');
|
||||
expect(rows[1]).toHaveTextContent('19%');
|
||||
expect(rows[2]).toHaveTextContent('3.1%');
|
||||
});
|
||||
|
||||
it('still lists a group that contributed nothing', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen.getAllByTestId('heatmap-tooltip-contribution-row');
|
||||
expect(rows).toHaveLength(GROUPED.length);
|
||||
expect(rows[3]).toHaveTextContent('payments');
|
||||
expect(rows[3]).toHaveTextContent('0.0%');
|
||||
});
|
||||
|
||||
it('does not name a filter when every group is enabled', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-filter'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — grouped, one enabled', () => {
|
||||
const selected = { visibleGroups: ['service.name=checkout'] };
|
||||
|
||||
it('returns to neighbouring buckets, since contribution is already answered', () => {
|
||||
renderTooltip(selected);
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-buckets')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-contribution'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the active filter', () => {
|
||||
renderTooltip(selected);
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-filter')).toHaveTextContent(
|
||||
'service.name = checkout',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — no grouping', () => {
|
||||
const ungrouped = {
|
||||
series: [{ label: '', points: GROUPED[0].points }],
|
||||
visibleGroups: [''],
|
||||
};
|
||||
|
||||
it('shows neighbouring buckets, highest first', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
const rows = screen
|
||||
.getAllByTestId('heatmap-tooltip-bucket-row')
|
||||
.map((row) => row.textContent);
|
||||
|
||||
// Two buckets either side of 500ms – 1s, reading down the y axis.
|
||||
expect(rows).toHaveLength(5);
|
||||
expect(rows[0]).toContain('> 2.5 s');
|
||||
expect(rows[2]).toContain('500 ms – 1 s');
|
||||
expect(rows[4]).toContain('≤ 100 ms');
|
||||
});
|
||||
|
||||
it('marks the hovered bucket among its neighbours', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
const hovered = screen
|
||||
.getAllByTestId('heatmap-tooltip-bucket-row')
|
||||
.filter((row) => row.dataset.hovered === 'true');
|
||||
|
||||
expect(hovered).toHaveLength(1);
|
||||
expect(hovered[0]).toHaveTextContent('500 ms – 1 s');
|
||||
});
|
||||
|
||||
it('never breaks down a single series', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-contribution'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatRowLabel } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
HeatmapSeries,
|
||||
HeatmapYAxis,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
/** Rows shown either side of the hovered one. */
|
||||
const NEIGHBOUR_SPAN = 2;
|
||||
/** Below this share a percentage needs a decimal to stay informative. */
|
||||
const PERCENT_DECIMAL_THRESHOLD = 10;
|
||||
/** Below this, the header needs seconds to distinguish columns. */
|
||||
const SUB_MINUTE_STEP = 60;
|
||||
|
||||
export const NO_DATA_LABEL = 'no data';
|
||||
|
||||
/**
|
||||
* Which question the second block answers. A cell summed across several groups begs
|
||||
* "which group?"; a cell that is already one series begs "how does this bucket
|
||||
* compare with its neighbours?".
|
||||
*/
|
||||
export enum HeatmapTooltipBody {
|
||||
Buckets = 'buckets',
|
||||
Contribution = 'contribution',
|
||||
}
|
||||
|
||||
export interface HeatmapBucketRow {
|
||||
label: string;
|
||||
count: number | null;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
export interface HeatmapContributionRow {
|
||||
label: string;
|
||||
color: string;
|
||||
count: number;
|
||||
/** Share of the cell's total, 0..100. */
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
|
||||
// One enabled group contributes the whole cell, so there is nothing to break
|
||||
// down — whether the query is ungrouped or the legend has isolated a group.
|
||||
return visibleCount > 1
|
||||
? HeatmapTooltipBody.Contribution
|
||||
: HeatmapTooltipBody.Buckets;
|
||||
}
|
||||
|
||||
/** A cell is an interval, so a single instant would misreport which observations
|
||||
* it contains. The date is left to the x axis directly below. */
|
||||
export function formatColumnRange({
|
||||
start,
|
||||
step,
|
||||
timezone,
|
||||
}: {
|
||||
/** Column start, in seconds. */
|
||||
start: number;
|
||||
/** Column width, in seconds. */
|
||||
step: number;
|
||||
timezone: string;
|
||||
}): string {
|
||||
const format =
|
||||
step < SUB_MINUTE_STEP
|
||||
? DATE_TIME_FORMATS.TIME_SECONDS
|
||||
: DATE_TIME_FORMATS.TIME;
|
||||
const from = dayjs(start * 1000).tz(timezone);
|
||||
const to = dayjs((start + step) * 1000).tz(timezone);
|
||||
return `${from.format(format)} → ${to.format(format)}`;
|
||||
}
|
||||
|
||||
/** Formatted with the panel's unit. */
|
||||
export function formatBucketLabel({
|
||||
yAxis,
|
||||
row,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
}: {
|
||||
yAxis: HeatmapYAxis;
|
||||
row: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}): string {
|
||||
const bucket = yAxis.rows[row];
|
||||
if (!bucket) {
|
||||
return '';
|
||||
}
|
||||
return formatRowLabel(bucket, (value) =>
|
||||
getToolTipValue(String(value), yAxisUnit, decimalPrecision),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCount(count: number | null): string {
|
||||
return count === null ? NO_DATA_LABEL : count.toLocaleString();
|
||||
}
|
||||
|
||||
export function formatPercent(percent: number): string {
|
||||
return percent >= PERCENT_DECIMAL_THRESHOLD
|
||||
? `${Math.round(percent)}%`
|
||||
: `${percent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** Names the group the grid is currently isolated to. */
|
||||
export function formatGroupFilter(series: HeatmapSeries | undefined): string {
|
||||
if (!series) {
|
||||
return '';
|
||||
}
|
||||
if (!series.labels?.length) {
|
||||
return series.label;
|
||||
}
|
||||
return series.labels
|
||||
.map((label) => `${label.key} = ${label.value}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/** The `groupBy` keys the breakdown is by. */
|
||||
export function resolveGroupByLabel(series: HeatmapSeries[]): string {
|
||||
const keys = series[0]?.labels?.map((label) => label.key) ?? [];
|
||||
return keys.join(', ');
|
||||
}
|
||||
|
||||
function formatSeriesValue(series: HeatmapSeries): string {
|
||||
if (!series.labels?.length) {
|
||||
return series.label;
|
||||
}
|
||||
return series.labels.map((label) => label.value).join(', ');
|
||||
}
|
||||
|
||||
/** Highest first, so the list reads in the same direction as the y axis. */
|
||||
export function buildBucketRows({
|
||||
counts,
|
||||
yAxis,
|
||||
row,
|
||||
column,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
}: {
|
||||
/** Row-major, as the renderer draws them. */
|
||||
counts: Array<ArrayLike<number | null> | undefined>;
|
||||
yAxis: HeatmapYAxis;
|
||||
row: number;
|
||||
column: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}): HeatmapBucketRow[] {
|
||||
const formatBucketValue = (value: number): string =>
|
||||
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
|
||||
|
||||
const rows: HeatmapBucketRow[] = [];
|
||||
for (let offset = NEIGHBOUR_SPAN; offset >= -NEIGHBOUR_SPAN; offset -= 1) {
|
||||
const index = row + offset;
|
||||
const bucket = yAxis.rows[index];
|
||||
if (!bucket) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
label: formatRowLabel(bucket, formatBucketValue),
|
||||
count: counts[index]?.[column] ?? null,
|
||||
isHovered: offset === 0,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest first. Groups that contributed nothing are still listed — that is an
|
||||
* answer, and dropping the row makes the list look truncated.
|
||||
*/
|
||||
export function buildContributionRows({
|
||||
series,
|
||||
timestamp,
|
||||
row,
|
||||
color,
|
||||
}: {
|
||||
/** Only the groups the legend has enabled — they are what the cell sums. */
|
||||
series: HeatmapSeries[];
|
||||
/** Column start, in seconds. */
|
||||
timestamp: number;
|
||||
row: number;
|
||||
color: string;
|
||||
}): HeatmapContributionRow[] {
|
||||
const counts = series.map((entry) => {
|
||||
const point = entry.points.find((item) => item.timestamp === timestamp);
|
||||
// Absent or null contributed nothing to the sum, which is what this breaks down.
|
||||
return point?.counts[row] ?? 0;
|
||||
});
|
||||
const total = counts.reduce((sum, count) => sum + count, 0);
|
||||
|
||||
return series
|
||||
.map((entry, index) => ({
|
||||
label: formatSeriesValue(entry),
|
||||
color,
|
||||
count: counts[index],
|
||||
percent: total > 0 ? (counts[index] / total) * 100 : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import uPlot from 'uplot';
|
||||
|
||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
import { LegendItem } from '../config/types';
|
||||
import { HeatmapSeries, HeatmapYAxis } from '../plugins/HeatmapPlugin/types';
|
||||
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
|
||||
|
||||
/**
|
||||
@@ -103,6 +104,21 @@ export interface BarTooltipProps extends BaseTooltipProps, TooltipRenderArgs {
|
||||
export interface HistogramTooltipProps
|
||||
extends BaseTooltipProps, TooltipRenderArgs {}
|
||||
|
||||
/** Not part of `TooltipProps`: it renders its own container, since none of its
|
||||
* states is the flat series list the shared `Tooltip` draws. */
|
||||
export interface HeatmapTooltipProps
|
||||
extends BaseTooltipProps, TooltipRenderArgs {
|
||||
yAxis: HeatmapYAxis;
|
||||
/** Column width in seconds. */
|
||||
step: number;
|
||||
/** Needed to break a summed cell down by contribution. */
|
||||
series: HeatmapSeries[];
|
||||
/** Groups the legend has enabled; the cell sums exactly these. */
|
||||
visibleGroups: string[];
|
||||
/** Same colour the legend and the densest cells use. */
|
||||
groupColor: string;
|
||||
}
|
||||
|
||||
export type TooltipProps =
|
||||
| TimeSeriesTooltipProps
|
||||
| BarTooltipProps
|
||||
|
||||
@@ -148,6 +148,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
show = true,
|
||||
side = 2, // bottom by default
|
||||
space,
|
||||
splits,
|
||||
gap = 5, // default gap is 5
|
||||
} = this.props;
|
||||
|
||||
@@ -179,6 +180,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
if (values) {
|
||||
axisConfig.values = values;
|
||||
}
|
||||
if (splits) {
|
||||
axisConfig.splits = splits;
|
||||
}
|
||||
if (gap !== undefined) {
|
||||
axisConfig.gap = gap;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,13 @@ export class UPlotScaleBuilder extends ConfigBuilder<
|
||||
|
||||
// Special handling for time scales (X axis)
|
||||
if (time) {
|
||||
// An explicit range wins: the alignment below trims the tail of the window
|
||||
// to whole minutes, which is right for point-based series but drops the
|
||||
// final column of any chart whose marks span an interval.
|
||||
if (range) {
|
||||
return { [scaleKey]: { time: true, auto: false, range } };
|
||||
}
|
||||
|
||||
let minTime = this.min ?? 0;
|
||||
let maxTime = this.max ?? 0;
|
||||
|
||||
|
||||
@@ -78,6 +78,8 @@ export interface AxisProps {
|
||||
};
|
||||
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
|
||||
values?: uPlot.Axis.Values;
|
||||
/** Explicit axis splits, overriding the default tick calculation. */
|
||||
splits?: uPlot.Axis.Splits;
|
||||
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
|
||||
gap?: number;
|
||||
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
clampColorSteps,
|
||||
createHeatmapColorResolver,
|
||||
DEFAULT_COLOR_STEPS,
|
||||
DEFAULT_HEATMAP_COLORS,
|
||||
getMaxCount,
|
||||
MAX_COLOR_STEPS,
|
||||
MIN_OPACITY_ALPHA,
|
||||
normalizeCount,
|
||||
resolveCountDomain,
|
||||
} from '../colorScale';
|
||||
import { HeatmapColorMode, HeatmapColorScale } from '../types';
|
||||
|
||||
const SERIES_COLOR = '#4e74f8';
|
||||
|
||||
describe('getMaxCount', () => {
|
||||
it('ignores null cells', () => {
|
||||
expect(
|
||||
getMaxCount([
|
||||
[1, null, 9],
|
||||
[null, 4],
|
||||
]),
|
||||
).toBe(9);
|
||||
});
|
||||
|
||||
it('returns 0 for an empty or all-null grid', () => {
|
||||
expect(getMaxCount([])).toBe(0);
|
||||
expect(getMaxCount([[null, null]])).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores non-finite counts', () => {
|
||||
expect(getMaxCount([[3, Number.POSITIVE_INFINITY, Number.NaN]])).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCountDomain', () => {
|
||||
it('floors at 0 on auto so a zero count sits at the bottom of the scale', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: null, maxCount: null }, [[5, 20]]),
|
||||
).toStrictEqual({
|
||||
min: 0,
|
||||
max: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('honours explicit clamps', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: 10, maxCount: 100 }, [[5, 20]]),
|
||||
).toStrictEqual({
|
||||
min: 10,
|
||||
max: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses a max at or below min', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: 50, maxCount: 10 }, [[5]]),
|
||||
).toStrictEqual({
|
||||
min: 50,
|
||||
max: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCount', () => {
|
||||
const domain = { min: 0, max: 1000 };
|
||||
|
||||
it('spreads low counts on a log scale where a linear one washes them out', () => {
|
||||
const log = (count: number): number =>
|
||||
normalizeCount({ count, domain, scale: HeatmapColorScale.Log });
|
||||
|
||||
expect(log(10)).toBeCloseTo(1 / 3, 5);
|
||||
expect(log(20)).toBeCloseTo(Math.log10(20) / 3, 5);
|
||||
expect(
|
||||
normalizeCount({ count: 10, domain, scale: HeatmapColorScale.Linear }),
|
||||
).toBeCloseTo(0.01, 5);
|
||||
});
|
||||
|
||||
it('puts 0 and 1 at the bottom of a log scale', () => {
|
||||
expect(
|
||||
normalizeCount({ count: 0, domain, scale: HeatmapColorScale.Log }),
|
||||
).toBe(0);
|
||||
expect(
|
||||
normalizeCount({ count: 1, domain, scale: HeatmapColorScale.Log }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('reaches the top of the scale at max on every scale', () => {
|
||||
[
|
||||
HeatmapColorScale.Log,
|
||||
HeatmapColorScale.Sqrt,
|
||||
HeatmapColorScale.Linear,
|
||||
].forEach((scale) => {
|
||||
expect(normalizeCount({ count: 1000, domain, scale })).toBeCloseTo(1, 6);
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the square root of the linear position on a sqrt scale', () => {
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 250,
|
||||
domain: { min: 0, max: 1000 },
|
||||
scale: HeatmapColorScale.Sqrt,
|
||||
}),
|
||||
).toBeCloseTo(0.5, 6);
|
||||
});
|
||||
|
||||
it('clamps counts outside the domain', () => {
|
||||
const scale = HeatmapColorScale.Linear;
|
||||
expect(normalizeCount({ count: -5, domain, scale })).toBe(0);
|
||||
expect(normalizeCount({ count: 5000, domain, scale })).toBe(1);
|
||||
});
|
||||
|
||||
it('returns the bottom of the scale when min equals max', () => {
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 7,
|
||||
domain: { min: 7, max: 7 },
|
||||
scale: HeatmapColorScale.Log,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('handles a log domain whose min and max share a decade floor', () => {
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 1,
|
||||
domain: { min: 0, max: 1 },
|
||||
scale: HeatmapColorScale.Log,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampColorSteps', () => {
|
||||
it('clamps to the supported range', () => {
|
||||
expect(clampColorSteps(1)).toBe(2);
|
||||
expect(clampColorSteps(500)).toBe(MAX_COLOR_STEPS);
|
||||
expect(clampColorSteps(32)).toBe(32);
|
||||
});
|
||||
|
||||
it('falls back to the default for a non-finite value', () => {
|
||||
expect(clampColorSteps(Number.NaN)).toBe(DEFAULT_COLOR_STEPS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createHeatmapColorResolver', () => {
|
||||
const build = (
|
||||
overrides: Partial<typeof DEFAULT_HEATMAP_COLORS> = {},
|
||||
isDarkMode = true,
|
||||
): ReturnType<typeof createHeatmapColorResolver> =>
|
||||
createHeatmapColorResolver({
|
||||
options: { ...DEFAULT_HEATMAP_COLORS, ...overrides },
|
||||
domain: { min: 0, max: 1000 },
|
||||
isDarkMode,
|
||||
seriesColor: SERIES_COLOR,
|
||||
});
|
||||
|
||||
it('leaves null cells uncoloured so they can be hatched', () => {
|
||||
const resolver = build();
|
||||
|
||||
expect(resolver.colorFor(null)).toBeNull();
|
||||
expect(resolver.positionOf(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('gives a zero count the bottom colour, not the null treatment', () => {
|
||||
const resolver = build();
|
||||
|
||||
expect(resolver.colorFor(0)).toBe(resolver.ramp[0]);
|
||||
});
|
||||
|
||||
it('emits one ramp entry per step', () => {
|
||||
expect(build({ steps: 8 }).ramp).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('maps the max count to the top of the ramp', () => {
|
||||
const resolver = build({ steps: 8 });
|
||||
|
||||
expect(resolver.colorFor(1000)).toBe(resolver.ramp[7]);
|
||||
});
|
||||
|
||||
it('picks different stops per theme so low counts stay near the surface', () => {
|
||||
expect(build({}, true).ramp[0]).not.toBe(build({}, false).ramp[0]);
|
||||
});
|
||||
|
||||
it('varies alpha in opacity mode, never below the visibility floor', () => {
|
||||
const resolver = build({ mode: HeatmapColorMode.Opacity, steps: 4 });
|
||||
|
||||
expect(resolver.ramp[0]).toBe(`rgba(78, 116, 248, ${MIN_OPACITY_ALPHA})`);
|
||||
// `color` drops the alpha channel from the string once it reaches 1.
|
||||
expect(resolver.ramp[3]).toBe('rgb(78, 116, 248)');
|
||||
});
|
||||
|
||||
it('prefers an explicit opacity fill over the series colour', () => {
|
||||
const resolver = build({
|
||||
mode: HeatmapColorMode.Opacity,
|
||||
fill: '#e5484d',
|
||||
steps: 2,
|
||||
});
|
||||
|
||||
expect(resolver.ramp[1]).toBe('rgb(229, 72, 77)');
|
||||
});
|
||||
|
||||
it('reports the domain it applied', () => {
|
||||
expect(build().domain).toStrictEqual({ min: 0, max: 1000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
import {
|
||||
canUseLogAxis,
|
||||
decimateAxisSplits,
|
||||
formatRowLabel,
|
||||
resolveColumnIndex,
|
||||
resolveHeatmapYAxis,
|
||||
resolveRowIndex,
|
||||
} from '../geometry';
|
||||
import { HeatmapAxisScale } from '../types';
|
||||
|
||||
const BOUNDS = [128, 256, 1024, 4096];
|
||||
|
||||
describe('canUseLogAxis', () => {
|
||||
it('accepts strictly positive bounds', () => {
|
||||
expect(canUseLogAxis(BOUNDS)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a zero or negative bound', () => {
|
||||
expect(canUseLogAxis([0, 128])).toBe(false);
|
||||
expect(canUseLogAxis([-1, 128])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty bounds', () => {
|
||||
expect(canUseLogAxis([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeatmapYAxis', () => {
|
||||
it('turns N bounds into N+1 rows with underflow and overflow at the ends', () => {
|
||||
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
expect(rows).toHaveLength(BOUNDS.length + 1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
upper: 128,
|
||||
isUnderflow: true,
|
||||
isOverflow: false,
|
||||
});
|
||||
expect(rows[1]).toMatchObject({ lower: 128, upper: 256 });
|
||||
expect(rows[4]).toMatchObject({
|
||||
lower: 4096,
|
||||
isOverflow: true,
|
||||
isUnderflow: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes one edge per row boundary, ascending', () => {
|
||||
const { rows, edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
expect(edges).toHaveLength(rows.length + 1);
|
||||
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
|
||||
});
|
||||
|
||||
it('places bounds in log space so row heights are log-proportional', () => {
|
||||
const { splits, min, max } = resolveHeatmapYAxis(
|
||||
BOUNDS,
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
|
||||
// Outer edges extend by the geometric mean ratio, (4096/128)^(1/3) = 3.174…
|
||||
expect(10 ** min).toBeCloseTo(128 / (4096 / 128) ** (1 / 3), 6);
|
||||
expect(10 ** max).toBeCloseTo(4096 * (4096 / 128) ** (1 / 3), 6);
|
||||
});
|
||||
|
||||
it('keeps bounds in value space on a linear axis', () => {
|
||||
const { splits, min } = resolveHeatmapYAxis(
|
||||
[10, 20, 30],
|
||||
HeatmapAxisScale.Linear,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual([10, 20, 30]);
|
||||
// Mean gap is 10, and the underflow edge never crosses zero.
|
||||
expect(min).toBe(0);
|
||||
});
|
||||
|
||||
it('sorts and de-duplicates bounds', () => {
|
||||
const { rows, splits } = resolveHeatmapYAxis(
|
||||
[256, 128, 256, Number.NaN],
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual([Math.log10(128), Math.log10(256)]);
|
||||
expect(rows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('gives a single bound an underflow and an overflow row', () => {
|
||||
const { rows, edges } = resolveHeatmapYAxis([100], HeatmapAxisScale.Log);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].isUnderflow).toBe(true);
|
||||
expect(rows[1].isOverflow).toBe(true);
|
||||
expect(edges).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('degrades to an empty axis with no bounds', () => {
|
||||
expect(resolveHeatmapYAxis([], HeatmapAxisScale.Log).rows).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('puts the overflow label on the row"s upper edge, clear of the last boundary', () => {
|
||||
const { overflowSplit, edges } = resolveHeatmapYAxis(
|
||||
BOUNDS,
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
// A full row above the last boundary tick, so the two labels cannot collide.
|
||||
expect(overflowSplit).toBe(edges[edges.length - 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRowIndex', () => {
|
||||
const { edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
|
||||
|
||||
it('finds the row containing a value', () => {
|
||||
expect(resolveRowIndex(edges, 200)).toBe(1);
|
||||
expect(resolveRowIndex(edges, 2000)).toBe(3);
|
||||
});
|
||||
|
||||
it('assigns a boundary to the row it opens', () => {
|
||||
expect(resolveRowIndex(edges, 256)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns the last row on the top edge', () => {
|
||||
expect(resolveRowIndex(edges, edges[edges.length - 1])).toBe(
|
||||
edges.length - 2,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null outside the grid', () => {
|
||||
expect(resolveRowIndex(edges, edges[0] - 1)).toBeNull();
|
||||
expect(resolveRowIndex(edges, edges[edges.length - 1] + 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null without at least one row', () => {
|
||||
expect(resolveRowIndex([5], 5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveColumnIndex', () => {
|
||||
const timestamps = [100, 160, 220, 280];
|
||||
const step = 60;
|
||||
|
||||
it('resolves by containment, not proximity', () => {
|
||||
// 155 is nearer to 160, but the observations at 155 belong to column 0.
|
||||
expect(resolveColumnIndex(timestamps, 155, step)).toBe(0);
|
||||
expect(resolveColumnIndex(timestamps, 160, step)).toBe(1);
|
||||
});
|
||||
|
||||
it('includes the column start and excludes its end', () => {
|
||||
expect(resolveColumnIndex(timestamps, 100, step)).toBe(0);
|
||||
expect(resolveColumnIndex(timestamps, 159.9, step)).toBe(0);
|
||||
});
|
||||
|
||||
it('covers the trailing column using the step, not the next timestamp', () => {
|
||||
expect(resolveColumnIndex(timestamps, 330, step)).toBe(3);
|
||||
expect(resolveColumnIndex(timestamps, 340, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null before the first column', () => {
|
||||
expect(resolveColumnIndex(timestamps, 99, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null with no columns', () => {
|
||||
expect(resolveColumnIndex([], 100, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the last column open when the step is unknown', () => {
|
||||
expect(resolveColumnIndex(timestamps, 10_000, 0)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRowLabel', () => {
|
||||
const format = (value: number): string => `${value}ms`;
|
||||
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
it('labels the underflow row by its only real bound', () => {
|
||||
expect(formatRowLabel(rows[0], format)).toBe('≤ 128ms');
|
||||
});
|
||||
|
||||
it('labels the overflow row by its only real bound', () => {
|
||||
expect(formatRowLabel(rows[rows.length - 1], format)).toBe('> 4096ms');
|
||||
});
|
||||
|
||||
it('labels an interior row as a range', () => {
|
||||
expect(formatRowLabel(rows[1], format)).toBe('128ms – 256ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decimateAxisSplits', () => {
|
||||
const splits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const domain = { min: 0, max: 10 };
|
||||
|
||||
it('keeps every tick when they all fit', () => {
|
||||
expect(
|
||||
decimateAxisSplits({ ...domain, splits, plotHeight: 400, minGapPx: 18 }),
|
||||
).toStrictEqual(splits);
|
||||
});
|
||||
|
||||
it('thins to whatever fits at the available height', () => {
|
||||
// 11 ticks over 100px is 10px apart; an 18px floor keeps every other one.
|
||||
expect(
|
||||
decimateAxisSplits({ ...domain, splits, plotHeight: 100, minGapPx: 18 }),
|
||||
).toStrictEqual([0, 2, 4, 6, 8, 10]);
|
||||
});
|
||||
|
||||
it('always keeps the topmost tick, so the overflow edge survives thinning', () => {
|
||||
const thinned = decimateAxisSplits({
|
||||
...domain,
|
||||
splits,
|
||||
plotHeight: 40,
|
||||
minGapPx: 18,
|
||||
});
|
||||
|
||||
expect(thinned[thinned.length - 1]).toBe(10);
|
||||
});
|
||||
|
||||
it('returns ascending positions', () => {
|
||||
const thinned = decimateAxisSplits({
|
||||
...domain,
|
||||
splits,
|
||||
plotHeight: 60,
|
||||
minGapPx: 18,
|
||||
});
|
||||
|
||||
expect([...thinned].sort((a, b) => a - b)).toStrictEqual(thinned);
|
||||
});
|
||||
|
||||
it('thins by pixel distance, not index, so uneven rows are handled', () => {
|
||||
// Three boundaries bunched at the bottom of a wide linear domain: only the
|
||||
// first and the far-away last are far enough apart to both get labels.
|
||||
expect(
|
||||
decimateAxisSplits({
|
||||
splits: [1, 2, 3, 1000],
|
||||
min: 0,
|
||||
max: 1000,
|
||||
plotHeight: 200,
|
||||
minGapPx: 18,
|
||||
}),
|
||||
).toStrictEqual([3, 1000]);
|
||||
});
|
||||
|
||||
it('leaves the tick set alone when it cannot measure', () => {
|
||||
expect(
|
||||
decimateAxisSplits({ ...domain, splits, plotHeight: 0, minGapPx: 18 }),
|
||||
).toStrictEqual(splits);
|
||||
expect(
|
||||
decimateAxisSplits({
|
||||
splits,
|
||||
min: 5,
|
||||
max: 5,
|
||||
plotHeight: 400,
|
||||
minGapPx: 18,
|
||||
}),
|
||||
).toStrictEqual(splits);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeatmapYAxis — symmetric log', () => {
|
||||
// The OTel SDK default explicit bucket boundaries, which start at zero.
|
||||
const OTEL = [
|
||||
0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000,
|
||||
];
|
||||
// Clock skew in ms — a logs/traces field that straddles zero.
|
||||
const SKEW = [-1000, -100, -10, -1, 0, 1, 10, 100, 1000];
|
||||
|
||||
const PLOT_HEIGHT = 250;
|
||||
|
||||
/** Row heights in axis units, which map linearly to pixels. */
|
||||
function rowHeights(bounds: number[]): number[] {
|
||||
const { edges } = resolveHeatmapYAxis(bounds, HeatmapAxisScale.Log);
|
||||
return edges.slice(1).map((edge, index) => edge - edges[index]);
|
||||
}
|
||||
|
||||
/** Shortest row, in pixels, for a plot of `PLOT_HEIGHT`. */
|
||||
function shortestRowPx(bounds: number[], scale: HeatmapAxisScale): number {
|
||||
const { edges } = resolveHeatmapYAxis(bounds, scale);
|
||||
const span = edges[edges.length - 1] - edges[0];
|
||||
const heights = edges
|
||||
.slice(1)
|
||||
.map((edge, index) => ((edge - edges[index]) / span) * PLOT_HEIGHT);
|
||||
return Math.min(...heights);
|
||||
}
|
||||
|
||||
it('keeps a zero boundary on a log axis instead of giving up to linear', () => {
|
||||
const { splits } = resolveHeatmapYAxis([0, 5, 10], HeatmapAxisScale.Log);
|
||||
|
||||
// A linear fallback would leave the boundaries untransformed.
|
||||
expect(splits).not.toStrictEqual([0, 5, 10]);
|
||||
});
|
||||
|
||||
it('gives every row a usable height for the OTel default boundaries', () => {
|
||||
// Linear squeezes the 0–100ms buckets — where the data is — under a pixel.
|
||||
expect(shortestRowPx(OTEL, HeatmapAxisScale.Linear)).toBeLessThan(1);
|
||||
expect(shortestRowPx(OTEL, HeatmapAxisScale.Log)).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('gives the zero-crossing row a full decade, since it cannot be compressed', () => {
|
||||
const heights = rowHeights(OTEL);
|
||||
const { rows } = resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Log);
|
||||
const nearZero = rows.findIndex((row) => row.lower === 0 && row.upper === 5);
|
||||
|
||||
// One axis unit — the same space a decade gets above the threshold.
|
||||
expect(heights[nearZero]).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('places boundaries either side of zero symmetrically', () => {
|
||||
const heights = rowHeights(SKEW);
|
||||
|
||||
expect(Math.max(...heights) - Math.min(...heights)).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('keeps negative boundaries ascending', () => {
|
||||
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Log);
|
||||
|
||||
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
|
||||
});
|
||||
|
||||
it('round-trips a boundary back to its bucket value', () => {
|
||||
const { splits, toBucketValue } = resolveHeatmapYAxis(
|
||||
SKEW,
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(
|
||||
splits.map((split) => Math.round(toBucketValue(split) * 1e6) / 1e6),
|
||||
).toStrictEqual(SKEW);
|
||||
});
|
||||
|
||||
it('derives the linear threshold from the smallest non-zero boundary', () => {
|
||||
// Threshold 10 puts -10 at -1 and 0 at 0 in axis space.
|
||||
const { edges, rows } = resolveHeatmapYAxis(
|
||||
[-100, -10, 0, 10, 100],
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
const crossing = rows.findIndex(
|
||||
(row) => row.lower === -10 && row.upper === 0,
|
||||
);
|
||||
|
||||
expect(edges[crossing]).toBeCloseTo(-1, 6);
|
||||
expect(edges[crossing + 1]).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('leaves an all-positive layout on a plain log axis', () => {
|
||||
const { splits } = resolveHeatmapYAxis(
|
||||
[128, 256, 1024],
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual([128, 256, 1024].map((b) => Math.log10(b)));
|
||||
});
|
||||
|
||||
it('falls back to linear when every boundary is zero', () => {
|
||||
const { splits } = resolveHeatmapYAxis([0], HeatmapAxisScale.Log);
|
||||
|
||||
expect(splits).toStrictEqual([0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { resolveHeatmapGrid } from '../grid';
|
||||
import { HeatmapSeries } from '../types';
|
||||
|
||||
const BUCKETS = [10, 20];
|
||||
const STEP = 60;
|
||||
|
||||
/** Two groups over two columns, each missing a value the other reports. */
|
||||
const TWO_GROUPS: HeatmapSeries[] = [
|
||||
{
|
||||
label: 'cart',
|
||||
points: [
|
||||
{ timestamp: 60, counts: [1, 2, 3] },
|
||||
{ timestamp: 120, counts: [null, 5, 6] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'checkout',
|
||||
points: [
|
||||
{ timestamp: 60, counts: [10, 20, 30] },
|
||||
{ timestamp: 120, counts: [40, null, 60] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function resolve(
|
||||
overrides: Partial<Parameters<typeof resolveHeatmapGrid>[0]> = {},
|
||||
): ReturnType<typeof resolveHeatmapGrid> {
|
||||
return resolveHeatmapGrid({
|
||||
buckets: BUCKETS,
|
||||
step: STEP,
|
||||
series: TWO_GROUPS,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('resolveHeatmapGrid', () => {
|
||||
it('pivots per-timestamp count arrays into one row per bucket', () => {
|
||||
const { counts } = resolve({ series: [TWO_GROUPS[0]] });
|
||||
|
||||
// 2 boundaries describe 3 rows; each row spans both columns.
|
||||
expect(counts).toStrictEqual([
|
||||
[1, null],
|
||||
[2, 5],
|
||||
[3, 6],
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries the bounds and step through untouched', () => {
|
||||
const { bounds, step } = resolve();
|
||||
|
||||
expect(bounds).toStrictEqual(BUCKETS);
|
||||
expect(step).toBe(STEP);
|
||||
});
|
||||
|
||||
it('sums every group for the combined view', () => {
|
||||
const { counts } = resolve();
|
||||
|
||||
expect(counts[0]).toStrictEqual([11, 40]);
|
||||
expect(counts[2]).toStrictEqual([33, 66]);
|
||||
});
|
||||
|
||||
it('keeps one group"s count where the other has no data', () => {
|
||||
const { counts } = resolve();
|
||||
|
||||
// cart is null at 120 in row 0 while checkout reports 40.
|
||||
expect(counts[0][1]).toBe(40);
|
||||
// checkout is null at 120 in row 1 while cart reports 5.
|
||||
expect(counts[1][1]).toBe(5);
|
||||
});
|
||||
|
||||
it('reports a cell as no-data only when every group is missing it', () => {
|
||||
const { counts } = resolve({
|
||||
buckets: [10],
|
||||
series: [
|
||||
{ label: 'a', points: [{ timestamp: 60, counts: [null, null] }] },
|
||||
{ label: 'b', points: [{ timestamp: 60, counts: [null, null] }] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(counts).toStrictEqual([[null], [null]]);
|
||||
});
|
||||
|
||||
it('distinguishes a zero count from no data', () => {
|
||||
const { counts } = resolve({
|
||||
buckets: [10],
|
||||
series: [{ label: 'a', points: [{ timestamp: 60, counts: [0, null] }] }],
|
||||
});
|
||||
|
||||
expect(counts[0][0]).toBe(0);
|
||||
expect(counts[1][0]).toBeNull();
|
||||
});
|
||||
|
||||
it('sums only the groups the legend has enabled', () => {
|
||||
const { counts } = resolve({ visibleGroups: ['cart'] });
|
||||
|
||||
expect(counts[0]).toStrictEqual([1, null]);
|
||||
expect(counts[2]).toStrictEqual([3, 6]);
|
||||
});
|
||||
|
||||
it('sums every group when the legend passes nothing', () => {
|
||||
const { counts } = resolve({ visibleGroups: undefined });
|
||||
|
||||
expect(counts[0]).toStrictEqual([11, 40]);
|
||||
});
|
||||
|
||||
it('ignores an enabled label that left the result', () => {
|
||||
const { counts } = resolve({ visibleGroups: ['cart', 'gone'] });
|
||||
|
||||
expect(counts[0]).toStrictEqual([1, null]);
|
||||
});
|
||||
|
||||
it('empties the grid when every group is excluded', () => {
|
||||
const { timestamps, counts } = resolve({ visibleGroups: [] });
|
||||
|
||||
expect(timestamps).toStrictEqual([]);
|
||||
expect(counts.every((row) => row.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('unions timestamps when groups do not align', () => {
|
||||
const { timestamps, counts } = resolve({
|
||||
buckets: [10],
|
||||
series: [
|
||||
{ label: 'a', points: [{ timestamp: 60, counts: [1, 2] }] },
|
||||
{ label: 'b', points: [{ timestamp: 180, counts: [3, 4] }] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(timestamps).toStrictEqual([60, 180]);
|
||||
expect(counts[0]).toStrictEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('sorts columns ascending regardless of response order', () => {
|
||||
const { timestamps } = resolve({
|
||||
buckets: [10],
|
||||
series: [
|
||||
{
|
||||
label: 'a',
|
||||
points: [
|
||||
{ timestamp: 180, counts: [1, 2] },
|
||||
{ timestamp: 60, counts: [3, 4] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(timestamps).toStrictEqual([60, 180]);
|
||||
});
|
||||
|
||||
it('pads rows the response left short', () => {
|
||||
const { counts } = resolve({
|
||||
buckets: [10, 20, 30],
|
||||
series: [{ label: 'a', points: [{ timestamp: 60, counts: [1, 2] }] }],
|
||||
});
|
||||
|
||||
expect(counts).toStrictEqual([[1], [2], [null], [null]]);
|
||||
});
|
||||
|
||||
it('ignores counts beyond the bucket rows', () => {
|
||||
const { counts } = resolve({
|
||||
buckets: [10],
|
||||
series: [{ label: 'a', points: [{ timestamp: 60, counts: [1, 2, 99] }] }],
|
||||
});
|
||||
|
||||
expect(counts).toStrictEqual([[1], [2]]);
|
||||
});
|
||||
|
||||
it('degrades to an empty grid with no buckets or no series', () => {
|
||||
expect(resolve({ buckets: [] })).toStrictEqual({
|
||||
bounds: [],
|
||||
timestamps: [],
|
||||
step: 0,
|
||||
counts: [],
|
||||
});
|
||||
expect(resolve({ series: [] }).counts).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { DEFAULT_HEATMAP_COLORS } from '../colorScale';
|
||||
import { resolveHeatmapYAxis } from '../geometry';
|
||||
import { createHeatmapHooks } from '../heatmapPlugin';
|
||||
import { HeatmapAxisScale, HeatmapCell } from '../types';
|
||||
|
||||
const BOUNDS = [100, 1000];
|
||||
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
|
||||
const TIMESTAMPS = [1000, 1060, 1120];
|
||||
const STEP = 60;
|
||||
const PLOT_WIDTH = 300;
|
||||
const PLOT_HEIGHT = 300;
|
||||
|
||||
// Three rows for two bounds, three columns; row 1 column 1 is a data gap.
|
||||
const DATA = [
|
||||
TIMESTAMPS,
|
||||
[1, 2, 3],
|
||||
[4, null, 6],
|
||||
[7, 8, 9],
|
||||
] as unknown as uPlot.AlignedData;
|
||||
|
||||
interface FakeContext {
|
||||
fillRect: jest.Mock;
|
||||
fills: string[];
|
||||
}
|
||||
|
||||
interface FakePlot {
|
||||
plot: uPlot;
|
||||
context: FakeContext;
|
||||
setSeries: jest.Mock;
|
||||
over: HTMLDivElement;
|
||||
}
|
||||
|
||||
function createFakePlot(cursor: { left: number; top: number }): FakePlot {
|
||||
const over = document.createElement('div');
|
||||
Object.defineProperty(over, 'clientWidth', { value: PLOT_WIDTH });
|
||||
Object.defineProperty(over, 'clientHeight', { value: PLOT_HEIGHT });
|
||||
|
||||
const fills: string[] = [];
|
||||
const fillRect = jest.fn();
|
||||
const context = { fills, fillRect };
|
||||
const setSeries = jest.fn();
|
||||
|
||||
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
|
||||
const ySpan = Y_AXIS.max - Y_AXIS.min;
|
||||
|
||||
const ctx = {
|
||||
save: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
beginPath: jest.fn(),
|
||||
rect: jest.fn(),
|
||||
clip: jest.fn(),
|
||||
moveTo: jest.fn(),
|
||||
lineTo: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
setLineDash: jest.fn(),
|
||||
createPattern: jest.fn(() => null),
|
||||
set fillStyle(value: string) {
|
||||
fills.push(value);
|
||||
},
|
||||
fillRect: (...args: number[]): void => {
|
||||
fillRect(...args);
|
||||
},
|
||||
};
|
||||
|
||||
const plot = {
|
||||
data: DATA,
|
||||
cursor,
|
||||
over,
|
||||
setSeries,
|
||||
ctx,
|
||||
bbox: { left: 0, top: 0, width: PLOT_WIDTH, height: PLOT_HEIGHT },
|
||||
scales: { x: { min: TIMESTAMPS[0], max: TIMESTAMPS[2] + STEP } },
|
||||
// x grows left to right; y is inverted, so the highest bucket is at the top.
|
||||
valToPos: (value: number, scaleKey: string): number =>
|
||||
scaleKey === 'x'
|
||||
? ((value - TIMESTAMPS[0]) / xSpan) * PLOT_WIDTH
|
||||
: PLOT_HEIGHT - ((value - Y_AXIS.min) / ySpan) * PLOT_HEIGHT,
|
||||
posToVal: (pos: number, scaleKey: string): number =>
|
||||
scaleKey === 'x'
|
||||
? TIMESTAMPS[0] + (pos / PLOT_WIDTH) * xSpan
|
||||
: Y_AXIS.min + ((PLOT_HEIGHT - pos) / PLOT_HEIGHT) * ySpan,
|
||||
};
|
||||
|
||||
return { plot: plot as unknown as uPlot, context, setSeries, over };
|
||||
}
|
||||
|
||||
function createHooks(
|
||||
onHoverChange?: (cell: HeatmapCell | null) => void,
|
||||
dimOnHover = true,
|
||||
): ReturnType<typeof createHeatmapHooks> {
|
||||
return createHeatmapHooks({
|
||||
yAxis: Y_AXIS,
|
||||
step: STEP,
|
||||
colors: DEFAULT_HEATMAP_COLORS,
|
||||
isDarkMode: true,
|
||||
seriesColor: '#4e74f8',
|
||||
dimOnHover,
|
||||
onHoverChange,
|
||||
});
|
||||
}
|
||||
|
||||
describe('heatmap renderer — lifecycle', () => {
|
||||
it('mounts the hover overlay into the plot overlay and tears it down', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, over } = createFakePlot({ left: -10, top: -10 });
|
||||
|
||||
hooks.init(plot);
|
||||
expect(
|
||||
over.querySelector('[data-testid="heatmap-hover-overlay"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
hooks.destroy(plot);
|
||||
expect(
|
||||
over.querySelector('[data-testid="heatmap-hover-overlay"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('heatmap renderer — draw', () => {
|
||||
it('paints every cell of every visible column', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, context } = createFakePlot({ left: -10, top: -10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.draw(plot);
|
||||
|
||||
// 3 rows x 3 columns, less the one null cell that has no hatch pattern
|
||||
// available under jsdom.
|
||||
expect(context.fillRect).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it('gives a zero count the bottom-of-scale fill rather than skipping it', () => {
|
||||
const hooks = createHooks();
|
||||
const zeroed = [TIMESTAMPS, [0, 0, 0], [0, 0, 0], [0, 0, 0]];
|
||||
const { plot, context } = createFakePlot({ left: -10, top: -10 });
|
||||
(plot as { data: unknown }).data = zeroed;
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.draw(plot);
|
||||
|
||||
expect(context.fillRect).toHaveBeenCalledTimes(9);
|
||||
expect(new Set(context.fills).size).toBe(1);
|
||||
});
|
||||
|
||||
it('skips columns outside the current x range', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, context } = createFakePlot({ left: -10, top: -10 });
|
||||
(plot as { scales: unknown }).scales = {
|
||||
x: { min: TIMESTAMPS[0], max: TIMESTAMPS[0] + STEP },
|
||||
};
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.draw(plot);
|
||||
|
||||
// Only the first two columns overlap the range; the third starts past its end.
|
||||
// 2 columns x 3 rows, less the null cell in column 1.
|
||||
expect(context.fillRect).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('draws nothing without columns', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, context } = createFakePlot({ left: -10, top: -10 });
|
||||
(plot as { data: unknown }).data = [[]];
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.draw(plot);
|
||||
|
||||
expect(context.fillRect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('heatmap renderer — hover', () => {
|
||||
it('focuses the hovered row and reports the cell under the cursor', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
// Left third of the plot is column 0; the top third is the overflow row.
|
||||
const { plot, setSeries } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenCalledWith({ row: 2, column: 0, count: 7 });
|
||||
expect(setSeries).toHaveBeenCalledWith(3, { focus: true });
|
||||
});
|
||||
|
||||
it('reports a data gap as a null count instead of zero', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot } = createFakePlot({
|
||||
left: PLOT_WIDTH / 2,
|
||||
top: PLOT_HEIGHT / 2,
|
||||
});
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenCalledWith({
|
||||
row: 1,
|
||||
column: 1,
|
||||
count: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-report the same cell', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the overlay over the hovered cell and dims around it', () => {
|
||||
const hooks = createHooks(undefined, true);
|
||||
const { plot, over } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
const overlay = over.querySelector<HTMLDivElement>(
|
||||
'[data-testid="heatmap-hover-overlay"]',
|
||||
);
|
||||
expect(overlay?.style.display).toBe('block');
|
||||
// Column 0 spans the left third of a 300px plot.
|
||||
expect(overlay?.lastElementChild).toHaveStyle({
|
||||
left: '0px',
|
||||
width: '100px',
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses the dim rects when dimming is off', () => {
|
||||
const hooks = createHooks(undefined, false);
|
||||
const { plot, over } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
const overlay = over.querySelector<HTMLDivElement>(
|
||||
'[data-testid="heatmap-hover-overlay"]',
|
||||
);
|
||||
expect(overlay?.firstElementChild).toHaveStyle({
|
||||
width: '0px',
|
||||
height: '0px',
|
||||
});
|
||||
});
|
||||
|
||||
it('releases focus and hides the overlay when the cursor leaves', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot, over, setSeries } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
(plot as { cursor: { left: number; top: number } }).cursor = {
|
||||
left: -10,
|
||||
top: -10,
|
||||
};
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenLastCalledWith(null);
|
||||
expect(setSeries).toHaveBeenLastCalledWith(null, { focus: true });
|
||||
expect(
|
||||
over.querySelector<HTMLDivElement>('[data-testid="heatmap-hover-overlay"]')
|
||||
?.style.display,
|
||||
).toBe('none');
|
||||
});
|
||||
|
||||
it('clears the hover when the cursor is inside the plot but past the last column', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
(plot as { data: unknown }).data = [[], [], [], []];
|
||||
(plot as { cursor: { left: number; top: number } }).cursor = {
|
||||
left: 10,
|
||||
top: 10,
|
||||
};
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { getPaletteStops } from '../palettes';
|
||||
import { HeatmapColorPalette } from '../types';
|
||||
|
||||
const ALL_PALETTES = Object.values(HeatmapColorPalette);
|
||||
|
||||
/** Perceived brightness, good enough to tell a ramp's ends apart. */
|
||||
function luminance(hex: string): number {
|
||||
const value = parseInt(hex.slice(1), 16);
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const [r, g, b] = [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
describe('getPaletteStops', () => {
|
||||
it.each(ALL_PALETTES)('%s is a full ramp of valid colours', (palette) => {
|
||||
const stops = getPaletteStops(palette, true);
|
||||
|
||||
expect(stops).toHaveLength(9);
|
||||
stops.forEach((stop) => expect(stop).toMatch(/^#[0-9a-f]{6}$/));
|
||||
});
|
||||
|
||||
it.each(ALL_PALETTES)(
|
||||
'%s climbs from dark to bright on a dark panel',
|
||||
(palette) => {
|
||||
const stops = getPaletteStops(palette, true);
|
||||
|
||||
// Low counts must sit near the surface, whichever direction the ramp is
|
||||
// stored in — otherwise empty cells become the loudest thing on screen.
|
||||
expect(luminance(stops[0])).toBeLessThan(luminance(stops[stops.length - 1]));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL_PALETTES)(
|
||||
'%s falls from pale to saturated on a light panel',
|
||||
(palette) => {
|
||||
const stops = getPaletteStops(palette, false);
|
||||
|
||||
expect(luminance(stops[0])).toBeGreaterThan(
|
||||
luminance(stops[stops.length - 1]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL_PALETTES)('%s uses the same colours in both themes', (palette) => {
|
||||
// Only the polarity flips; the palette itself is theme-independent.
|
||||
expect([...getPaletteStops(palette, false)].reverse()).toStrictEqual(
|
||||
getPaletteStops(palette, true),
|
||||
);
|
||||
});
|
||||
|
||||
it('never mutates the stored ramp when reversing it', () => {
|
||||
const first = getPaletteStops(HeatmapColorPalette.Lava, false);
|
||||
const second = getPaletteStops(HeatmapColorPalette.Lava, false);
|
||||
|
||||
expect(first).toStrictEqual(second);
|
||||
});
|
||||
|
||||
it('falls back to the first ramp for an unknown palette', () => {
|
||||
const unknown = 'nope' as HeatmapColorPalette;
|
||||
|
||||
expect(getPaletteStops(unknown, true)).toStrictEqual(
|
||||
getPaletteStops(HeatmapColorPalette.Ice, true),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a neutral ramp for panels that already spend colour elsewhere', () => {
|
||||
const stops = getPaletteStops(HeatmapColorPalette.Graphite, true);
|
||||
|
||||
// Every stop is a grey: red, green and blue channels stay equal.
|
||||
stops.forEach((stop) => {
|
||||
expect(stop.slice(1, 3)).toBe(stop.slice(3, 5));
|
||||
expect(stop.slice(3, 5)).toBe(stop.slice(5, 7));
|
||||
});
|
||||
});
|
||||
});
|
||||
198
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/colorScale.ts
Normal file
198
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/colorScale.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { Color as DesignToken } from '@signozhq/design-tokens';
|
||||
import Color from 'color';
|
||||
|
||||
import { getPaletteStops } from './palettes';
|
||||
import {
|
||||
HeatmapColorMode,
|
||||
HeatmapColorOptions,
|
||||
HeatmapColorScale,
|
||||
HeatmapColorPalette,
|
||||
} from './types';
|
||||
|
||||
export const MIN_COLOR_STEPS = 2;
|
||||
export const MAX_COLOR_STEPS = 128;
|
||||
export const DEFAULT_COLOR_STEPS = 64;
|
||||
|
||||
/** Without a floor, the lowest counts read as "no data". */
|
||||
export const MIN_OPACITY_ALPHA = 0.1;
|
||||
|
||||
/** Used when neither an explicit fill nor a series colour is available. */
|
||||
export const DEFAULT_OPACITY_FILL = DesignToken.BG_ROBIN_500;
|
||||
|
||||
export const DEFAULT_HEATMAP_COLORS: HeatmapColorOptions = {
|
||||
mode: HeatmapColorMode.Palette,
|
||||
scale: HeatmapColorScale.Log,
|
||||
minCount: null,
|
||||
maxCount: null,
|
||||
palette: HeatmapColorPalette.Lava,
|
||||
steps: DEFAULT_COLOR_STEPS,
|
||||
fill: '',
|
||||
};
|
||||
|
||||
export interface CountDomain {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/** Highest count, ignoring `null`. 0 for an empty grid. */
|
||||
export function getMaxCount(counts: Array<Array<number | null>>): number {
|
||||
let max = 0;
|
||||
for (const row of counts) {
|
||||
for (const count of row) {
|
||||
if (count !== null && Number.isFinite(count) && count > max) {
|
||||
max = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/** Explicit clamps win; otherwise 0 to the grid's highest count. */
|
||||
export function resolveCountDomain(
|
||||
options: Pick<HeatmapColorOptions, 'minCount' | 'maxCount'>,
|
||||
counts: Array<Array<number | null>>,
|
||||
): CountDomain {
|
||||
const min = options.minCount ?? 0;
|
||||
const max = options.maxCount ?? getMaxCount(counts);
|
||||
return max > min ? { min, max } : { min, max: min };
|
||||
}
|
||||
|
||||
/** Position on the colour scale, 0..1. A degenerate domain collapses to 0 so an
|
||||
* all-zero grid renders at the bottom rather than disappearing. */
|
||||
export function normalizeCount({
|
||||
count,
|
||||
domain,
|
||||
scale,
|
||||
}: {
|
||||
count: number;
|
||||
domain: CountDomain;
|
||||
scale: HeatmapColorScale;
|
||||
}): number {
|
||||
const { min, max } = domain;
|
||||
if (!(max > min)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const clamped = Math.min(Math.max(count, min), max);
|
||||
|
||||
if (scale === HeatmapColorScale.Log) {
|
||||
// 0 and 1 both sit at the bottom; log of either is meaningless.
|
||||
const logMin = Math.log10(Math.max(min, 1));
|
||||
const logMax = Math.log10(Math.max(max, 1));
|
||||
if (!(logMax > logMin)) {
|
||||
return 0;
|
||||
}
|
||||
return (Math.log10(Math.max(clamped, 1)) - logMin) / (logMax - logMin);
|
||||
}
|
||||
|
||||
const linear = (clamped - min) / (max - min);
|
||||
return scale === HeatmapColorScale.Sqrt ? Math.sqrt(linear) : linear;
|
||||
}
|
||||
|
||||
export function clampColorSteps(steps: number): number {
|
||||
if (!Number.isFinite(steps)) {
|
||||
return DEFAULT_COLOR_STEPS;
|
||||
}
|
||||
return Math.min(Math.max(Math.round(steps), MIN_COLOR_STEPS), MAX_COLOR_STEPS);
|
||||
}
|
||||
|
||||
/** Colour at `t` (0..1) along a multi-stop ramp. */
|
||||
function sampleStops(stops: string[], t: number): string {
|
||||
if (stops.length === 0) {
|
||||
return 'transparent';
|
||||
}
|
||||
if (stops.length === 1) {
|
||||
return stops[0];
|
||||
}
|
||||
const scaled = Math.min(Math.max(t, 0), 1) * (stops.length - 1);
|
||||
const lower = Math.min(Math.floor(scaled), stops.length - 2);
|
||||
return Color(stops[lower])
|
||||
.mix(Color(stops[lower + 1]), scaled - lower)
|
||||
.hex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Colour the densest cells are drawn with — the palette's extreme, or the opacity
|
||||
* fill at full strength. Depends only on the options, not on the data, so callers
|
||||
* can read it before a grid exists.
|
||||
*/
|
||||
export function resolveExtremeColor({
|
||||
options,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
}: {
|
||||
options: HeatmapColorOptions;
|
||||
isDarkMode: boolean;
|
||||
seriesColor: string;
|
||||
}): string {
|
||||
if (options.mode === HeatmapColorMode.Opacity) {
|
||||
return options.fill || seriesColor || DEFAULT_OPACITY_FILL;
|
||||
}
|
||||
const stops = getPaletteStops(options.palette, isDarkMode);
|
||||
return stops[stops.length - 1] ?? DEFAULT_OPACITY_FILL;
|
||||
}
|
||||
|
||||
export interface HeatmapColorResolver {
|
||||
/** `null` for a `null` count, which must be hatched. */
|
||||
colorFor: (count: number | null) => string | null;
|
||||
/** 0..1, or `null` for a `null` count. */
|
||||
positionOf: (count: number | null) => number | null;
|
||||
/** Low to high. The colour bar renders exactly these. */
|
||||
ramp: string[];
|
||||
domain: CountDomain;
|
||||
}
|
||||
|
||||
/** Palette mode walks a sequential ramp; opacity mode varies the alpha of one
|
||||
* fill, so the grid matches its group's legend swatch. */
|
||||
export function createHeatmapColorResolver({
|
||||
options,
|
||||
domain,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
}: {
|
||||
options: HeatmapColorOptions;
|
||||
domain: CountDomain;
|
||||
isDarkMode: boolean;
|
||||
/** Opacity-mode fill when `options.fill` is empty. */
|
||||
seriesColor: string;
|
||||
}): HeatmapColorResolver {
|
||||
const steps = clampColorSteps(options.steps);
|
||||
const positions = Array.from({ length: steps }, (_, index) =>
|
||||
steps === 1 ? 0 : index / (steps - 1),
|
||||
);
|
||||
|
||||
let ramp: string[];
|
||||
if (options.mode === HeatmapColorMode.Opacity) {
|
||||
const base = Color(options.fill || seriesColor || DEFAULT_OPACITY_FILL);
|
||||
ramp = positions.map((t) =>
|
||||
base
|
||||
.alpha(MIN_OPACITY_ALPHA + t * (1 - MIN_OPACITY_ALPHA))
|
||||
.rgb()
|
||||
.string(),
|
||||
);
|
||||
} else {
|
||||
const stops = getPaletteStops(options.palette, isDarkMode);
|
||||
ramp = positions.map((t) => sampleStops(stops, t));
|
||||
}
|
||||
|
||||
const positionOf = (count: number | null): number | null => {
|
||||
if (count === null || !Number.isFinite(count)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeCount({ count, domain, scale: options.scale });
|
||||
};
|
||||
|
||||
return {
|
||||
positionOf,
|
||||
colorFor: (count): string | null => {
|
||||
const t = positionOf(count);
|
||||
if (t === null) {
|
||||
return null;
|
||||
}
|
||||
const index = Math.min(Math.floor(t * steps), steps - 1);
|
||||
return ramp[index];
|
||||
},
|
||||
ramp,
|
||||
domain,
|
||||
};
|
||||
}
|
||||
297
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/geometry.ts
Normal file
297
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/geometry.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { HeatmapAxisScale, HeatmapRow, HeatmapYAxis } from './types';
|
||||
|
||||
/** Used when the ratio cannot be inferred, i.e. a single boundary. */
|
||||
const FALLBACK_LOG_RATIO = 2;
|
||||
|
||||
const EMPTY_Y_AXIS: HeatmapYAxis = {
|
||||
rows: [],
|
||||
edges: [],
|
||||
splits: [],
|
||||
overflowSplit: null,
|
||||
toBucketValue: (axisValue: number): number => axisValue,
|
||||
min: 0,
|
||||
max: 1,
|
||||
};
|
||||
|
||||
/** Ascending, finite, de-duplicated boundaries. */
|
||||
function normalizeBounds(bounds: number[]): number[] {
|
||||
const sorted = bounds
|
||||
.filter((bound) => Number.isFinite(bound))
|
||||
.sort((a, b) => a - b);
|
||||
return sorted.filter(
|
||||
(bound, index) => index === 0 || bound !== sorted[index - 1],
|
||||
);
|
||||
}
|
||||
|
||||
/** True when a plain log axis can place every boundary. */
|
||||
export function canUseLogAxis(bounds: number[]): boolean {
|
||||
return bounds.length > 0 && bounds.every((bound) => bound > 0);
|
||||
}
|
||||
|
||||
interface AxisTransform {
|
||||
toAxisValue: (value: number) => number;
|
||||
toBucketValue: (axisValue: number) => number;
|
||||
}
|
||||
|
||||
const LINEAR_TRANSFORM: AxisTransform = {
|
||||
toAxisValue: (value) => value,
|
||||
toBucketValue: (axisValue) => axisValue,
|
||||
};
|
||||
|
||||
const LOG_TRANSFORM: AxisTransform = {
|
||||
toAxisValue: (value) => Math.log10(value),
|
||||
toBucketValue: (axisValue) => 10 ** axisValue,
|
||||
};
|
||||
|
||||
/**
|
||||
* Where "near zero" starts, taken as the smallest non-zero boundary magnitude. The
|
||||
* bucket layout already declares it, so it never needs to be configured.
|
||||
*/
|
||||
function resolveLinearThreshold(bounds: number[]): number {
|
||||
let threshold = Number.POSITIVE_INFINITY;
|
||||
for (const bound of bounds) {
|
||||
const magnitude = Math.abs(bound);
|
||||
if (magnitude > 0 && magnitude < threshold) {
|
||||
threshold = magnitude;
|
||||
}
|
||||
}
|
||||
return Number.isFinite(threshold) ? threshold : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symmetric log: linear within ±threshold, logarithmic beyond, mirrored across
|
||||
* zero. Bucketing an arbitrary logs/traces field can straddle zero — clock skew,
|
||||
* deltas, balances — which a plain log cannot place at all, and which a linear axis
|
||||
* squeezes into sub-pixel rows exactly where the interesting data sits.
|
||||
*
|
||||
* The gradient kink at ±threshold is invisible here: the threshold *is* a boundary,
|
||||
* so it lands on a row edge, and row edges are already discrete.
|
||||
*/
|
||||
function createSymlogTransform(threshold: number): AxisTransform {
|
||||
return {
|
||||
toAxisValue: (value) =>
|
||||
Math.abs(value) <= threshold
|
||||
? value / threshold
|
||||
: Math.sign(value) * (1 + Math.log10(Math.abs(value) / threshold)),
|
||||
toBucketValue: (axisValue) =>
|
||||
Math.abs(axisValue) <= 1
|
||||
? axisValue * threshold
|
||||
: Math.sign(axisValue) * threshold * 10 ** (Math.abs(axisValue) - 1),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAxisTransform(
|
||||
bounds: number[],
|
||||
scale: HeatmapAxisScale,
|
||||
): AxisTransform {
|
||||
if (scale !== HeatmapAxisScale.Log) {
|
||||
return LINEAR_TRANSFORM;
|
||||
}
|
||||
if (canUseLogAxis(bounds)) {
|
||||
return LOG_TRANSFORM;
|
||||
}
|
||||
// All-zero bounds have no magnitude to scale against.
|
||||
if (!bounds.some((bound) => bound !== 0)) {
|
||||
return LINEAR_TRANSFORM;
|
||||
}
|
||||
return createSymlogTransform(resolveLinearThreshold(bounds));
|
||||
}
|
||||
|
||||
/**
|
||||
* The open-ended rows still need a height, so each gets the grid's typical bucket
|
||||
* width — the mean gap in axis space, which on a geometric layout is exactly one
|
||||
* bucket ratio. Linear stays in value space so it can refuse to cross zero.
|
||||
*/
|
||||
function resolveOuterEdges(
|
||||
bounds: number[],
|
||||
transform: AxisTransform,
|
||||
isLinear: boolean,
|
||||
): { lower: number; upper: number } {
|
||||
const first = bounds[0];
|
||||
const last = bounds[bounds.length - 1];
|
||||
|
||||
if (isLinear) {
|
||||
const gap = bounds.length > 1 ? (last - first) / (bounds.length - 1) : 0;
|
||||
const safeGap = gap > 0 ? gap : Math.abs(first) || 1;
|
||||
// Never extend below zero unless the boundaries already do.
|
||||
const lower = first > 0 ? Math.max(0, first - safeGap) : first - safeGap;
|
||||
return { lower, upper: last + safeGap };
|
||||
}
|
||||
|
||||
const axisFirst = transform.toAxisValue(first);
|
||||
const axisLast = transform.toAxisValue(last);
|
||||
const fallback = Math.log10(FALLBACK_LOG_RATIO);
|
||||
const gap =
|
||||
bounds.length > 1 ? (axisLast - axisFirst) / (bounds.length - 1) : fallback;
|
||||
const safeGap = gap > 0 ? gap : fallback;
|
||||
|
||||
return {
|
||||
lower: transform.toBucketValue(axisFirst - safeGap),
|
||||
upper: transform.toBucketValue(axisLast + safeGap),
|
||||
};
|
||||
}
|
||||
|
||||
/** N boundaries produce N+1 rows: an underflow row below the first, and the
|
||||
* `+Inf` overflow row above the last. */
|
||||
export function resolveHeatmapYAxis(
|
||||
bounds: number[],
|
||||
scale: HeatmapAxisScale,
|
||||
): HeatmapYAxis {
|
||||
const normalized = normalizeBounds(bounds);
|
||||
if (normalized.length === 0) {
|
||||
return EMPTY_Y_AXIS;
|
||||
}
|
||||
|
||||
const transform = resolveAxisTransform(normalized, scale);
|
||||
const isLinear = transform === LINEAR_TRANSFORM;
|
||||
const { toAxisValue, toBucketValue } = transform;
|
||||
|
||||
const { lower, upper } = resolveOuterEdges(normalized, transform, isLinear);
|
||||
const last = normalized[normalized.length - 1];
|
||||
|
||||
const rows: HeatmapRow[] = [
|
||||
{ lower, upper: normalized[0], isUnderflow: true, isOverflow: false },
|
||||
];
|
||||
for (let index = 1; index < normalized.length; index += 1) {
|
||||
rows.push({
|
||||
lower: normalized[index - 1],
|
||||
upper: normalized[index],
|
||||
isUnderflow: false,
|
||||
isOverflow: false,
|
||||
});
|
||||
}
|
||||
rows.push({ lower: last, upper, isUnderflow: false, isOverflow: true });
|
||||
|
||||
const edges = [
|
||||
toAxisValue(lower),
|
||||
...normalized.map(toAxisValue),
|
||||
toAxisValue(upper),
|
||||
];
|
||||
|
||||
return {
|
||||
rows,
|
||||
edges,
|
||||
splits: normalized.map(toAxisValue),
|
||||
overflowSplit: toAxisValue(upper),
|
||||
toBucketValue,
|
||||
min: edges[0],
|
||||
max: edges[edges.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
/** Row containing `axisValue`, or `null` when it falls outside the grid. */
|
||||
export function resolveRowIndex(
|
||||
edges: number[],
|
||||
axisValue: number,
|
||||
): number | null {
|
||||
if (edges.length < 2) {
|
||||
return null;
|
||||
}
|
||||
if (axisValue < edges[0] || axisValue > edges[edges.length - 1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let low = 0;
|
||||
let high = edges.length - 2;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (axisValue < edges[mid]) {
|
||||
high = mid - 1;
|
||||
} else if (axisValue >= edges[mid + 1]) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
return mid;
|
||||
}
|
||||
}
|
||||
// Exactly on the top edge.
|
||||
return edges.length - 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* A containment test, not a nearest-timestamp lookup: uPlot's own `cursor.idx`
|
||||
* snaps to the closest boundary and would report the next column as soon as the
|
||||
* cursor passed a cell's midpoint.
|
||||
*/
|
||||
export function resolveColumnIndex(
|
||||
timestamps: ArrayLike<number>,
|
||||
xValue: number,
|
||||
step: number,
|
||||
): number | null {
|
||||
if (timestamps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let low = 0;
|
||||
let high = timestamps.length - 1;
|
||||
let candidate = -1;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (timestamps[mid] <= xValue) {
|
||||
candidate = mid;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate < 0) {
|
||||
return null;
|
||||
}
|
||||
const width = step > 0 ? step : Number.POSITIVE_INFINITY;
|
||||
return xValue < timestamps[candidate] + width ? candidate : null;
|
||||
}
|
||||
|
||||
/** The open-ended rows are labelled by their one real boundary; the synthetic
|
||||
* edge is a drawing device, not a value. */
|
||||
export function formatRowLabel(
|
||||
row: HeatmapRow,
|
||||
formatValue: (value: number) => string,
|
||||
): string {
|
||||
if (row.isOverflow) {
|
||||
return `> ${formatValue(row.lower)}`;
|
||||
}
|
||||
if (row.isUnderflow) {
|
||||
return `≤ ${formatValue(row.upper)}`;
|
||||
}
|
||||
return `${formatValue(row.lower)} – ${formatValue(row.upper)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops boundary ticks that would overlap. Filters by pixel distance rather than
|
||||
* index, since linear rows are not the same height, and walks down from the top
|
||||
* so the `∞` edge survives whatever else is dropped.
|
||||
*/
|
||||
export function decimateAxisSplits({
|
||||
splits,
|
||||
min,
|
||||
max,
|
||||
plotHeight,
|
||||
minGapPx,
|
||||
}: {
|
||||
/** Candidates in axis space, ascending. */
|
||||
splits: number[];
|
||||
min: number;
|
||||
max: number;
|
||||
/** Plotting area height, in CSS pixels. */
|
||||
plotHeight: number;
|
||||
minGapPx: number;
|
||||
}): number[] {
|
||||
if (splits.length < 2 || plotHeight <= 0 || minGapPx <= 0 || !(max > min)) {
|
||||
return splits;
|
||||
}
|
||||
|
||||
const pixelsPerUnit = plotHeight / (max - min);
|
||||
const kept: number[] = [];
|
||||
let lastPosition = 0;
|
||||
|
||||
for (let index = splits.length - 1; index >= 0; index -= 1) {
|
||||
// Axis values grow upward, pixel offsets downward.
|
||||
const position = (max - splits[index]) * pixelsPerUnit;
|
||||
if (kept.length === 0 || position - lastPosition >= minGapPx) {
|
||||
kept.push(splits[index]);
|
||||
lastPosition = position;
|
||||
}
|
||||
}
|
||||
|
||||
return kept.reverse();
|
||||
}
|
||||
99
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/grid.ts
Normal file
99
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/grid.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { HeatmapGrid, HeatmapSeries } from './types';
|
||||
|
||||
const EMPTY_GRID: HeatmapGrid = {
|
||||
bounds: [],
|
||||
timestamps: [],
|
||||
step: 0,
|
||||
counts: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Highest single-cell count each group reaches. Read against the same domain the
|
||||
* colour bar uses, this is where a group sits on that bar.
|
||||
*/
|
||||
export function resolveGroupPeaks(
|
||||
series: HeatmapSeries[],
|
||||
): Map<string, number> {
|
||||
const peaks = new Map<string, number>();
|
||||
series.forEach((entry) => {
|
||||
let peak = 0;
|
||||
entry.points.forEach((point) =>
|
||||
point.counts.forEach((count) => {
|
||||
if (count !== null && count > peak) {
|
||||
peak = count;
|
||||
}
|
||||
}),
|
||||
);
|
||||
peaks.set(entry.label, peak);
|
||||
});
|
||||
return peaks;
|
||||
}
|
||||
|
||||
/** Groups the legend currently has enabled. `undefined` means all of them. */
|
||||
function resolveVisible(
|
||||
series: HeatmapSeries[],
|
||||
visibleGroups: string[] | undefined,
|
||||
): HeatmapSeries[] {
|
||||
if (visibleGroups === undefined) {
|
||||
return series;
|
||||
}
|
||||
const allowed = new Set(visibleGroups);
|
||||
return series.filter((entry) => allowed.has(entry.label));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pivots the response's column-major counts into the row-major grid the renderer
|
||||
* draws, and sums the enabled groups — counts are additive, so the sum is exact and
|
||||
* needs no extra request. A cell is `null` only when no group contributed to it.
|
||||
*/
|
||||
export function resolveHeatmapGrid({
|
||||
buckets,
|
||||
step,
|
||||
series,
|
||||
visibleGroups,
|
||||
}: {
|
||||
buckets: number[];
|
||||
/** Column width in seconds. */
|
||||
step: number;
|
||||
series: HeatmapSeries[];
|
||||
/** Labels the legend has enabled. `undefined` sums every group. */
|
||||
visibleGroups?: string[];
|
||||
}): HeatmapGrid {
|
||||
if (buckets.length === 0 || series.length === 0) {
|
||||
return EMPTY_GRID;
|
||||
}
|
||||
|
||||
const selected = resolveVisible(series, visibleGroups);
|
||||
|
||||
// Groups are not guaranteed to share timestamps, so the columns are their union.
|
||||
const timestampSet = new Set<number>();
|
||||
selected.forEach((entry) => {
|
||||
entry.points.forEach((point) => timestampSet.add(point.timestamp));
|
||||
});
|
||||
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
|
||||
const columnOf = new Map(timestamps.map((value, index) => [value, index]));
|
||||
|
||||
// N boundaries describe N+1 rows: the underflow row and the `+Inf` overflow row.
|
||||
const rowCount = buckets.length + 1;
|
||||
const counts: Array<Array<number | null>> = Array.from(
|
||||
{ length: rowCount },
|
||||
() => new Array<number | null>(timestamps.length).fill(null),
|
||||
);
|
||||
|
||||
selected.forEach((entry) => {
|
||||
entry.points.forEach((point) => {
|
||||
const column = columnOf.get(point.timestamp);
|
||||
if (column === undefined) {
|
||||
return;
|
||||
}
|
||||
point.counts.forEach((count, row) => {
|
||||
if (row >= rowCount || count === null || count === undefined) {
|
||||
return;
|
||||
}
|
||||
counts[row][column] = (counts[row][column] ?? 0) + count;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { bounds: buckets, timestamps, step, counts };
|
||||
}
|
||||
170
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin.ts
Normal file
170
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
createHeatmapColorResolver,
|
||||
HeatmapColorResolver,
|
||||
resolveCountDomain,
|
||||
} from './colorScale';
|
||||
import { resolveColumnIndex, resolveRowIndex } from './geometry';
|
||||
import {
|
||||
createHoverOverlay,
|
||||
HeatmapHoverOverlay,
|
||||
showHoverOverlay,
|
||||
} from './hoverOverlay';
|
||||
import { createHatchPattern, drawCells, drawOverflowBoundary } from './paint';
|
||||
import { HeatmapCell, HeatmapColorOptions, HeatmapYAxis } from './types';
|
||||
|
||||
export interface HeatmapRenderOptions {
|
||||
yAxis: HeatmapYAxis;
|
||||
/** Column width in seconds. */
|
||||
step: number;
|
||||
colors: HeatmapColorOptions;
|
||||
isDarkMode: boolean;
|
||||
/** Opacity-mode fill when `colors.fill` is empty. */
|
||||
seriesColor: string;
|
||||
/** Default true. */
|
||||
dimOnHover?: boolean;
|
||||
/** `null` when the cursor leaves. */
|
||||
onHoverChange?: (cell: HeatmapCell | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered through `UPlotConfigBuilder.addHook`, not as a `uPlot.Plugin`: uPlot
|
||||
* appends plugin hooks *after* the hook arrays, and `setCursor` must run before
|
||||
* TooltipPlugin's so the focused row is resolved when the tooltip positions
|
||||
* itself. As a plugin it trails a frame and the tooltip flashes at the origin.
|
||||
*/
|
||||
export interface HeatmapHooks {
|
||||
init: (u: uPlot) => void;
|
||||
draw: (u: uPlot) => void;
|
||||
setCursor: (u: uPlot) => void;
|
||||
destroy: (u: uPlot) => void;
|
||||
}
|
||||
|
||||
export function createHeatmapHooks({
|
||||
yAxis,
|
||||
step,
|
||||
colors,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
dimOnHover = true,
|
||||
onHoverChange,
|
||||
}: HeatmapRenderOptions): HeatmapHooks {
|
||||
let overlay: HeatmapHoverOverlay | null = null;
|
||||
let hovered: HeatmapCell | null = null;
|
||||
let hatchPattern: CanvasPattern | null = null;
|
||||
|
||||
// On auto, the domain comes from the data, but these hooks are captured once at
|
||||
// config-build time. Resolving lazily keeps a refetch on uPlot's `setData` path
|
||||
// rather than forcing a rebuild.
|
||||
let cachedData: uPlot.AlignedData | null = null;
|
||||
let cachedResolver: HeatmapColorResolver | null = null;
|
||||
|
||||
function getResolver(u: uPlot): HeatmapColorResolver {
|
||||
if (cachedResolver && cachedData === u.data) {
|
||||
return cachedResolver;
|
||||
}
|
||||
cachedResolver = createHeatmapColorResolver({
|
||||
options: colors,
|
||||
domain: resolveCountDomain(
|
||||
colors,
|
||||
u.data.slice(1) as Array<Array<number | null>>,
|
||||
),
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
});
|
||||
cachedData = u.data;
|
||||
return cachedResolver;
|
||||
}
|
||||
|
||||
function clearHover(u: uPlot): void {
|
||||
if (overlay) {
|
||||
overlay.container.style.display = 'none';
|
||||
}
|
||||
if (hovered === null) {
|
||||
return;
|
||||
}
|
||||
hovered = null;
|
||||
u.setSeries(null, { focus: true });
|
||||
onHoverChange?.(null);
|
||||
}
|
||||
|
||||
return {
|
||||
init: (u: uPlot): void => {
|
||||
overlay = createHoverOverlay(isDarkMode);
|
||||
u.over.appendChild(overlay.container);
|
||||
},
|
||||
|
||||
draw: (u: uPlot): void => {
|
||||
const timestamps = u.data[0] as ArrayLike<number> | undefined;
|
||||
if (!timestamps?.length || yAxis.rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
const { ctx } = u;
|
||||
hatchPattern ??= createHatchPattern(ctx, isDarkMode);
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
|
||||
ctx.clip();
|
||||
drawCells({ u, yAxis, step, resolver: getResolver(u), hatchPattern });
|
||||
ctx.restore();
|
||||
|
||||
drawOverflowBoundary({ u, yAxis, isDarkMode });
|
||||
},
|
||||
|
||||
setCursor: (u: uPlot): void => {
|
||||
const { left = -10, top = -10 } = u.cursor;
|
||||
if (left < 0 || top < 0) {
|
||||
clearHover(u);
|
||||
return;
|
||||
}
|
||||
|
||||
const column = resolveColumnIndex(
|
||||
u.data[0] as ArrayLike<number>,
|
||||
u.posToVal(left, 'x'),
|
||||
step,
|
||||
);
|
||||
const row = resolveRowIndex(yAxis.edges, u.posToVal(top, 'y'));
|
||||
if (column === null || row === null) {
|
||||
clearHover(u);
|
||||
return;
|
||||
}
|
||||
if (hovered?.row === row && hovered?.column === column) {
|
||||
return;
|
||||
}
|
||||
|
||||
hovered = {
|
||||
row,
|
||||
column,
|
||||
count:
|
||||
(u.data[row + 1] as Array<number | null> | undefined)?.[column] ?? null,
|
||||
};
|
||||
// Drives TooltipPlugin, which only shows a tooltip for a focused series.
|
||||
// uPlot's own focus is disabled here: it picks the series nearest in value
|
||||
// space, and a heatmap's value is a colour, not a y coordinate.
|
||||
u.setSeries(row + 1, { focus: true });
|
||||
if (overlay) {
|
||||
showHoverOverlay({
|
||||
overlay,
|
||||
u,
|
||||
yAxis,
|
||||
step,
|
||||
row,
|
||||
column,
|
||||
dim: dimOnHover,
|
||||
});
|
||||
}
|
||||
onHoverChange?.(hovered);
|
||||
},
|
||||
|
||||
destroy: (): void => {
|
||||
overlay?.container.remove();
|
||||
overlay = null;
|
||||
hovered = null;
|
||||
hatchPattern = null;
|
||||
cachedData = null;
|
||||
cachedResolver = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
118
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/hoverOverlay.ts
Normal file
118
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/hoverOverlay.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { HeatmapYAxis } from './types';
|
||||
|
||||
const HIGHLIGHT_BORDER_WIDTH = 1;
|
||||
/** ~55% alpha. */
|
||||
const DIM_ALPHA = '8C';
|
||||
|
||||
export interface HeatmapHoverOverlay {
|
||||
container: HTMLDivElement;
|
||||
highlight: HTMLDivElement;
|
||||
/** Four corner rects whose complement is the hovered row/column cross. */
|
||||
dims: HTMLDivElement[];
|
||||
}
|
||||
|
||||
function createOverlayElement(): HTMLDivElement {
|
||||
const element = document.createElement('div');
|
||||
element.style.position = 'absolute';
|
||||
element.style.pointerEvents = 'none';
|
||||
return element;
|
||||
}
|
||||
|
||||
function setRect(
|
||||
element: HTMLDivElement,
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
element.style.left = `${left}px`;
|
||||
element.style.top = `${top}px`;
|
||||
element.style.width = `${Math.max(0, width)}px`;
|
||||
element.style.height = `${Math.max(0, height)}px`;
|
||||
}
|
||||
|
||||
/** Kept out of the canvas so moving between cells repositions a few nodes
|
||||
* instead of repainting the grid. */
|
||||
export function createHoverOverlay(isDarkMode: boolean): HeatmapHoverOverlay {
|
||||
const container = createOverlayElement();
|
||||
container.style.inset = '0';
|
||||
container.style.display = 'none';
|
||||
container.setAttribute('data-testid', 'heatmap-hover-overlay');
|
||||
|
||||
const dimColor = `${
|
||||
isDarkMode ? Color.BG_INK_500 : Color.BG_VANILLA_100
|
||||
}${DIM_ALPHA}`;
|
||||
const dims = Array.from({ length: 4 }, () => {
|
||||
const dim = createOverlayElement();
|
||||
dim.style.background = dimColor;
|
||||
container.appendChild(dim);
|
||||
return dim;
|
||||
});
|
||||
|
||||
const highlight = createOverlayElement();
|
||||
highlight.style.border = `${HIGHLIGHT_BORDER_WIDTH}px solid ${
|
||||
isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_300
|
||||
}`;
|
||||
highlight.style.boxSizing = 'border-box';
|
||||
container.appendChild(highlight);
|
||||
|
||||
return { container, highlight, dims };
|
||||
}
|
||||
|
||||
/** Positions the highlight, and the four corner rects so only the hovered row
|
||||
* and column stay at full contrast. */
|
||||
export function showHoverOverlay({
|
||||
overlay,
|
||||
u,
|
||||
yAxis,
|
||||
step,
|
||||
row,
|
||||
column,
|
||||
dim,
|
||||
}: {
|
||||
overlay: HeatmapHoverOverlay;
|
||||
u: uPlot;
|
||||
yAxis: HeatmapYAxis;
|
||||
step: number;
|
||||
row: number;
|
||||
column: number;
|
||||
dim: boolean;
|
||||
}): void {
|
||||
const timestamps = u.data[0] as ArrayLike<number>;
|
||||
const width = u.over.clientWidth;
|
||||
const height = u.over.clientHeight;
|
||||
|
||||
const cellLeft = u.valToPos(timestamps[column], 'x');
|
||||
const cellRight = u.valToPos(timestamps[column] + step, 'x');
|
||||
const cellTop = u.valToPos(yAxis.edges[row + 1], 'y');
|
||||
const cellBottom = u.valToPos(yAxis.edges[row], 'y');
|
||||
|
||||
setRect(
|
||||
overlay.highlight,
|
||||
cellLeft,
|
||||
cellTop,
|
||||
cellRight - cellLeft,
|
||||
cellBottom - cellTop,
|
||||
);
|
||||
|
||||
const [topLeft, topRight, bottomLeft, bottomRight] = overlay.dims;
|
||||
if (dim) {
|
||||
setRect(topLeft, 0, 0, cellLeft, cellTop);
|
||||
setRect(topRight, cellRight, 0, width - cellRight, cellTop);
|
||||
setRect(bottomLeft, 0, cellBottom, cellLeft, height - cellBottom);
|
||||
setRect(
|
||||
bottomRight,
|
||||
cellRight,
|
||||
cellBottom,
|
||||
width - cellRight,
|
||||
height - cellBottom,
|
||||
);
|
||||
} else {
|
||||
overlay.dims.forEach((element) => setRect(element, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
overlay.container.style.display = 'block';
|
||||
}
|
||||
128
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/paint.ts
Normal file
128
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/paint.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { HeatmapColorResolver } from './colorScale';
|
||||
import { HeatmapYAxis } from './types';
|
||||
|
||||
/** Cells at least this wide/tall keep a hairline separator. */
|
||||
const MIN_CELL_SIZE_FOR_GAP = 4;
|
||||
const HATCH_TILE_SIZE = 6;
|
||||
const OVERFLOW_DASH: [number, number] = [4, 3];
|
||||
|
||||
/** Hatch for `null` cells: a gap must never share the bottom-of-scale fill, or a
|
||||
* scrape outage reads as a quiet period. */
|
||||
export function createHatchPattern(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
isDarkMode: boolean,
|
||||
): CanvasPattern | null {
|
||||
const pxRatio = uPlot.pxRatio;
|
||||
const size = Math.max(2, Math.round(HATCH_TILE_SIZE * pxRatio));
|
||||
const tile = document.createElement('canvas');
|
||||
tile.width = size;
|
||||
tile.height = size;
|
||||
|
||||
const tileCtx = tile.getContext('2d');
|
||||
if (!tileCtx) {
|
||||
return null;
|
||||
}
|
||||
tileCtx.strokeStyle = isDarkMode
|
||||
? `${Color.BG_VANILLA_400}59`
|
||||
: `${Color.BG_INK_300}40`;
|
||||
tileCtx.lineWidth = Math.max(1, pxRatio);
|
||||
tileCtx.beginPath();
|
||||
// Three strokes keep the pattern continuous across tile seams.
|
||||
tileCtx.moveTo(0, size);
|
||||
tileCtx.lineTo(size, 0);
|
||||
tileCtx.moveTo(-size / 2, size / 2);
|
||||
tileCtx.lineTo(size / 2, -size / 2);
|
||||
tileCtx.moveTo(size / 2, size * 1.5);
|
||||
tileCtx.lineTo(size * 1.5, size / 2);
|
||||
tileCtx.stroke();
|
||||
|
||||
return ctx.createPattern(tile, 'repeat');
|
||||
}
|
||||
|
||||
/** One canvas pass. Offscreen columns are skipped rather than clipped. */
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function drawCells({
|
||||
u,
|
||||
yAxis,
|
||||
step,
|
||||
resolver,
|
||||
hatchPattern,
|
||||
}: {
|
||||
u: uPlot;
|
||||
yAxis: HeatmapYAxis;
|
||||
step: number;
|
||||
resolver: HeatmapColorResolver;
|
||||
hatchPattern: CanvasPattern | null;
|
||||
}): void {
|
||||
const { ctx } = u;
|
||||
const timestamps = u.data[0] as ArrayLike<number>;
|
||||
const { rows, edges } = yAxis;
|
||||
const pxRatio = uPlot.pxRatio;
|
||||
|
||||
const xMin = u.scales.x.min ?? timestamps[0];
|
||||
const xMax = u.scales.x.max ?? timestamps[timestamps.length - 1] + step;
|
||||
const rowEdgePositions = edges.map((edge) => u.valToPos(edge, 'y', true));
|
||||
|
||||
for (let column = 0; column < timestamps.length; column += 1) {
|
||||
const columnStart = timestamps[column];
|
||||
const columnEnd = columnStart + step;
|
||||
if (columnEnd < xMin || columnStart > xMax) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const left = u.valToPos(columnStart, 'x', true);
|
||||
const rawWidth = u.valToPos(columnEnd, 'x', true) - left;
|
||||
const gapX = rawWidth > MIN_CELL_SIZE_FOR_GAP * pxRatio ? pxRatio : 0;
|
||||
const width = Math.max(1, rawWidth - gapX);
|
||||
|
||||
for (let row = 0; row < rows.length; row += 1) {
|
||||
const top = rowEdgePositions[row + 1];
|
||||
const rawHeight = rowEdgePositions[row] - top;
|
||||
const gapY = rawHeight > MIN_CELL_SIZE_FOR_GAP * pxRatio ? pxRatio : 0;
|
||||
|
||||
const count = (u.data[row + 1] as Array<number | null> | undefined)?.[
|
||||
column
|
||||
];
|
||||
const fill = resolver.colorFor(count ?? null);
|
||||
|
||||
if (fill === null && hatchPattern === null) {
|
||||
continue;
|
||||
}
|
||||
ctx.fillStyle = fill ?? (hatchPattern as CanvasPattern);
|
||||
ctx.fillRect(left, top, width, Math.max(1, rawHeight - gapY));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The `+Inf` row is unbounded, so its height is a drawing convenience and
|
||||
* should not be compared with the real buckets. */
|
||||
export function drawOverflowBoundary({
|
||||
u,
|
||||
yAxis,
|
||||
isDarkMode,
|
||||
}: {
|
||||
u: uPlot;
|
||||
yAxis: HeatmapYAxis;
|
||||
isDarkMode: boolean;
|
||||
}): void {
|
||||
const overflowIndex = yAxis.rows.length - 1;
|
||||
if (overflowIndex < 1 || !yAxis.rows[overflowIndex].isOverflow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ctx } = u;
|
||||
const y = Math.round(u.valToPos(yAxis.edges[overflowIndex], 'y', true));
|
||||
|
||||
ctx.save();
|
||||
ctx.setLineDash(OVERFLOW_DASH);
|
||||
ctx.lineWidth = Math.max(1, uPlot.pxRatio);
|
||||
ctx.strokeStyle = isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_300;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(u.bbox.left, y);
|
||||
ctx.lineTo(u.bbox.left + u.bbox.width, y);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
167
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/palettes.ts
Normal file
167
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/palettes.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { HeatmapColorPalette } from './types';
|
||||
|
||||
interface PaletteDefinition {
|
||||
/** Evenly spaced, one end of the ramp to the other. */
|
||||
stops: string[];
|
||||
/** `true` when `stops[0]` is the dark end. */
|
||||
darkFirst: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop values come from the long-established public palette families —
|
||||
* ColorBrewer for the hue ramps, matplotlib's perceptual set for the rest.
|
||||
*/
|
||||
const PALETTES: Record<HeatmapColorPalette, PaletteDefinition> = {
|
||||
[HeatmapColorPalette.Ice]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#f7fbff',
|
||||
'#deebf7',
|
||||
'#c3dbee',
|
||||
'#9cc8e2',
|
||||
'#6daed5',
|
||||
'#4391c6',
|
||||
'#2271b4',
|
||||
'#0c5198',
|
||||
'#08306b',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Moss]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#f7fcf5',
|
||||
'#e3f4de',
|
||||
'#c6e8bf',
|
||||
'#a0d89b',
|
||||
'#73c378',
|
||||
'#45aa5d',
|
||||
'#228b45',
|
||||
'#066b2d',
|
||||
'#00441b',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Rust]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#fff5f0',
|
||||
'#feddcf',
|
||||
'#fcbaa1',
|
||||
'#fc9273',
|
||||
'#f9694c',
|
||||
'#eb3d2f',
|
||||
'#cb1c1e',
|
||||
'#a10e15',
|
||||
'#67000d',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Graphite]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#ffffff',
|
||||
'#efefef',
|
||||
'#d8d8d8',
|
||||
'#bbbbbb',
|
||||
'#979797',
|
||||
'#737373',
|
||||
'#505050',
|
||||
'#262626',
|
||||
'#000000',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Ember]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#ffffcc',
|
||||
'#ffeda0',
|
||||
'#fed676',
|
||||
'#feb250',
|
||||
'#fd893c',
|
||||
'#f8502b',
|
||||
'#e11e20',
|
||||
'#b90424',
|
||||
'#800026',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Lagoon]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#ffffd9',
|
||||
'#eaf7b8',
|
||||
'#c1e7b5',
|
||||
'#81cebb',
|
||||
'#45b4c2',
|
||||
'#248fbd',
|
||||
'#2260a9',
|
||||
'#20378d',
|
||||
'#081d58',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Orchid]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#fff7f3',
|
||||
'#fddfdc',
|
||||
'#fcc3c3',
|
||||
'#fa9cb4',
|
||||
'#f369a3',
|
||||
'#da3495',
|
||||
'#ad0a81',
|
||||
'#7b0176',
|
||||
'#49006a',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Verdant]: {
|
||||
darkFirst: true,
|
||||
stops: [
|
||||
'#440154',
|
||||
'#472d7b',
|
||||
'#3b528b',
|
||||
'#2c728e',
|
||||
'#21918c',
|
||||
'#28ae80',
|
||||
'#5ec962',
|
||||
'#addc30',
|
||||
'#fde725',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Lava]: {
|
||||
darkFirst: true,
|
||||
stops: [
|
||||
'#000004',
|
||||
'#1d1147',
|
||||
'#51127c',
|
||||
'#832681',
|
||||
'#b73779',
|
||||
'#e75263',
|
||||
'#fc8961',
|
||||
'#fec488',
|
||||
'#fcfdbf',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Beacon]: {
|
||||
darkFirst: true,
|
||||
stops: [
|
||||
'#002051',
|
||||
'#11366c',
|
||||
'#3c4d6e',
|
||||
'#62646f',
|
||||
'#7f7c75',
|
||||
'#9a9478',
|
||||
'#bbaf71',
|
||||
'#e2cb5c',
|
||||
'#fdea45',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Stops oriented low-count first for the active theme. At the wrong polarity,
|
||||
* empty cells become the loudest thing on screen. */
|
||||
export function getPaletteStops(
|
||||
palette: HeatmapColorPalette,
|
||||
isDarkMode: boolean,
|
||||
): string[] {
|
||||
const definition = PALETTES[palette] ?? PALETTES[HeatmapColorPalette.Ice];
|
||||
return definition.darkFirst === isDarkMode
|
||||
? definition.stops
|
||||
: [...definition.stops].reverse();
|
||||
}
|
||||
113
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/types.ts
Normal file
113
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/types.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
export enum HeatmapColorScale {
|
||||
Log = 'log',
|
||||
Sqrt = 'sqrt',
|
||||
Linear = 'linear',
|
||||
}
|
||||
|
||||
export enum HeatmapColorMode {
|
||||
Palette = 'palette',
|
||||
Opacity = 'opacity',
|
||||
}
|
||||
|
||||
/** Sequential ramps only: colour means "count", so a midpoint or hue cycle would
|
||||
* read as a threshold that does not exist. */
|
||||
export enum HeatmapColorPalette {
|
||||
Ice = 'ice',
|
||||
Moss = 'moss',
|
||||
Rust = 'rust',
|
||||
Graphite = 'graphite',
|
||||
Ember = 'ember',
|
||||
Lagoon = 'lagoon',
|
||||
Orchid = 'orchid',
|
||||
Verdant = 'verdant',
|
||||
Lava = 'lava',
|
||||
Beacon = 'beacon',
|
||||
}
|
||||
|
||||
export interface HeatmapColorOptions {
|
||||
mode: HeatmapColorMode;
|
||||
scale: HeatmapColorScale;
|
||||
/** `null` derives it, which is always 0 — a count of 0 belongs at the bottom. */
|
||||
minCount: number | null;
|
||||
/** `null` derives it from the grid's highest count. */
|
||||
maxCount: number | null;
|
||||
palette: HeatmapColorPalette;
|
||||
/** Colour steps the ramp is quantised into, 2..128. Unrelated to `step`, the
|
||||
* column width in seconds. */
|
||||
steps: number;
|
||||
/** Opacity mode. Empty falls back to the caller's series colour. */
|
||||
fill: string;
|
||||
}
|
||||
|
||||
/** Row-height distribution of the bucket axis. */
|
||||
export enum HeatmapAxisScale {
|
||||
Log = 'log',
|
||||
Linear = 'linear',
|
||||
}
|
||||
|
||||
export interface HeatmapSeriesPoint {
|
||||
/** Column start, in seconds. */
|
||||
timestamp: number;
|
||||
/** One per bucket row, lowest first. `null` is "no data", never `0`. */
|
||||
counts: Array<number | null>;
|
||||
}
|
||||
|
||||
export interface HeatmapSeriesLabel {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface HeatmapSeries {
|
||||
/** Group label, as the legend names it. Empty when there is no grouping. */
|
||||
label: string;
|
||||
/** The pairs behind `label`, letting the tooltip name rows by value alone. */
|
||||
labels?: HeatmapSeriesLabel[];
|
||||
points: HeatmapSeriesPoint[];
|
||||
}
|
||||
|
||||
/** Counts pivoted into rows and aligned to one column axis. Internal to the
|
||||
* chart, which resolves it from `buckets` and `series`. */
|
||||
export interface HeatmapGrid {
|
||||
/** Ascending. N boundaries describe N+1 rows, including the `+Inf` overflow. */
|
||||
bounds: number[];
|
||||
/** Column starts, in seconds. */
|
||||
timestamps: number[];
|
||||
/** Column width in seconds. Cells span `[timestamps[j], timestamps[j] + step)`,
|
||||
* and the last column has no successor to infer it from. */
|
||||
step: number;
|
||||
/** `counts[row][column]`, row 0 lowest. `null` (no data) renders hatched, `0`
|
||||
* at the bottom of the scale — conflating them hides an outage. */
|
||||
counts: Array<Array<number | null>>;
|
||||
}
|
||||
|
||||
export interface HeatmapRow {
|
||||
/** Synthetic on the underflow row. */
|
||||
lower: number;
|
||||
/** Synthetic on the overflow row. */
|
||||
upper: number;
|
||||
isUnderflow: boolean;
|
||||
isOverflow: boolean;
|
||||
}
|
||||
|
||||
/** The bucket axis in uPlot y-scale space. A log axis is log10 values on a
|
||||
* *linear* scale, not uPlot's log distribution, so boundaries stay exactly on
|
||||
* ticks and uPlot's decade-only label filter cannot hide them. */
|
||||
export interface HeatmapYAxis {
|
||||
rows: HeatmapRow[];
|
||||
/** Row edges, ascending. Length is `rows.length + 1`. */
|
||||
edges: number[];
|
||||
/** Real bucket boundaries — one tick each. */
|
||||
splits: number[];
|
||||
/** Where the `∞` tick goes: the overflow row's upper edge, not its centre,
|
||||
* which would sit half a row from the last boundary and collide with it. */
|
||||
overflowSplit: number | null;
|
||||
toBucketValue: (axisValue: number) => number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface HeatmapCell {
|
||||
row: number;
|
||||
column: number;
|
||||
count: number | null;
|
||||
}
|
||||
@@ -41,6 +41,9 @@ export default function ChartWrapper({
|
||||
customTooltip,
|
||||
pinnedTooltipElement,
|
||||
tooltipPortalRoot,
|
||||
customLegend,
|
||||
legendLabels,
|
||||
contentFooter,
|
||||
'data-testid': testId,
|
||||
}: ChartWrapperProps): JSX.Element {
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
@@ -61,6 +64,10 @@ export default function ChartWrapper({
|
||||
if (!showLegend) {
|
||||
return null;
|
||||
}
|
||||
// Charts whose legend does not list uPlot series supply their own.
|
||||
if (customLegend) {
|
||||
return customLegend(averageLegendWidth);
|
||||
}
|
||||
return (
|
||||
<UPlotLegend
|
||||
config={config}
|
||||
@@ -69,7 +76,7 @@ export default function ChartWrapper({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[config, legendConfig.position, showLegend],
|
||||
[config, legendConfig.position, showLegend, customLegend],
|
||||
);
|
||||
|
||||
const renderTooltipCallback = useCallback(
|
||||
@@ -100,6 +107,8 @@ export default function ChartWrapper({
|
||||
containerHeight={containerHeight}
|
||||
legendConfig={legendConfig}
|
||||
legendComponent={legendComponent}
|
||||
seriesLabels={legendLabels}
|
||||
contentFooter={contentFooter}
|
||||
layoutChildren={layoutChildren}
|
||||
>
|
||||
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
|
||||
|
||||
296
frontend/src/lib/visualization/charts/Heatmap/Heatmap.tsx
Normal file
296
frontend/src/lib/visualization/charts/Heatmap/Heatmap.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import ColorBar from 'lib/uPlotV2/components/ColorBar/ColorBar';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import HeatmapTooltip from 'lib/uPlotV2/components/Tooltip/components/HeatmapTooltip/HeatmapTooltip';
|
||||
import {
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
createHeatmapColorResolver,
|
||||
DEFAULT_HEATMAP_COLORS,
|
||||
resolveCountDomain,
|
||||
resolveExtremeColor,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
resolveGroupPeaks,
|
||||
resolveHeatmapGrid,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapCell,
|
||||
HeatmapColorMode,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { HeatmapChartProps } from 'lib/visualization/charts/types';
|
||||
import { useHeatmapGroupLegend } from './useHeatmapGroupLegend';
|
||||
import { buildHeatmapConfig, prepareHeatmapChartData } from './utils';
|
||||
|
||||
/** Vertical space the colour bar takes out of the container. */
|
||||
const COLOR_BAR_HEIGHT = 28;
|
||||
|
||||
/**
|
||||
* Columns are time slices, rows are bucket ranges, cell colour is the observation
|
||||
* count — so a distribution can be watched changing shape instead of collapsing to
|
||||
* percentile lines. Drawn on canvas (see `createHeatmapHooks`): a 40 × 240 grid is
|
||||
* ~9,600 cells, far past what per-cell DOM carries.
|
||||
*/
|
||||
export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
const {
|
||||
id,
|
||||
buckets,
|
||||
step,
|
||||
series,
|
||||
width,
|
||||
height,
|
||||
isDarkMode,
|
||||
axisScale = HeatmapAxisScale.Log,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
showVisualMap = true,
|
||||
showLegend = true,
|
||||
legendPosition = LegendPosition.BOTTOM,
|
||||
dimOnHover = true,
|
||||
showTooltip = true,
|
||||
canPinTooltip = false,
|
||||
pinKey,
|
||||
seriesColor,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
onCellClick,
|
||||
renderTooltipFooter,
|
||||
tooltipPortalRoot,
|
||||
layoutChildren,
|
||||
'data-testid': testId,
|
||||
} = props;
|
||||
|
||||
const [hoveredCell, setHoveredCell] = useState<HeatmapCell | null>(null);
|
||||
const hoveredCellRef = useRef<HeatmapCell | null>(null);
|
||||
const onCellClickRef = useRef(onCellClick);
|
||||
onCellClickRef.current = onCellClick;
|
||||
|
||||
const groups = useMemo(() => series.map((entry) => entry.label), [series]);
|
||||
|
||||
// One series has nothing to choose between.
|
||||
const hasGroupLegend = showLegend && groups.length > 1;
|
||||
|
||||
const colors = useMemo(
|
||||
() => ({ ...DEFAULT_HEATMAP_COLORS, ...props.colors }),
|
||||
[props.colors],
|
||||
);
|
||||
|
||||
// The opacity fill no longer follows a group colour: with several groups enabled
|
||||
// at once there is no single one to follow.
|
||||
const resolvedSeriesColor = seriesColor ?? DEFAULT_HEATMAP_COLORS.fill;
|
||||
|
||||
// Opacity mode keeps the solid fill; a partially transparent marker is hard to
|
||||
// read against the panel.
|
||||
const extremeColor = resolveExtremeColor({
|
||||
options: colors,
|
||||
isDarkMode,
|
||||
seriesColor: resolvedSeriesColor,
|
||||
});
|
||||
|
||||
const { visibleGroups, focusedSeriesIndex, onLegendAction } =
|
||||
useHeatmapGroupLegend({ groups });
|
||||
|
||||
const grid = useMemo(
|
||||
() => resolveHeatmapGrid({ buckets, step, series, visibleGroups }),
|
||||
[buckets, step, series, visibleGroups],
|
||||
);
|
||||
|
||||
const yAxis = useMemo(
|
||||
() => resolveHeatmapYAxis(grid.bounds, axisScale),
|
||||
[grid.bounds, axisScale],
|
||||
);
|
||||
|
||||
const hasGrid = yAxis.rows.length > 0 && grid.timestamps.length > 0;
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
hasGrid
|
||||
? prepareHeatmapChartData(grid, yAxis.rows.length)
|
||||
: ([[]] as unknown as ReturnType<typeof prepareHeatmapChartData>),
|
||||
[grid, yAxis.rows.length, hasGrid],
|
||||
);
|
||||
|
||||
const colorResolver = useMemo(
|
||||
() =>
|
||||
createHeatmapColorResolver({
|
||||
options: colors,
|
||||
domain: resolveCountDomain(colors, grid.counts),
|
||||
isDarkMode,
|
||||
seriesColor: resolvedSeriesColor,
|
||||
}),
|
||||
[colors, grid.counts, isDarkMode, resolvedSeriesColor],
|
||||
);
|
||||
|
||||
// Stable: the renderer captures it at config-build time, so a new identity would
|
||||
// recreate the plot on every hover.
|
||||
const handleHoverChange = useCallback((cell: HeatmapCell | null): void => {
|
||||
hoveredCellRef.current = cell;
|
||||
setHoveredCell(cell);
|
||||
}, []);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildHeatmapConfig({
|
||||
id,
|
||||
grid,
|
||||
yAxis,
|
||||
colors,
|
||||
isDarkMode,
|
||||
seriesColor: resolvedSeriesColor,
|
||||
dimOnHover,
|
||||
onHoverChange: handleHoverChange,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
}),
|
||||
[
|
||||
id,
|
||||
grid,
|
||||
yAxis,
|
||||
colors,
|
||||
isDarkMode,
|
||||
resolvedSeriesColor,
|
||||
dimOnHover,
|
||||
handleHoverChange,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
],
|
||||
);
|
||||
|
||||
// Each marker takes the ramp colour for where that group's densest cell falls on
|
||||
// the colour bar, so a swatch reads against the same scale as the grid.
|
||||
const groupPeaks = useMemo(() => resolveGroupPeaks(series), [series]);
|
||||
const isPaletteMode = colors.mode === HeatmapColorMode.Palette;
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
groups.map((group, index) => ({
|
||||
// +1 mirrors uPlot's 1-based data series, so the shared legend's index
|
||||
// handling is identical across charts.
|
||||
seriesIndex: index + 1,
|
||||
label: group,
|
||||
color: isPaletteMode
|
||||
? (colorResolver.colorFor(groupPeaks.get(group) ?? 0) ?? extremeColor)
|
||||
: extremeColor,
|
||||
show: visibleGroups.includes(group),
|
||||
})),
|
||||
[
|
||||
groups,
|
||||
visibleGroups,
|
||||
isPaletteMode,
|
||||
colorResolver,
|
||||
groupPeaks,
|
||||
extremeColor,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => (
|
||||
<HeatmapTooltip
|
||||
{...args}
|
||||
id={id}
|
||||
yAxis={yAxis}
|
||||
step={grid.step}
|
||||
series={series}
|
||||
visibleGroups={visibleGroups}
|
||||
groupColor={extremeColor}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
timezone={timezone}
|
||||
canPinTooltip={canPinTooltip}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
/>
|
||||
),
|
||||
[
|
||||
id,
|
||||
yAxis,
|
||||
grid.step,
|
||||
series,
|
||||
visibleGroups,
|
||||
extremeColor,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
canPinTooltip,
|
||||
renderTooltipFooter,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClick = useCallback((clickData: ChartClickData): void => {
|
||||
if (hoveredCellRef.current) {
|
||||
onCellClickRef.current?.(hoveredCellRef.current, clickData);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const groupLegend = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => (
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={legendPosition}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
),
|
||||
[legendItems, legendPosition, focusedSeriesIndex, onLegendAction],
|
||||
);
|
||||
|
||||
const visualMap = useMemo(() => {
|
||||
if (!showVisualMap || !hasGrid) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ColorBar
|
||||
label="count"
|
||||
ramp={colorResolver.ramp}
|
||||
minLabel={colorResolver.domain.min.toLocaleString()}
|
||||
maxLabel={colorResolver.domain.max.toLocaleString()}
|
||||
markerPosition={colorResolver.positionOf(hoveredCell?.count ?? null)}
|
||||
/>
|
||||
);
|
||||
}, [showVisualMap, hasGrid, colorResolver, hoveredCell]);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
config={config}
|
||||
data={data}
|
||||
width={width}
|
||||
height={
|
||||
showVisualMap && hasGrid ? Math.max(0, height - COLOR_BAR_HEIGHT) : height
|
||||
}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
showLegend={hasGroupLegend}
|
||||
customLegend={groupLegend}
|
||||
legendLabels={groups}
|
||||
showTooltip={showTooltip}
|
||||
canPinTooltip={canPinTooltip}
|
||||
pinKey={pinKey}
|
||||
onClick={onCellClick ? handleClick : undefined}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
customTooltip={renderTooltip}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
tooltipPortalRoot={tooltipPortalRoot}
|
||||
contentFooter={visualMap}
|
||||
layoutChildren={layoutChildren}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import type React from 'react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import {
|
||||
createHeatmapColorResolver,
|
||||
DEFAULT_HEATMAP_COLORS,
|
||||
resolveCountDomain,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import { resolveHeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
|
||||
import {
|
||||
HeatmapColorMode,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
import Heatmap from '../Heatmap';
|
||||
|
||||
// The shared Legend virtualises its items; render them all so they are queryable.
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<div>
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex}>{itemContent(index, item)}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const BUCKETS = [128, 256, 1024];
|
||||
const STEP = 60;
|
||||
|
||||
/** Two groups whose counts sum to a peak of 1,204 in the combined view. */
|
||||
const SERIES: HeatmapSeries[] = [
|
||||
{
|
||||
label: 'service.name=cart',
|
||||
points: [
|
||||
{ timestamp: 1000, counts: [1, 4, 7, 10] },
|
||||
{ timestamp: 1060, counts: [2, null, 8, 11] },
|
||||
{ timestamp: 1120, counts: [3, 6, 9, 1200] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'service.name=checkout',
|
||||
points: [{ timestamp: 1120, counts: [0, 0, 0, 4] }],
|
||||
},
|
||||
];
|
||||
|
||||
function renderHeatmap(
|
||||
props: Partial<React.ComponentProps<typeof Heatmap>> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<Heatmap
|
||||
id="panel-1"
|
||||
buckets={BUCKETS}
|
||||
step={STEP}
|
||||
series={SERIES}
|
||||
width={800}
|
||||
height={400}
|
||||
isDarkMode
|
||||
data-testid="heatmap"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Heatmap', () => {
|
||||
it('renders the plot container', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(screen.getByTestId('heatmap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the colour bar with the resolved count domain', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(screen.getByTestId('color-bar')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,204')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('puts the colour bar against the plot, with the legend after it', () => {
|
||||
renderHeatmap();
|
||||
|
||||
const bar = screen.getByTestId('color-bar');
|
||||
const legend = screen
|
||||
.getByText('service.name=cart')
|
||||
.closest('[data-legend-item-id]');
|
||||
// The bar is the scale key for the grid, so it reads before the controls.
|
||||
expect(
|
||||
bar.compareDocumentPosition(legend as Node) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the colour bar inside the chart column, not below the legend', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('color-bar').closest('.chart-layout__content'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides the colour bar when the visual map is off', () => {
|
||||
renderHeatmap({ showVisualMap: false });
|
||||
|
||||
expect(screen.queryByTestId('color-bar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels the colour bar with an explicit clamp instead of the data range', () => {
|
||||
renderHeatmap({ colors: { minCount: 5, maxCount: 500 } });
|
||||
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
expect(screen.getByText('500')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the no-data state when the metric has no buckets', () => {
|
||||
renderHeatmap({ buckets: [] });
|
||||
|
||||
expect(screen.getByText('No Data')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('color-bar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the no-data state when no columns came back', () => {
|
||||
renderHeatmap({ series: [] });
|
||||
|
||||
expect(screen.getByText('No Data')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Heatmap group legend', () => {
|
||||
const CART = 'service.name=cart';
|
||||
const CHECKOUT = 'service.name=checkout';
|
||||
|
||||
function legendItem(label: string): HTMLElement {
|
||||
return screen.getByRole('switch', { name: label });
|
||||
}
|
||||
|
||||
function marker(label: string): HTMLElement {
|
||||
const element = legendItem(label).querySelector<HTMLElement>(
|
||||
'[data-is-legend-marker]',
|
||||
);
|
||||
if (!element) {
|
||||
throw new Error(`no marker for ${label}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
it('lists the groups, with no combined-view entry', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(screen.getByText(CART)).toBeInTheDocument();
|
||||
expect(screen.getByText(CHECKOUT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/all groups/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables every group to begin with', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('isolates a group when its label is clicked', async () => {
|
||||
renderHeatmap();
|
||||
|
||||
await userEvent.click(screen.getByText(CART));
|
||||
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('restores every group when the isolated label is clicked again', async () => {
|
||||
renderHeatmap();
|
||||
|
||||
await userEvent.click(screen.getByText(CART));
|
||||
await userEvent.click(screen.getByText(CART));
|
||||
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('excludes just one group when its marker is clicked', async () => {
|
||||
renderHeatmap();
|
||||
|
||||
await userEvent.click(marker(CHECKOUT));
|
||||
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
/** The ramp the cells and colour bar are drawn from, for the default options. */
|
||||
function activeRamp(): string[] {
|
||||
const grid = resolveHeatmapGrid({
|
||||
buckets: BUCKETS,
|
||||
step: STEP,
|
||||
series: SERIES,
|
||||
});
|
||||
return createHeatmapColorResolver({
|
||||
options: DEFAULT_HEATMAP_COLORS,
|
||||
domain: resolveCountDomain(DEFAULT_HEATMAP_COLORS, grid.counts),
|
||||
isDarkMode: true,
|
||||
seriesColor: DEFAULT_HEATMAP_COLORS.fill,
|
||||
}).ramp.map((color) => color.toLowerCase());
|
||||
}
|
||||
|
||||
it('places each marker where its group sits on the colour bar', () => {
|
||||
renderHeatmap();
|
||||
const ramp = activeRamp();
|
||||
|
||||
// cart peaks at 1200, checkout at 4, so cart sits further along the ramp.
|
||||
// The DOM lowercases hex; the ramp is built uppercase.
|
||||
const cart = ramp.indexOf(marker(CART).style.borderColor.toLowerCase());
|
||||
const checkout = ramp.indexOf(
|
||||
marker(CHECKOUT).style.borderColor.toLowerCase(),
|
||||
);
|
||||
|
||||
expect(cart).toBeGreaterThan(-1);
|
||||
expect(checkout).toBeGreaterThan(-1);
|
||||
expect(cart).toBeGreaterThan(checkout);
|
||||
});
|
||||
|
||||
it('gives every marker the solid fill in opacity mode', () => {
|
||||
renderHeatmap({
|
||||
colors: { mode: HeatmapColorMode.Opacity, fill: '#e5484d' },
|
||||
});
|
||||
|
||||
// A partially transparent marker is hard to read against the panel.
|
||||
expect(marker(CART).style.borderColor).toBe(
|
||||
marker(CHECKOUT).style.borderColor,
|
||||
);
|
||||
expect(marker(CART).style.borderColor).not.toBe('');
|
||||
});
|
||||
|
||||
it('hides the legend when there is only one group to choose from', () => {
|
||||
renderHeatmap({ series: [SERIES[0]] });
|
||||
|
||||
expect(screen.queryByText(CART)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the legend when asked', () => {
|
||||
renderHeatmap({ showLegend: false });
|
||||
|
||||
expect(screen.queryByText(CART)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { LegendAction } from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { useHeatmapGroupLegend } from '../useHeatmapGroupLegend';
|
||||
|
||||
const GROUPS = ['cart', 'checkout', 'payments'];
|
||||
|
||||
function render(
|
||||
groups: string[] = GROUPS,
|
||||
): ReturnType<
|
||||
typeof renderHook<ReturnType<typeof useHeatmapGroupLegend>, unknown>
|
||||
> {
|
||||
return renderHook(() => useHeatmapGroupLegend({ groups }));
|
||||
}
|
||||
|
||||
describe('useHeatmapGroupLegend', () => {
|
||||
it('enables every group to begin with', () => {
|
||||
const { result } = render();
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('isolates a group when the legend asks to show only it', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
|
||||
});
|
||||
|
||||
it('restores every group when the legend asks to show all', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 2,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onLegendAction({ type: LegendAction.SHOW_ALL }));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('moves the isolation to the group named last', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['payments']);
|
||||
});
|
||||
|
||||
it('excludes just one group when it is toggled off', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['cart', 'payments']);
|
||||
});
|
||||
|
||||
it('re-includes a group when it is toggled again', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 2,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('excludes more than one group', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
|
||||
});
|
||||
|
||||
it('allows every group to be excluded, as the other legends do', () => {
|
||||
const { result } = render();
|
||||
|
||||
GROUPS.forEach((_, index) =>
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: index + 1,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('ignores an action naming an entry that is not there', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 99,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('forgets a hidden group that left the result', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ groups }) => useHeatmapGroupLegend({ groups }),
|
||||
{ initialProps: { groups: GROUPS } },
|
||||
);
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 3,
|
||||
}),
|
||||
);
|
||||
rerender({ groups: ['cart', 'checkout'] });
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['cart', 'checkout']);
|
||||
});
|
||||
|
||||
it('tracks the hovered entry for the legend"s focus highlight', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.HOVER,
|
||||
seriesIndex: 2,
|
||||
}),
|
||||
);
|
||||
expect(result.current.focusedSeriesIndex).toBe(2);
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.HOVER,
|
||||
seriesIndex: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { DEFAULT_HEATMAP_COLORS } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapGrid,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { buildHeatmapConfig, prepareHeatmapChartData } from '../utils';
|
||||
|
||||
const GRID: HeatmapGrid = {
|
||||
bounds: [128, 256, 1024],
|
||||
timestamps: [1000, 1060, 1120],
|
||||
step: 60,
|
||||
counts: [
|
||||
[1, 2, 3],
|
||||
[4, null, 6],
|
||||
[7, 8, 9],
|
||||
[0, 0, 0],
|
||||
],
|
||||
};
|
||||
|
||||
const Y_AXIS = resolveHeatmapYAxis(GRID.bounds, HeatmapAxisScale.Log);
|
||||
|
||||
/** Tall enough that no tick needs thinning. */
|
||||
const TALL_PLOT = { bbox: { height: 1000 } } as uPlot;
|
||||
|
||||
function readSplits(
|
||||
config: ReturnType<typeof buildHeatmapConfig>,
|
||||
plot: uPlot,
|
||||
): number[] {
|
||||
const [, yAxisConfig] = config.getConfig().axes ?? [];
|
||||
return (yAxisConfig.splits as (self: uPlot) => number[])(plot);
|
||||
}
|
||||
|
||||
function readLabels(
|
||||
config: ReturnType<typeof buildHeatmapConfig>,
|
||||
splits: number[],
|
||||
): string[] {
|
||||
const [, yAxisConfig] = config.getConfig().axes ?? [];
|
||||
return (yAxisConfig.values as (u: uPlot, splits: number[]) => string[])(
|
||||
{} as uPlot,
|
||||
splits,
|
||||
);
|
||||
}
|
||||
|
||||
function readRange(scale?: uPlot.Scale): [number, number] {
|
||||
const range = scale?.range as (
|
||||
u: uPlot,
|
||||
min: number,
|
||||
max: number,
|
||||
) => [number, number];
|
||||
return range({} as uPlot, 0, 0);
|
||||
}
|
||||
|
||||
function buildConfig(
|
||||
overrides: Partial<Parameters<typeof buildHeatmapConfig>[0]> = {},
|
||||
): ReturnType<typeof buildHeatmapConfig> {
|
||||
return buildHeatmapConfig({
|
||||
id: 'panel-1',
|
||||
grid: GRID,
|
||||
yAxis: Y_AXIS,
|
||||
colors: DEFAULT_HEATMAP_COLORS,
|
||||
isDarkMode: true,
|
||||
seriesColor: '#4e74f8',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('prepareHeatmapChartData', () => {
|
||||
it('puts timestamps first and one series per bucket row', () => {
|
||||
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
|
||||
|
||||
expect(data).toHaveLength(Y_AXIS.rows.length + 1);
|
||||
expect(data[0]).toStrictEqual(GRID.timestamps);
|
||||
expect(data[1]).toStrictEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('preserves null cells rather than zeroing them', () => {
|
||||
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
|
||||
|
||||
expect(data[2]).toStrictEqual([4, null, 6]);
|
||||
});
|
||||
|
||||
it('pads short rows so every uPlot data array is the same length', () => {
|
||||
const data = prepareHeatmapChartData(
|
||||
{ ...GRID, counts: [[1]] },
|
||||
Y_AXIS.rows.length,
|
||||
);
|
||||
|
||||
expect(data[1]).toStrictEqual([1, null, null]);
|
||||
});
|
||||
|
||||
it('pads missing rows up to the resolved row count', () => {
|
||||
const data = prepareHeatmapChartData({ ...GRID, counts: [] }, 2);
|
||||
|
||||
expect(data).toHaveLength(3);
|
||||
expect(data[2]).toStrictEqual([null, null, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHeatmapConfig', () => {
|
||||
it('registers one series per bucket row, plus uPlot"s timestamp series', () => {
|
||||
const config = buildHeatmapConfig({
|
||||
id: 'panel-1',
|
||||
grid: GRID,
|
||||
yAxis: Y_AXIS,
|
||||
colors: DEFAULT_HEATMAP_COLORS,
|
||||
isDarkMode: true,
|
||||
seriesColor: '#4e74f8',
|
||||
}).getConfig();
|
||||
|
||||
expect(config.series).toHaveLength(Y_AXIS.rows.length + 1);
|
||||
});
|
||||
|
||||
it('draws no paths or points per series — the renderer paints the cells', () => {
|
||||
const [, firstRow] = buildConfig().getConfig().series ?? [];
|
||||
|
||||
expect((firstRow as uPlot.Series).paths?.({} as uPlot, 1, 0, 1)).toBeNull();
|
||||
expect((firstRow as uPlot.Series).points?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('labels series by bucket range, including the open-ended rows', () => {
|
||||
const labels = (buildConfig().getConfig().series ?? [])
|
||||
.slice(1)
|
||||
.map((series) => series.label);
|
||||
|
||||
expect(labels[0]).toContain('≤');
|
||||
expect(labels[labels.length - 1]).toContain('>');
|
||||
});
|
||||
|
||||
it('spans the x scale to the end of the last column, not its start', () => {
|
||||
const { x } = buildConfig().getConfig().scales ?? {};
|
||||
|
||||
expect(readRange(x)).toStrictEqual([1000, 1180]);
|
||||
});
|
||||
|
||||
it('prefers the query window over the grid extent', () => {
|
||||
const { x } =
|
||||
buildConfig({ minTimeScale: 900, maxTimeScale: 1500 }).getConfig().scales ??
|
||||
{};
|
||||
|
||||
expect(readRange(x)).toStrictEqual([900, 1500]);
|
||||
});
|
||||
|
||||
it('pins the y scale to the bucket axis instead of auto-ranging on counts', () => {
|
||||
const { y } = buildConfig().getConfig().scales ?? {};
|
||||
|
||||
expect(y?.auto).toBe(false);
|
||||
expect(readRange(y)).toStrictEqual([Y_AXIS.min, Y_AXIS.max]);
|
||||
});
|
||||
|
||||
it('puts a y tick on every bucket boundary plus the overflow row"s upper edge', () => {
|
||||
const splits = readSplits(buildConfig(), TALL_PLOT);
|
||||
|
||||
expect(splits).toStrictEqual([...Y_AXIS.splits, Y_AXIS.overflowSplit]);
|
||||
});
|
||||
|
||||
it('labels the overflow edge as infinite and the rest by bucket value', () => {
|
||||
const config = buildConfig();
|
||||
const labels = readLabels(config, readSplits(config, TALL_PLOT));
|
||||
|
||||
expect(labels[0]).toBe('128');
|
||||
expect(labels[labels.length - 1]).toBe('∞');
|
||||
});
|
||||
|
||||
it('thins the tick set when the panel is too short to label every boundary', () => {
|
||||
const config = buildConfig();
|
||||
const splits = readSplits(config, { bbox: { height: 40 } } as uPlot);
|
||||
|
||||
expect(splits.length).toBeLessThan(Y_AXIS.splits.length + 1);
|
||||
// The infinite edge is the one label that must never be dropped.
|
||||
expect(readLabels(config, splits).at(-1)).toBe('∞');
|
||||
});
|
||||
|
||||
it('disables uPlot cursor focus and points, which cannot read a colour axis', () => {
|
||||
const config = buildConfig().getConfig();
|
||||
|
||||
expect(config.cursor?.focus?.prox).toBe(-1);
|
||||
expect(config.cursor?.points?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps focus alpha at 1 so focusing a row does not force a full redraw', () => {
|
||||
expect(buildConfig().getConfig().focus?.alpha).toBe(1);
|
||||
});
|
||||
|
||||
it('registers the renderer hooks', () => {
|
||||
const { hooks } = buildConfig().getConfig();
|
||||
|
||||
expect(hooks?.init).toHaveLength(1);
|
||||
expect(hooks?.draw).toHaveLength(1);
|
||||
expect(hooks?.setCursor).toHaveLength(1);
|
||||
expect(hooks?.destroy).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { LegendAction, OnLegendAction } from 'lib/uPlotV2/components/types';
|
||||
|
||||
export interface UseHeatmapGroupLegendResult {
|
||||
/** Groups currently enabled. The grid sums exactly these. */
|
||||
visibleGroups: string[];
|
||||
focusedSeriesIndex: number | null;
|
||||
onLegendAction: OnLegendAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group visibility for the heatmap legend, matching every other legend in the
|
||||
* product: the shared Legend decides what a click means and sends the action;
|
||||
* this only applies it. Everything is enabled to begin with.
|
||||
*
|
||||
* Counts are additive, so whatever is enabled is summed client-side and needs no
|
||||
* extra request.
|
||||
*
|
||||
* Visibility only. Marker colour is resolved by the caller, which owns the colour
|
||||
* ramp — and that ramp depends on which groups this hook has enabled.
|
||||
*/
|
||||
export function useHeatmapGroupLegend({
|
||||
groups,
|
||||
}: {
|
||||
groups: string[];
|
||||
}): UseHeatmapGroupLegendResult {
|
||||
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
|
||||
const [focusedSeriesIndex, setFocusedSeriesIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const visibleGroups = useMemo(
|
||||
() => groups.filter((group) => !hidden.has(group)),
|
||||
[groups, hidden],
|
||||
);
|
||||
|
||||
const onLegendAction = useCallback<OnLegendAction>(
|
||||
(payload): void => {
|
||||
// Legend items are numbered from 1, mirroring uPlot's 1-based data series.
|
||||
const groupAt = (seriesIndex: number): string | undefined =>
|
||||
groups[seriesIndex - 1];
|
||||
|
||||
switch (payload.type) {
|
||||
case LegendAction.TOGGLE: {
|
||||
const group = groupAt(payload.seriesIndex);
|
||||
if (group === undefined) {
|
||||
return;
|
||||
}
|
||||
setHidden((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(group)) {
|
||||
next.delete(group);
|
||||
} else {
|
||||
next.add(group);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case LegendAction.SHOW_ONLY: {
|
||||
const group = groupAt(payload.seriesIndex);
|
||||
if (group === undefined) {
|
||||
return;
|
||||
}
|
||||
setHidden(new Set(groups.filter((entry) => entry !== group)));
|
||||
break;
|
||||
}
|
||||
case LegendAction.SHOW_ALL:
|
||||
setHidden(new Set());
|
||||
break;
|
||||
case LegendAction.HOVER:
|
||||
setFocusedSeriesIndex(payload.seriesIndex);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[groups],
|
||||
);
|
||||
|
||||
return {
|
||||
visibleGroups,
|
||||
focusedSeriesIndex,
|
||||
onLegendAction,
|
||||
};
|
||||
}
|
||||
193
frontend/src/lib/visualization/charts/Heatmap/utils.ts
Normal file
193
frontend/src/lib/visualization/charts/Heatmap/utils.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import {
|
||||
decimateAxisSplits,
|
||||
formatRowLabel,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
createHeatmapHooks,
|
||||
HeatmapRenderOptions,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin';
|
||||
import { HeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
/** Minimum gap between y tick labels, in CSS pixels. */
|
||||
const MIN_Y_TICK_GAP_PX = 18;
|
||||
|
||||
/** Label for the edge above the overflow row. */
|
||||
const OVERFLOW_AXIS_LABEL = '∞';
|
||||
|
||||
/**
|
||||
* Flattens the grid into `[timestamps, ...rows]`, one series per bucket row so
|
||||
* `setData` handles refetches. The series draw nothing; the renderer paints cells.
|
||||
*
|
||||
* Rows are padded to `rowCount` — which can differ from `bounds.length + 1` when
|
||||
* the response carried duplicate boundaries — since uPlot requires equal lengths.
|
||||
*/
|
||||
export function prepareHeatmapChartData(
|
||||
grid: HeatmapGrid,
|
||||
rowCount: number,
|
||||
): uPlot.AlignedData {
|
||||
const columnCount = grid.timestamps.length;
|
||||
const rows = Array.from({ length: rowCount }, (_, row) => {
|
||||
const counts = grid.counts[row] ?? [];
|
||||
return Array.from({ length: columnCount }, (_, column) =>
|
||||
counts[column] === undefined ? null : counts[column],
|
||||
);
|
||||
});
|
||||
|
||||
return [grid.timestamps, ...rows] as unknown as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
export interface BuildHeatmapConfigArgs extends Omit<
|
||||
HeatmapRenderOptions,
|
||||
'step'
|
||||
> {
|
||||
id: string;
|
||||
grid: HeatmapGrid;
|
||||
/** Unit of the bucket boundaries; counts are never formatted with it. */
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
timezone?: Timezone;
|
||||
/** Query window, in seconds. Falls back to the grid's own extent. */
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
}
|
||||
|
||||
export function buildHeatmapConfig({
|
||||
id,
|
||||
grid,
|
||||
yAxis,
|
||||
colors,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
dimOnHover,
|
||||
onHoverChange,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
}: BuildHeatmapConfigArgs): UPlotConfigBuilder {
|
||||
const tzDate = timezone
|
||||
? (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value)
|
||||
: undefined;
|
||||
|
||||
const builder = new UPlotConfigBuilder({ id, onDragSelect, tzDate });
|
||||
|
||||
// uPlot's focus picks the series closest in value space, meaningless when the
|
||||
// value is a colour; the renderer focuses the hovered row itself. alpha 1 keeps
|
||||
// that call off uPlot's full-redraw path.
|
||||
builder.setFocus({ alpha: 1 });
|
||||
builder.setCursor({ focus: { prox: -1 }, points: { show: false } });
|
||||
|
||||
const formatBucketValue = (value: number): string =>
|
||||
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
|
||||
|
||||
const lastTimestamp = grid.timestamps[grid.timestamps.length - 1] ?? 0;
|
||||
const xRange: [number, number] = [
|
||||
minTimeScale ?? grid.timestamps[0] ?? 0,
|
||||
maxTimeScale ?? lastTimestamp + grid.step,
|
||||
];
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
range: (): [number, number] => xRange,
|
||||
});
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
auto: false,
|
||||
range: (): [number, number] => [yAxis.min, yAxis.max],
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
values: uPlotXAxisValuesFormat as uPlot.Axis.Values,
|
||||
});
|
||||
|
||||
// Ticks sit on row edges, so the overflow row is the band between the last
|
||||
// boundary and `∞`. A centre label would sit half a row from the boundary tick
|
||||
// and collide with it.
|
||||
const overflowRow = yAxis.rows[yAxis.rows.length - 1];
|
||||
const hasOverflowTick =
|
||||
yAxis.overflowSplit !== null && overflowRow?.isOverflow === true;
|
||||
const axisSplits = hasOverflowTick
|
||||
? [...yAxis.splits, yAxis.overflowSplit as number]
|
||||
: yAxis.splits;
|
||||
|
||||
// From the boundaries themselves, not by inverting the transform:
|
||||
// 10 ** Math.log10(128) is 127.999…, which formats as "127.99".
|
||||
const splitLabels = new Map<number, string>();
|
||||
yAxis.splits.forEach((split, index) => {
|
||||
splitLabels.set(split, formatBucketValue(yAxis.rows[index].upper));
|
||||
});
|
||||
if (hasOverflowTick) {
|
||||
splitLabels.set(yAxis.overflowSplit as number, OVERFLOW_AXIS_LABEL);
|
||||
}
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
// Thinned to whatever fits: a histogram can carry more boundaries than the
|
||||
// panel has room to label.
|
||||
splits: (self): number[] =>
|
||||
decimateAxisSplits({
|
||||
splits: axisSplits,
|
||||
min: yAxis.min,
|
||||
max: yAxis.max,
|
||||
plotHeight: self.bbox.height / uPlot.pxRatio,
|
||||
minGapPx: MIN_Y_TICK_GAP_PX,
|
||||
}),
|
||||
values: (_, splits): string[] =>
|
||||
splits.map(
|
||||
(split) =>
|
||||
splitLabels.get(split) ?? formatBucketValue(yAxis.toBucketValue(split)),
|
||||
),
|
||||
});
|
||||
|
||||
yAxis.rows.forEach((row) => {
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
// Nothing is stroked per series; the draw hook paints the grid.
|
||||
drawStyle: DrawStyle.Line,
|
||||
pathBuilder: (): null => null,
|
||||
showPoints: false,
|
||||
spanGaps: false,
|
||||
label: formatRowLabel(row, formatBucketValue),
|
||||
colorMapping: {},
|
||||
isDarkMode,
|
||||
});
|
||||
});
|
||||
|
||||
const hooks = createHeatmapHooks({
|
||||
yAxis,
|
||||
step: grid.step,
|
||||
colors,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
dimOnHover,
|
||||
onHoverChange,
|
||||
});
|
||||
|
||||
// Order matters — see the HeatmapHooks doc comment.
|
||||
builder.addHook('init', hooks.init);
|
||||
builder.addHook('draw', hooks.draw);
|
||||
builder.addHook('setCursor', hooks.setCursor);
|
||||
builder.addHook('destroy', hooks.destroy);
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type {
|
||||
HeatmapAxisScale,
|
||||
HeatmapCell,
|
||||
HeatmapColorOptions,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
@@ -33,6 +39,13 @@ interface BaseChartProps {
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
tooltipPortalRoot?: HTMLElement | null;
|
||||
/** Replaces the config-driven legend, for charts whose legend lists something
|
||||
* other than uPlot series — heatmap groups, where the series are bucket rows. */
|
||||
customLegend?: (averageLegendWidth: number) => React.ReactNode;
|
||||
/** Measured against for the chart/legend split. Pair with `customLegend`. */
|
||||
legendLabels?: string[];
|
||||
/** Rendered under the plot but above the legend, inside the chart column. */
|
||||
contentFooter?: React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
interface UPlotBasedChartProps {
|
||||
@@ -74,6 +87,60 @@ export interface HistogramChartProps extends ChartWrapperProps {
|
||||
isQueriesMerged?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data arrives as the query response carries it — bucket bounds plus one series per
|
||||
* group — and the chart pivots and sums it, so no caller has to get the transpose
|
||||
* or the combined view right. It builds its own `UPlotConfigBuilder` too, since the
|
||||
* y axis *is* the bucket axis and `buckets` fully determines it.
|
||||
*
|
||||
* `buckets`, `series` and `colors` must be referentially stable: a new identity
|
||||
* rebuilds the config, which recreates the plot.
|
||||
*/
|
||||
export interface HeatmapChartProps {
|
||||
id: string;
|
||||
/** Ascending. N boundaries describe N+1 rows. */
|
||||
buckets: number[];
|
||||
/** The *effective* step the server used (`meta.stepIntervals[queryName]`), not
|
||||
* the requested one. Cannot be inferred: the last column has no successor. */
|
||||
step: number;
|
||||
/** One entry per group; a query without grouping yields one series. */
|
||||
series: HeatmapSeries[];
|
||||
width: number;
|
||||
height: number;
|
||||
isDarkMode: boolean;
|
||||
/** Overrides on top of `DEFAULT_HEATMAP_COLORS`. */
|
||||
colors?: Partial<HeatmapColorOptions>;
|
||||
/** Default log. */
|
||||
axisScale?: HeatmapAxisScale;
|
||||
/** Unit of the bucket boundaries; counts are always plain numbers. */
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
timezone?: Timezone;
|
||||
/** Colour bar below the grid. Default true. */
|
||||
showVisualMap?: boolean;
|
||||
/** Default true; hidden anyway when there is only one group. Every group starts
|
||||
* enabled — the label isolates one, the marker excludes one. */
|
||||
showLegend?: boolean;
|
||||
legendPosition?: LegendPosition;
|
||||
/** Default true. */
|
||||
dimOnHover?: boolean;
|
||||
showTooltip?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
pinKey?: string;
|
||||
/** Overrides the opacity-mode fill, which otherwise follows the selected
|
||||
* group's legend colour so the grid matches the swatch that was clicked. */
|
||||
seriesColor?: string;
|
||||
/** Query window, in seconds. Falls back to the data's own extent. */
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
onCellClick?: (cell: HeatmapCell, clickData: ChartClickData) => void;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
tooltipPortalRoot?: HTMLElement | null;
|
||||
layoutChildren?: React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One resolved pie/donut slice: a display label, its (already parsed) positive
|
||||
* numeric value, and the colour used for the arc + legend swatch.
|
||||
|
||||
@@ -16,20 +16,31 @@ export interface ChartLayoutProps {
|
||||
averageLegendWidth: number;
|
||||
}) => React.ReactNode;
|
||||
layoutChildren?: React.ReactNode;
|
||||
/**
|
||||
* Rendered directly under the plot, inside the chart column — so it stays next to
|
||||
* the axis with the legend below it, and beside a RIGHT legend rather than under
|
||||
* it. `layoutChildren` sits below everything instead.
|
||||
*/
|
||||
contentFooter?: React.ReactNode;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
legendConfig: LegendConfig;
|
||||
config: UPlotConfigBuilder;
|
||||
/** Defaults to the chart's series labels. Pass them when the legend lists
|
||||
* something else, or the split is measured against the wrong text. */
|
||||
seriesLabels?: string[];
|
||||
}
|
||||
export default function ChartLayout({
|
||||
showLegend = true,
|
||||
legendComponent,
|
||||
children,
|
||||
layoutChildren,
|
||||
contentFooter,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
legendConfig,
|
||||
config,
|
||||
seriesLabels,
|
||||
}: ChartLayoutProps): JSX.Element {
|
||||
const chartDimensions = useMemo(
|
||||
() => {
|
||||
@@ -42,19 +53,20 @@ export default function ChartLayout({
|
||||
averageLegendWidth: MAX_LEGEND_WIDTH,
|
||||
};
|
||||
}
|
||||
const legendItemsMap = config.getLegendItems();
|
||||
const seriesLabels = Object.values(legendItemsMap)
|
||||
.map((item) => item.label)
|
||||
.filter((label): label is string => label !== undefined);
|
||||
const resolvedLabels =
|
||||
seriesLabels ??
|
||||
Object.values(config.getLegendItems())
|
||||
.map((item) => item.label)
|
||||
.filter((label): label is string => label !== undefined);
|
||||
return calculateChartDimensions({
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
legendConfig,
|
||||
seriesLabels,
|
||||
seriesLabels: resolvedLabels,
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[containerWidth, containerHeight, legendConfig, showLegend],
|
||||
[containerWidth, containerHeight, legendConfig, showLegend, seriesLabels],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -72,6 +84,7 @@ export default function ChartLayout({
|
||||
chartHeight: chartDimensions.height,
|
||||
averageLegendWidth: chartDimensions.averageLegendWidth,
|
||||
})}
|
||||
{contentFooter}
|
||||
</div>
|
||||
{showLegend && (
|
||||
<div
|
||||
|
||||
@@ -9,19 +9,17 @@ import { Atom, Terminal } from '@signozhq/icons';
|
||||
import { Tabs } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import PromQLIcon from 'assets/Dashboard/PromQl';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import ClickHouseQueryContainer from 'container/QueryBuilder/rawQueryEditors/ClickHouse';
|
||||
import PromQLQueryContainer from 'container/QueryBuilder/rawQueryEditors/PromQL';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
|
||||
import { isRawRequest } from '../../Panels/types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
|
||||
import { toPanelType } from '../../Panels/types/panelKind';
|
||||
|
||||
@@ -30,8 +28,6 @@ import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
interface PanelEditorQueryBuilderProps {
|
||||
/** The edited kind's definition — drives supported query types + field visibility. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** The panel's current signal; selects per-signal query-builder field rules. */
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
|
||||
isLoadingQueries: boolean;
|
||||
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
|
||||
@@ -51,7 +47,6 @@ interface PanelEditorQueryBuilderProps {
|
||||
*/
|
||||
function PanelEditorQueryBuilder({
|
||||
panelDefinition,
|
||||
signal,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
@@ -63,7 +58,7 @@ function PanelEditorQueryBuilder({
|
||||
const panelType = toPanelType(panelDefinition.kind);
|
||||
// Raw rows: the builder drops its aggregation controls, and with them the trace
|
||||
// operator that combines aggregated trace queries (V1 parity).
|
||||
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';
|
||||
const isRawQuery = isRawRequest(panelDefinition.queryCapabilities);
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -91,13 +86,6 @@ function PanelEditorQueryBuilder({
|
||||
[onStageRunQuery],
|
||||
);
|
||||
|
||||
// Per-kind query-builder field rules from the guard (e.g. List hides step interval
|
||||
// and having), passed to QueryBuilderV2 as its `filterConfigs`.
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
|
||||
() => mergeQueryBuilderFieldRule(panelDefinition.queryBuilderFields, signal),
|
||||
[panelDefinition.queryBuilderFields, signal],
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const { supportedQueryTypes } = panelDefinition;
|
||||
|
||||
@@ -109,11 +97,10 @@ function PanelEditorQueryBuilder({
|
||||
<div className="query-builder-v2-container">
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
filterConfigs={filterConfigs}
|
||||
showTraceOperator={!isListViewPanel}
|
||||
fieldsConfig={panelDefinition.queryBuilderFields}
|
||||
showTraceOperator={!isRawQuery}
|
||||
version="v3"
|
||||
isListViewPanel={isListViewPanel}
|
||||
queryComponents={{}}
|
||||
isRawQuery={isRawQuery}
|
||||
signalSourceChangeEnabled
|
||||
savePreviousQuery
|
||||
/>
|
||||
@@ -146,7 +133,7 @@ function PanelEditorQueryBuilder({
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
}));
|
||||
}, [panelDefinition, panelType, filterConfigs, isDarkMode, isListViewPanel]);
|
||||
}, [panelDefinition, panelType, isDarkMode, isRawQuery]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -7,7 +7,6 @@ import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
|
||||
*/
|
||||
function QueryBuilderEditorPane({
|
||||
panelDefinition,
|
||||
signal,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
@@ -16,7 +15,6 @@ function QueryBuilderEditorPane({
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={panelDefinition}
|
||||
signal={signal}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
onCancelQuery={onCancelQuery}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
@@ -45,14 +43,10 @@ jest.mock('assets/Dashboard/PromQl', () => ({
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
|
||||
function renderBuilder(
|
||||
panelKind: string,
|
||||
signal: TelemetrytypesSignalDTO = TelemetrytypesSignalDTO.logs,
|
||||
): void {
|
||||
function renderBuilder(panelKind: string): void {
|
||||
render(
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
|
||||
signal={signal}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={jest.fn()}
|
||||
onCancelQuery={jest.fn()}
|
||||
@@ -62,9 +56,9 @@ function renderBuilder(
|
||||
|
||||
function lastQueryBuilderProps(): {
|
||||
panelType: string;
|
||||
isListViewPanel: boolean;
|
||||
isRawQuery: boolean;
|
||||
showTraceOperator: boolean;
|
||||
filterConfigs: unknown;
|
||||
fieldsConfig: unknown;
|
||||
} {
|
||||
const calls = mockQueryBuilderV2.mock.calls;
|
||||
return calls[calls.length - 1][0];
|
||||
@@ -80,7 +74,7 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
|
||||
});
|
||||
|
||||
it('shows only the Query Builder tab for the List kind', () => {
|
||||
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
|
||||
renderBuilder('signoz/ListPanel');
|
||||
|
||||
expect(screen.getByText('Query Builder')).toBeInTheDocument();
|
||||
expect(screen.queryByText('ClickHouse Query')).not.toBeInTheDocument();
|
||||
@@ -114,40 +108,24 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
|
||||
});
|
||||
|
||||
it('passes empty field config + non-list flag for a non-list kind', () => {
|
||||
renderBuilder('signoz/TimeSeriesPanel', TelemetrytypesSignalDTO.metrics);
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
const props = lastQueryBuilderProps();
|
||||
expect(props.panelType).toBe('graph');
|
||||
expect(props.isListViewPanel).toBe(false);
|
||||
expect(props.isRawQuery).toBe(false);
|
||||
// The trace operator combines aggregated trace queries, so it rides along with
|
||||
// the aggregation controls.
|
||||
expect(props.showTraceOperator).toBe(true);
|
||||
expect(props.filterConfigs).toStrictEqual({});
|
||||
expect(props.fieldsConfig).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('hides step interval / having and sets body-contains for List + logs', () => {
|
||||
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
|
||||
it('marks List raw and leaves the field surface to the raw baseline', () => {
|
||||
renderBuilder('signoz/ListPanel');
|
||||
|
||||
const props = lastQueryBuilderProps();
|
||||
expect(props.panelType).toBe('list');
|
||||
expect(props.isListViewPanel).toBe(true);
|
||||
expect(props.isRawQuery).toBe(true);
|
||||
expect(props.showTraceOperator).toBe(false);
|
||||
expect(props.filterConfigs).toStrictEqual({
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
|
||||
});
|
||||
});
|
||||
|
||||
it('additionally hides limit for List + traces', () => {
|
||||
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.traces);
|
||||
|
||||
const props = lastQueryBuilderProps();
|
||||
expect(props.filterConfigs).toStrictEqual({
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
limit: { isHidden: true, isDisabled: true },
|
||||
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
|
||||
});
|
||||
expect(props.fieldsConfig).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,7 +86,6 @@ function EditorRoute(): JSX.Element {
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={requireQueryPanelDefinition('signoz/TimeSeriesPanel')}
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={noop}
|
||||
onCancelQuery={noop}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { UNSUPPORTED_PANEL } from '../kinds/UnsupportedPanel/definition';
|
||||
@@ -10,7 +9,6 @@ import { getPanelDefinition, isPanelKindSupported } from '../registry';
|
||||
import type { PanelQueryCapabilities } from '../types/panelCapabilities';
|
||||
import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
|
||||
import {
|
||||
getHiddenQueryBuilderFields,
|
||||
getQueryPanelDefinition,
|
||||
requireQueryPanelDefinition,
|
||||
getSupportedQueryTypes,
|
||||
@@ -144,7 +142,6 @@ describe('panel capabilities guard', () => {
|
||||
expect(
|
||||
isPanelCombinationValid({ kind: unknownKind, queryType: QUERY_BUILDER }),
|
||||
).toBe(false);
|
||||
expect(getHiddenQueryBuilderFields(unknownKind, logs)).toStrictEqual({});
|
||||
expect(getPanelDefinition(unknownKind).sections).toStrictEqual([]);
|
||||
});
|
||||
|
||||
@@ -267,37 +264,4 @@ describe('panel capabilities guard', () => {
|
||||
expect(resolveQueryType('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHiddenQueryBuilderFields', () => {
|
||||
it('returns {} for kinds that declare no field rules', () => {
|
||||
expect(
|
||||
getHiddenQueryBuilderFields('signoz/TimeSeriesPanel', logs),
|
||||
).toStrictEqual({});
|
||||
expect(getHiddenQueryBuilderFields('signoz/TablePanel', logs)).toStrictEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
// Mirrors QueryBuilderV2's internal listViewLogFilterConfigs — the guard is the
|
||||
// single source of truth for these values.
|
||||
it('hides step interval / having and sets body-contains for List + logs', () => {
|
||||
expect(getHiddenQueryBuilderFields('signoz/ListPanel', logs)).toStrictEqual({
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
|
||||
});
|
||||
});
|
||||
|
||||
// Mirrors listViewTracesFilterConfigs — traces additionally hide `limit`.
|
||||
it('additionally hides limit for List + traces', () => {
|
||||
expect(
|
||||
getHiddenQueryBuilderFields('signoz/ListPanel', traces),
|
||||
).toStrictEqual({
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
limit: { isHidden: true, isDisabled: true },
|
||||
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { type TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { getPanelDefinition } from './registry';
|
||||
import {
|
||||
mergeQueryBuilderFieldRule,
|
||||
type FilterConfigsPartial,
|
||||
} from './types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
|
||||
import type { PanelKind } from './types/panelKind';
|
||||
|
||||
/**
|
||||
* The single deterministic guard for V2 dashboards. Every "what works with what"
|
||||
* question — panel kind × query type × signal, and which query-builder fields a kind
|
||||
* hides — is answered here by reading each kind's declared capabilities from the panel
|
||||
* registry. Adding a new kind means declaring its capabilities once in its definition;
|
||||
* question — panel kind × query type × signal — is answered here by reading each kind's
|
||||
* declared capabilities from the panel registry. Adding a new kind means declaring its
|
||||
* capabilities once in its definition;
|
||||
* these functions then cover it automatically. Pure and side-effect free.
|
||||
*/
|
||||
|
||||
@@ -122,16 +118,3 @@ export function resolveQueryType(
|
||||
// A query-less kind has no supported types; the builder is the neutral answer.
|
||||
return supported[0] ?? EQueryType.QUERY_BUILDER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query-builder field visibility for a kind + signal: the kind's `default` rule with
|
||||
* its per-signal overrides merged over it (signal wins). `{}` when the kind hides
|
||||
* nothing, i.e. the builder shows every field.
|
||||
*/
|
||||
export function getHiddenQueryBuilderFields(
|
||||
kind: PanelKind,
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): FilterConfigsPartial {
|
||||
const rule = getQueryPanelDefinition(kind)?.queryBuilderFields ?? {};
|
||||
return mergeQueryBuilderFieldRule(rule, signal);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ function ListEditorPane({
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={panelDefinition}
|
||||
signal={signal}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
onCancelQuery={onCancelQuery}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
@@ -23,21 +22,9 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
// Raw rows have no aggregation, so step interval / having never apply, and the
|
||||
// Where clause searches the log/span body via `body CONTAINS`. Traces additionally
|
||||
// hide `limit` (the server paginates raw spans). Mirrors QueryBuilderV2's internal
|
||||
// list configs — the capabilities guard is the single source for both.
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER],
|
||||
queryBuilderFields: {
|
||||
default: {
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
filters: { customKey: 'body', customOp: OPERATORS.CONTAINS },
|
||||
},
|
||||
[TelemetrytypesSignalDTO.traces]: {
|
||||
limit: { isHidden: true, isDisabled: true },
|
||||
},
|
||||
},
|
||||
// No deviation from the baseline the raw request type below already implies.
|
||||
queryBuilderFields: {},
|
||||
sections,
|
||||
// The only kind reading raw rows: they page server-side, and the sort needs a
|
||||
// tiebreaker so a duplicated sort key can't repeat or skip a row across pages.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user