mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-05 11:00:41 +01:00
Compare commits
9 Commits
proto/stor
...
feat/heatm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0844308f1f | ||
|
|
2bb8c7970c | ||
|
|
e0da06f76d | ||
|
|
85f9924b4b | ||
|
|
c015622258 | ||
|
|
0f36cb9334 | ||
|
|
e1ee386016 | ||
|
|
717d37a945 | ||
|
|
63b130ccd1 |
28
.github/CODEOWNERS
vendored
28
.github/CODEOWNERS
vendored
@@ -152,39 +152,29 @@ go.mod @therealpandey
|
||||
|
||||
## Dashboard Types
|
||||
|
||||
/frontend/src/api/types/dashboard/ @SigNoz/pulse-frontend
|
||||
/frontend/src/types/api/dashboard/ @SigNoz/pulse-frontend
|
||||
/frontend/src/types/api/widgets/ @SigNoz/pulse-frontend
|
||||
|
||||
## Dashboard List
|
||||
## Widget Card
|
||||
|
||||
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/ListOfDashboard/ @SigNoz/pulse-frontend
|
||||
|
||||
# Dashboard Widget Page
|
||||
|
||||
/frontend/src/pages/DashboardWidget/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/NewWidget/ @SigNoz/pulse-frontend
|
||||
|
||||
## Dashboard Page
|
||||
|
||||
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/DashboardContainer/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/GridCardLayout/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/WidgetCard/ @SigNoz/pulse-frontend
|
||||
|
||||
## Public Dashboard Page
|
||||
|
||||
/frontend/src/pages/PublicDashboard/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/PublicDashboardContainer/ @SigNoz/pulse-frontend
|
||||
|
||||
## Dashboard Libs + Components
|
||||
|
||||
/frontend/src/lib/uPlotV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/lib/visualization/ @SigNoz/pulse-frontend
|
||||
/frontend/src/lib/dashboard/ @SigNoz/pulse-frontend
|
||||
/frontend/src/lib/dashboardVariables/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/NewSelect/ @SigNoz/pulse-frontend
|
||||
|
||||
## Dashboard V2
|
||||
/frontend/src/pages/DashboardPageV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/DashboardsListPageV2/ @SigNoz/pulse-frontend
|
||||
## Dashboard Pages
|
||||
|
||||
/frontend/src/pages/DashboardPage/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/DashboardsListPage/ @SigNoz/pulse-frontend
|
||||
|
||||
## Infrastructure Monitoring
|
||||
/frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend
|
||||
|
||||
@@ -44,6 +44,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/subscription"
|
||||
"github.com/SigNoz/signoz/pkg/subscription/noopsubscription"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
@@ -87,6 +89,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(_ sqlstore.SQLStore, _ zeus.Zeus, _ organization.Getter, _ analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
|
||||
return nooplicensing.NewFactory()
|
||||
},
|
||||
func(_ zeus.Zeus, _ licensing.Licensing) subscription.Subscription {
|
||||
return noopsubscription.New()
|
||||
},
|
||||
signoz.NewEmailingProviderFactories(),
|
||||
signoz.NewCacheProviderFactories(),
|
||||
signoz.NewWebProviderFactories(config.Global),
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
eequerier "github.com/SigNoz/signoz/ee/querier"
|
||||
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
|
||||
eerules "github.com/SigNoz/signoz/ee/query-service/rules"
|
||||
"github.com/SigNoz/signoz/ee/subscription/httpsubscription"
|
||||
enterprisezeus "github.com/SigNoz/signoz/ee/zeus"
|
||||
"github.com/SigNoz/signoz/ee/zeus/httpzeus"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
@@ -60,6 +61,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/subscription"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
@@ -103,6 +105,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(sqlstore sqlstore.SQLStore, zeus zeus.Zeus, orgGetter organization.Getter, analytics analytics.Analytics) factory.ProviderFactory[licensing.Licensing, licensing.Config] {
|
||||
return httplicensing.NewProviderFactory(sqlstore, zeus, orgGetter, analytics)
|
||||
},
|
||||
func(zeus zeus.Zeus, licensing licensing.Licensing) subscription.Subscription {
|
||||
return httpsubscription.New(zeus, licensing)
|
||||
},
|
||||
signoz.NewEmailingProviderFactories(),
|
||||
signoz.NewCacheProviderFactories(),
|
||||
signoz.NewWebProviderFactories(config.Global),
|
||||
|
||||
@@ -9217,6 +9217,116 @@ components:
|
||||
required:
|
||||
- references
|
||||
type: object
|
||||
SubscriptiontypesGettableSubscription:
|
||||
properties:
|
||||
redirectURL:
|
||||
type: string
|
||||
required:
|
||||
- redirectURL
|
||||
type: object
|
||||
SubscriptiontypesGettableSubscriptionUsage:
|
||||
properties:
|
||||
billingPeriodEnd:
|
||||
format: int64
|
||||
type: integer
|
||||
billingPeriodStart:
|
||||
format: int64
|
||||
type: integer
|
||||
details:
|
||||
$ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageDetails'
|
||||
discount:
|
||||
format: double
|
||||
type: number
|
||||
subscriptionStatus:
|
||||
type: string
|
||||
type: object
|
||||
SubscriptiontypesPostableSubscription:
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
required:
|
||||
- url
|
||||
type: object
|
||||
SubscriptiontypesSubscriptionUsageBreakdown:
|
||||
properties:
|
||||
dayWiseBreakdown:
|
||||
$ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageDayWiseBreakdown'
|
||||
tiers:
|
||||
items:
|
||||
$ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageTier'
|
||||
nullable: true
|
||||
type: array
|
||||
type:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
type: object
|
||||
SubscriptiontypesSubscriptionUsageDayWiseBreakdown:
|
||||
properties:
|
||||
breakdown:
|
||||
items:
|
||||
$ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageDayWiseData'
|
||||
nullable: true
|
||||
type: array
|
||||
type:
|
||||
type: string
|
||||
type: object
|
||||
SubscriptiontypesSubscriptionUsageDayWiseData:
|
||||
properties:
|
||||
count:
|
||||
format: double
|
||||
type: number
|
||||
quantity:
|
||||
format: double
|
||||
type: number
|
||||
size:
|
||||
format: double
|
||||
type: number
|
||||
timestamp:
|
||||
format: int64
|
||||
type: integer
|
||||
total:
|
||||
format: double
|
||||
type: number
|
||||
unitPrice:
|
||||
format: double
|
||||
type: number
|
||||
type: object
|
||||
SubscriptiontypesSubscriptionUsageDetails:
|
||||
properties:
|
||||
baseFee:
|
||||
format: double
|
||||
type: number
|
||||
billTotal:
|
||||
format: double
|
||||
type: number
|
||||
breakdown:
|
||||
items:
|
||||
$ref: '#/components/schemas/SubscriptiontypesSubscriptionUsageBreakdown'
|
||||
nullable: true
|
||||
type: array
|
||||
total:
|
||||
format: double
|
||||
type: number
|
||||
type: object
|
||||
SubscriptiontypesSubscriptionUsageTier:
|
||||
properties:
|
||||
quantity:
|
||||
format: double
|
||||
type: number
|
||||
tierCost:
|
||||
format: double
|
||||
type: number
|
||||
tierEnd:
|
||||
format: int64
|
||||
type: integer
|
||||
tierStart:
|
||||
format: int64
|
||||
type: integer
|
||||
unitPrice:
|
||||
format: double
|
||||
type: number
|
||||
type: object
|
||||
TagtypesGettableTag:
|
||||
properties:
|
||||
key:
|
||||
@@ -14441,6 +14551,197 @@ paths:
|
||||
summary: Get stats
|
||||
tags:
|
||||
- stats
|
||||
/api/v1/subscriptions:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets the organization's subscription along with its
|
||||
usage and billing details.
|
||||
operationId: GetSubscription
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SubscriptiontypesGettableSubscriptionUsage'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- subscription:read
|
||||
- tokenizer:
|
||||
- subscription:read
|
||||
summary: Get the subscription.
|
||||
tags:
|
||||
- subscriptions
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a subscription for the organization.
|
||||
operationId: CreateSubscription
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SubscriptiontypesPostableSubscription'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SubscriptiontypesGettableSubscription'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- subscription:create
|
||||
- tokenizer:
|
||||
- subscription:create
|
||||
summary: Create a subscription.
|
||||
tags:
|
||||
- subscriptions
|
||||
put:
|
||||
deprecated: false
|
||||
description: This endpoint updates the organization's subscription.
|
||||
operationId: UpdateSubscription
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SubscriptiontypesPostableSubscription'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SubscriptiontypesGettableSubscription'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- subscription:list
|
||||
- subscription:update
|
||||
- tokenizer:
|
||||
- subscription:list
|
||||
- subscription:update
|
||||
summary: Update the subscription.
|
||||
tags:
|
||||
- subscriptions
|
||||
/api/v1/testChannel:
|
||||
post:
|
||||
deprecated: true
|
||||
|
||||
@@ -54,95 +54,50 @@ The `fieldContexts` map includes aliases (`tag` -> `attribute`, `spanfield` -> `
|
||||
|
||||
## The Abstraction Stack
|
||||
|
||||
The query pipeline has three layers. The generic layer is written one time, in `pkg/querybuilder`. A storage is written one time per signal. The statement builders compose them. Each layer depends only on the layer below it. This layering is intentional and must be preserved.
|
||||
The query pipeline is built from four interfaces that compose vertically. Each layer has a single responsibility. Each layer depends only on the layers below it. This layering is intentional and must be preserved.
|
||||
|
||||
```
|
||||
StatementBuilder <- Composes one query into executable SQL
|
||||
├── AggExprRewriter <- Rewrites aggregation expressions through the generic layer
|
||||
├── filter visitor <- Parses the filter expression and compiles it one term at a time
|
||||
└── querybuilder (generic) <- Resolution, the condition builder, the column expression builder
|
||||
└── Storage <- What one signal's tables can answer about one field key
|
||||
StatementBuilder <- Orchestrates everything into executable SQL
|
||||
├── AggExprRewriter <- Rewrites aggregation expressions (maps field refs to columns)
|
||||
├── ConditionBuilder <- Builds WHERE predicates (field + operator + value -> SQL)
|
||||
└── FieldMapper <- Maps TelemetryFieldKey -> ClickHouse column expression
|
||||
```
|
||||
|
||||
### Storage
|
||||
### FieldMapper
|
||||
|
||||
**Contract:** `qbtypes.Storage` in `pkg/types/querybuildertypes/querybuildertypesv5/qb.go`. One implementation per signal: traces, logs, metrics, audit, rule state history, the resource fingerprint sub-query, and the related-values metadata.
|
||||
**Contract:** Given a `TelemetryFieldKey`, return a ClickHouse column expression that yields the value for that field when used in a SELECT.
|
||||
|
||||
A storage answers five questions and nothing else:
|
||||
**Principle:** This is the *only* place where field-to-column translation happens. No other layer should contain knowledge of how fields map to storage. If you need a column expression, go through the FieldMapper.
|
||||
|
||||
- `Read(field)`: the bare SQL read of one field key. No alias, no guard, no cast. It honors the materialization and the evolutions the field carries.
|
||||
- `Exists(field, exists)`: the presence test of one field key, and how the field reads when a row lacks it (`Absent`, below).
|
||||
- `Fallback(key, operator, value)`: the field keys that could hold a key metadata does not report: column aliases, the type variants of a map read, body paths, and virtual keys that compile to structural predicates (a span search scope, a full-text search over a scope).
|
||||
- `Traits()`: the storage's part in the resource fingerprint split, whether it supports body functions, what it does with an unknown key, and which contexts mean "this signal's own record".
|
||||
- `Compile` and `ColumnRead`: two overrides for a storage with its own condition language (the body JSON language in logs, the index hints of the resource fingerprint, the polarity form of the related values, the String-typed labels of metrics). Every other storage returns `querybuilder.SharedCondition` and `querybuilder.DefaultRead`.
|
||||
**Why:** The user says `http.request.method`. ClickHouse might store it as `attributes_string['http.request.method']`, or as a materialized column `` `attribute_string_http$$request$$method` ``, or via a JSON access path in a body column. This variation is entirely contained within the FieldMapper. Everything above it is storage-agnostic.
|
||||
|
||||
**Principle:** A storage describes its field keys. It never decides a guard, an ambiguity, a warning, or the shape of a fold. Those decisions are derived one time, in the generic layer, from those descriptions.
|
||||
### ConditionBuilder
|
||||
|
||||
### Absent
|
||||
**Contract:** Given a field key, an operator, and a value, produce a valid SQL predicate for a WHERE clause.
|
||||
|
||||
`Exists` returns the field's `Absent`: what a row without the field reads. It is a property of the read, not of the column. `resource.x::String` reads the empty string for an absent row, the multi-era fold `multiIf(..., NULL)` reads NULL, and a table column always reads a real value. Every guard derives from it:
|
||||
**Dependency:** Uses FieldMapper for the left-hand side of the condition.
|
||||
|
||||
| WhenAbsent | Absent row reads | Positive filter | Raw select | Multi-candidate column | Field keys |
|
||||
|---|---|---|---|---|---|
|
||||
| `AlwaysPresent` | a real value | no guard | no guard | no branch, ends the candidate list | table columns |
|
||||
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families |
|
||||
| `AbsentIsNull` | NULL | no guard | no guard | presence branch | multi-era folds, body JSON paths, numeric families |
|
||||
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels |
|
||||
**Principle:** The ConditionBuilder owns all the complexity of operator semantics, i.e type casting, array operators (`hasAny`/`hasAll` vs `=`), existence checks, and negative operator behavior. This complexity must not leak upward into the StatementBuilder.
|
||||
|
||||
### The generic layer
|
||||
### AggExprRewriter
|
||||
|
||||
`pkg/querybuilder` needs two inputs, made one time per request:
|
||||
**Contract:** Given a user-facing aggregation expression like `sum(duration_nano)`, resolve field references within it and produce valid ClickHouse SQL.
|
||||
|
||||
- The metadata keys: `keys := metadataStore.GetKeysMulti(...)`, the field keys the metadata store reports for the query's names, as `map[name][]*TelemetryFieldKey`.
|
||||
- `q := querybuilder.NewQueryInfo(ctx, orgID, fl, signal, metric, startNs, endNs)`: the org and time range every read needs, the signal and the queried metric that family admission needs, and the query-path flags (`FamiliesOn`, `BodyJSONOn`), evaluated one time.
|
||||
**Dependency:** Uses FieldMapper to resolve field names within expressions.
|
||||
|
||||
The functions, from the outside in:
|
||||
**Principle:** Aggregation expressions are user-authored strings that contain field references. The rewriter parses them, identifies field references, resolves each through the FieldMapper, and reassembles the expression.
|
||||
|
||||
| Function | Does |
|
||||
|---|---|
|
||||
| `PrepareWhereClause(query, opts)` | The filter visitor. Parses the filter grammar and compiles each term through `RejectsBodyFunction`, `Resolve`, and `Condition`. Returns the WHERE clause, the warnings, and the cost-guard flag. |
|
||||
| `NewAggExprRewriter(settings, fullTextColumn, storage, fl, signal)` | Parses an aggregation expression such as `sum(duration_nano)` and resolves each field reference through `ResolveColumn`. |
|
||||
| `ResolveColumn(ctx, q, storage, key, target, metadata)` | `Resolve` with `FilterOperatorUnknown`, then `Column`. The column stages (raw select, order by, group by, aggregation arguments) call it. |
|
||||
| `Resolve(ctx, q, storage, key, operator, value, metadata)` | One requested key to its meanings in this storage (see "A resolved key"). |
|
||||
| `Condition(ctx, q, storage, resolved, dropResourceFields, operator, value, sb)` | A resolved key to the conditions of one filter term: the split narrows the fields, and each field compiles through `storage.Compile`. |
|
||||
| `Conditions(...)` | `RejectsBodyFunction`, `Resolve`, and `Condition` in one call, for callers outside the visitor: the related-values metadata, the scoped traces predicate resolver, tests. |
|
||||
| `Column(ctx, q, storage, resolved, target)` | A resolved key to one bare column expression. The caller aliases. |
|
||||
| `RejectsBodyFunction(traits, operator)` | Before resolution: a storage without body functions (`has`, `hasAny`, `hasAll`, `hasToken`, `search`) errors; the fingerprint side of a split skips the term, because the main query evaluates it. |
|
||||
| `SharedCondition(...)` | The `Compile` of every storage without its own condition language: `LogicalValueExpr`, the shared data-type collision cast, `OperatorCondition`, then the guard rule. |
|
||||
| `OperatorCondition(...)` | The operator switch over an already cast read. A storage with its own cast policy composes with it. |
|
||||
| `DefaultRead(...)` | The `ColumnRead` of every storage without a target-dependent read: `LogicalValueExpr` as an uncoerced column expression. |
|
||||
| `LogicalValueExpr(...)`, `LogicalExistsExpr(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads current-first (`COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers) and ORs the member presence tests. A member with a value map reads through `TransformRead`. |
|
||||
### StatementBuilder
|
||||
|
||||
### A resolved key
|
||||
**Contract:** Given a complete `QueryBuilderQuery`, a time range, and a request type, produces an executable SQL statement.
|
||||
|
||||
`Resolve` turns one requested key into a `Resolved` value. It is the only thing the condition builder and the column expression builder receive. Compile it with the operator and value it was resolved with: the stage is the operand, and a nil value means a column or presence use.
|
||||
**Dependency:** Uses all three abstractions above.
|
||||
|
||||
```go
|
||||
type Resolved struct {
|
||||
Key *TelemetryFieldKey // the spelling the request used
|
||||
Fields []*LogicalField // its meanings in this storage, one per interpretation
|
||||
FromFallback bool // the fields came from the storage's Fallback, not from metadata matches
|
||||
Ambiguous bool // the matches held several interpretations
|
||||
Skipped bool // the storage contributes nothing for this key
|
||||
Warnings []string // the warnings to surface: ambiguity, not-found
|
||||
}
|
||||
```
|
||||
|
||||
A `LogicalField` is one meaning: one name, context, and data type, backed by one or more physical members. A family (one field with several spellings) is one logical field with several members, current spelling first. Ambiguity (one name, different fields) is several logical fields.
|
||||
|
||||
The resolution order is the same for every storage and every stage:
|
||||
|
||||
1. **Own context.** A key under one of the storage's own contexts (`span.x`, `log.x`) looks up as if it had no context. Strict contexts (`resource.`, `attribute.`, `scope.`, `body.`) are honored as written.
|
||||
2. **Matches.** The metadata keys under the key's spellings, grouped into families when the flag is on. Each combination of context and data type is one interpretation.
|
||||
3. **Ambiguity.** A filter settles several interpretations by resource over attribute, with a warning. A column stage keeps every interpretation in metadata order and folds them, so a select or a group by sees the value wherever it is.
|
||||
4. **Intrinsic column first**, bare keys only. A column every row has leads the list, whether metadata reports it or the storage's `Fallback` does. Sentinel-reading fields with a contradicting data type drop. A metadata gap degrades to the correct column, never to a corrupt metadata key.
|
||||
5. **Fallback.** With no match, the storage's fallback keys for the key. When the storage ignores unknown keys (a side query whose main query owns the error), the key is `Skipped`. Otherwise a key nothing can serve is an error with suggestions. The not-found warning fires only when every fallback key is a guess, that is, none of them is always present.
|
||||
|
||||
The condition builder then applies the fingerprint split (`MainOfSplit` drops the resource fields the sub-query serves and keeps fallback keys; `FingerprintOfSplit` keeps resource fields only), compiles each field, and the visitor joins the per-field conditions by the operator's polarity. The column expression builder reads each field through `ColumnRead`, casts for the coerced stages unless the read keeps its type, guards by `Absent`, and renders one candidate bare or several as `multiIf(..., NULL)`. A candidate that is not selectable (`ErrNotSelectable`) drops; the error surfaces only when none remains.
|
||||
**Principle:** This is the composition layer. It does not contain field mapping logic, condition building logic, or expression rewriting logic. It orchestrates the other abstractions. If you find storage-specific logic creeping into the StatementBuilder, push it down into the appropriate abstraction.
|
||||
|
||||
### Invariant: No layer skipping
|
||||
|
||||
A statement builder must not spell a column or a condition. It calls `ResolveColumn` and the filter visitor. A storage must not decide a guard or an ambiguity. It declares `Absent` and answers the five questions. Skipping layers recreates the per-signal copies the contract removed.
|
||||
The StatementBuilder must not call FieldMapper directly to build conditions, it goes through the ConditionBuilder. The AggExprRewriter must not hardcode column names, it goes through the FieldMapper. Skipping layers creates hidden coupling and makes the system fragile to storage changes.
|
||||
|
||||
---
|
||||
|
||||
@@ -164,15 +119,14 @@ Only additive/counting aggregations (`count`, `count_distinct`, `sum`, `rate`) d
|
||||
|
||||
**Enforcement:** `GetQueriesSupportingZeroDefault` determines which queries can default to zero. The `FormulaEvaluator` consumes this via `canDefaultZero`. Changes to aggregation handling must preserve this distinction.
|
||||
|
||||
### Constraint: The exists guard derives from the operator and from the field
|
||||
### Constraint: Existence semantics differ for positive vs negative operators
|
||||
|
||||
- **Positive operators** (`=`, `>`, `LIKE`, `IN`, etc.) implicitly assert field existence for a field that reads a sentinel when absent. `http.method = GET` on a map attribute means "the field exists AND equals GET".
|
||||
- **Negative operators** (`!=`, `NOT IN`, `NOT LIKE`, etc.) never add an existence check. `http.method != GET` includes records where the field doesn't exist at all.
|
||||
- A field that reads NULL when absent, and a table column, take no guard on any operator: the comparison already excludes the absent row, or there is no absent row.
|
||||
- **Positive operators** (`=`, `>`, `LIKE`, `IN`, etc.) implicitly assert field existence. `http.method = GET` means "the field exists AND equals GET".
|
||||
- **Negative operators** (`!=`, `NOT IN`, `NOT LIKE`, etc.) do **not** add an existence check. `http.method != GET` includes records where the field doesn't exist at all.
|
||||
|
||||
**Why:** The user's intent with negative operators is ambiguous. Rather than guess, we take the broader interpretation. Users can add an explicit `EXISTS` filter if they want the narrower one. The operator side is declared in `AddDefaultExistsFilter`; the field side is the `Absent` a storage returns from `Exists`.
|
||||
**Why:** The user's intent with negative operators is ambiguous. Rather than guess, we take the broader interpretation. Users can add an explicit `EXISTS` filter if they want the narrower one. This is documented in `AddDefaultExistsFilter`.
|
||||
|
||||
**Consequence:** Any new operator must declare its existence behavior in `AddDefaultExistsFilter`. Any new read must declare what an absent row reads. Never add a guard by hand in a storage.
|
||||
**Consequence:** Any new operator must declare its existence behavior in `AddDefaultExistsFilter`. Do not add operators without considering this.
|
||||
|
||||
### Constraint: Post-processing functions operate on result sets, not in SQL
|
||||
|
||||
@@ -234,11 +188,11 @@ The `MetadataStore` interface provides runtime field discovery and type resoluti
|
||||
|
||||
The same name can map to multiple `TelemetryFieldKey` variants (different contexts, different types). The metadata store returns *all* variants. Resolution to a single field happens during query building, using the query's signal and any explicit context/type hints from the user.
|
||||
|
||||
**Consequence:** Code that calls `GetKey` or `GetKeys` must handle multiple results. Do not assume a name maps to a single field. `querybuilder.Resolve` is where the variants settle: it returns every interpretation as a `LogicalField`, marks the result `Ambiguous`, and carries the warning.
|
||||
**Consequence:** Code that calls `GetKey` or `GetKeys` must handle multiple results. Do not assume a name maps to a single field.
|
||||
|
||||
### Principle: Materialized fields are a performance optimization, not a semantic distinction
|
||||
|
||||
A materialized field and its non-materialized equivalent represent the same logical field. The `Materialized` flag tells the storage's `Read` to generate a simpler column expression. The user should never need to know whether a field is materialized.
|
||||
A materialized field and its non-materialized equivalent represent the same logical field. The `Materialized` flag tells the FieldMapper to generate a simpler column expression. The user should never need to know whether a field is materialized.
|
||||
|
||||
### Principle: JSON body fields require access plans
|
||||
|
||||
@@ -249,14 +203,14 @@ Fields inside JSON body columns (`body.response.errors[].code`) need pre-compute
|
||||
## Summary of Inviolable Rules
|
||||
|
||||
1. **User-facing types never contain ClickHouse column names or SQL fragments.**
|
||||
2. **Field-to-column translation only happens in a Storage (`Read`, `Exists`, `Fallback`).**
|
||||
2. **Field-to-column translation only happens in FieldMapper.**
|
||||
3. **Normalization happens once at the API boundary, never deeper.**
|
||||
4. **Historical aliases in fieldContexts and fieldDataTypes must not be removed.**
|
||||
5. **Formula evaluation stays in Go — do not push it into ClickHouse JOINs.**
|
||||
6. **Zero-defaulting is aggregation-type-dependent — do not universally default to zero.**
|
||||
7. **The exists guard derives from `AddDefaultExistsFilter` and `Absent`; positive operators guard sentinel reads, negative operators never guard.**
|
||||
7. **Positive operators imply existence, negative operators do not.**
|
||||
8. **Post-processing functions operate on Go result sets, not in SQL.**
|
||||
9. **All user-facing types reject unknown JSON fields with suggestions.**
|
||||
10. **Validation rules are gated by request type.**
|
||||
11. **Query names must be unique within a composite query.**
|
||||
12. **The three-layer abstraction stack (Storage -> querybuilder generic layer -> StatementBuilder) must not be bypassed or flattened. A storage describes its field keys; the generic layer decides.**
|
||||
12. **The four-layer abstraction stack (FieldMapper -> ConditionBuilder -> AggExprRewriter -> StatementBuilder) must not be bypassed or flattened.**
|
||||
|
||||
95
ee/subscription/httpsubscription/provider.go
Normal file
95
ee/subscription/httpsubscription/provider.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package httpsubscription
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/subscription"
|
||||
"github.com/SigNoz/signoz/pkg/types/subscriptiontypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const upstreamTimeout = 10 * time.Second
|
||||
|
||||
type provider struct {
|
||||
zeus zeus.Zeus
|
||||
licensing licensing.Licensing
|
||||
}
|
||||
|
||||
func New(zeus zeus.Zeus, licensing licensing.Licensing) subscription.Subscription {
|
||||
return &provider{
|
||||
zeus: zeus,
|
||||
licensing: licensing,
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *provider) Create(ctx context.Context, organizationID valuer.UUID, postableSubscription *subscriptiontypes.PostableSubscription) (*subscriptiontypes.GettableSubscription, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, upstreamTimeout)
|
||||
defer cancel()
|
||||
|
||||
license, err := provider.licensing.GetActive(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := json.Marshal(postableSubscription)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal subscription payload")
|
||||
}
|
||||
|
||||
response, err := provider.zeus.GetCheckoutURL(ctx, license.Key, body)
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeAlreadyExists) {
|
||||
return nil, errors.WithAdditionalf(err, "checkout has already been completed for this account. Please click 'Refresh Status' to sync your subscription")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &subscriptiontypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Update(ctx context.Context, organizationID valuer.UUID, postableSubscription *subscriptiontypes.PostableSubscription) (*subscriptiontypes.GettableSubscription, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, upstreamTimeout)
|
||||
defer cancel()
|
||||
|
||||
license, err := provider.licensing.GetActive(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := json.Marshal(postableSubscription)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal subscription payload")
|
||||
}
|
||||
|
||||
response, err := provider.zeus.GetPortalURL(ctx, license.Key, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &subscriptiontypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Get(ctx context.Context, organizationID valuer.UUID) (*subscriptiontypes.GettableSubscriptionUsage, error) {
|
||||
license, err := provider.licensing.GetActive(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := provider.zeus.GetMeters(ctx, license.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
usage, err := subscriptiontypes.NewGettableSubscriptionUsage(data)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, zeus.ErrCodeResponseMalformed, "failed to unmarshal subscription usage")
|
||||
}
|
||||
|
||||
return usage, nil
|
||||
}
|
||||
@@ -323,9 +323,10 @@
|
||||
"name": "react",
|
||||
"importNames": [
|
||||
"createContext",
|
||||
"useContext"
|
||||
"useContext",
|
||||
"useSyncExternalStore"
|
||||
],
|
||||
"message": "[State mgmt] React Context is deprecated. Migrate shared state to Zustand."
|
||||
"message": "[State mgmt] React Context and hand-rolled external stores are deprecated. Migrate shared state to Zustand."
|
||||
},
|
||||
{
|
||||
"name": "immer",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10522,6 +10522,153 @@ export interface SpantypesUpdatableSpanMapperGroupDTO {
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
export interface SubscriptiontypesGettableSubscriptionDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
redirectURL: string;
|
||||
}
|
||||
|
||||
export interface SubscriptiontypesSubscriptionUsageDayWiseDataDTO {
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
count?: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
quantity?: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
size?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
timestamp?: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
total?: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
unitPrice?: number;
|
||||
}
|
||||
|
||||
export interface SubscriptiontypesSubscriptionUsageDayWiseBreakdownDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
breakdown?: SubscriptiontypesSubscriptionUsageDayWiseDataDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptiontypesSubscriptionUsageTierDTO {
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
quantity?: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
tierCost?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
tierEnd?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
tierStart?: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
unitPrice?: number;
|
||||
}
|
||||
|
||||
export interface SubscriptiontypesSubscriptionUsageBreakdownDTO {
|
||||
dayWiseBreakdown?: SubscriptiontypesSubscriptionUsageDayWiseBreakdownDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
tiers?: SubscriptiontypesSubscriptionUsageTierDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptiontypesSubscriptionUsageDetailsDTO {
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
baseFee?: number;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
billTotal?: number;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
breakdown?: SubscriptiontypesSubscriptionUsageBreakdownDTO[] | null;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface SubscriptiontypesGettableSubscriptionUsageDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
billingPeriodEnd?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
billingPeriodStart?: number;
|
||||
details?: SubscriptiontypesSubscriptionUsageDetailsDTO;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
discount?: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
subscriptionStatus?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptiontypesPostableSubscriptionDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type TelemetrytypesGettableFieldKeysDTOKeysAnyOf = {
|
||||
[key: string]: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
};
|
||||
@@ -11740,6 +11887,30 @@ export type GetStats200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetSubscription200 = {
|
||||
data: SubscriptiontypesGettableSubscriptionUsageDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateSubscription201 = {
|
||||
data: SubscriptiontypesGettableSubscriptionDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateSubscription200 = {
|
||||
data: SubscriptiontypesGettableSubscriptionDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetTraceAggregationsPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
|
||||
280
frontend/src/api/generated/services/subscriptions/index.ts
Normal file
280
frontend/src/api/generated/services/subscriptions/index.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
CreateSubscription201,
|
||||
GetSubscription200,
|
||||
RenderErrorResponseDTO,
|
||||
SubscriptiontypesPostableSubscriptionDTO,
|
||||
UpdateSubscription200,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* This endpoint gets the organization's subscription along with its usage and billing details.
|
||||
* @summary Get the subscription.
|
||||
*/
|
||||
export const getSubscription = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<GetSubscription200>({
|
||||
url: `/api/v1/subscriptions`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetSubscriptionQueryKey = () => {
|
||||
return [`/api/v1/subscriptions`] as const;
|
||||
};
|
||||
|
||||
export const getGetSubscriptionQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getSubscription>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSubscription>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetSubscriptionQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSubscription>>> = ({
|
||||
signal,
|
||||
}) => getSubscription(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSubscription>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetSubscriptionQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getSubscription>>
|
||||
>;
|
||||
export type GetSubscriptionQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get the subscription.
|
||||
*/
|
||||
|
||||
export function useGetSubscription<
|
||||
TData = Awaited<ReturnType<typeof getSubscription>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSubscription>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetSubscriptionQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get the subscription.
|
||||
*/
|
||||
export const invalidateGetSubscription = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetSubscriptionQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a subscription for the organization.
|
||||
* @summary Create a subscription.
|
||||
*/
|
||||
export const createSubscription = (
|
||||
subscriptiontypesPostableSubscriptionDTO?: BodyType<SubscriptiontypesPostableSubscriptionDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateSubscription201>({
|
||||
url: `/api/v1/subscriptions`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: subscriptiontypesPostableSubscriptionDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateSubscriptionMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSubscription>>,
|
||||
TError,
|
||||
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSubscription>>,
|
||||
TError,
|
||||
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createSubscription'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createSubscription>>,
|
||||
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createSubscription(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateSubscriptionMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createSubscription>>
|
||||
>;
|
||||
export type CreateSubscriptionMutationBody =
|
||||
| BodyType<SubscriptiontypesPostableSubscriptionDTO>
|
||||
| undefined;
|
||||
export type CreateSubscriptionMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create a subscription.
|
||||
*/
|
||||
export const useCreateSubscription = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSubscription>>,
|
||||
TError,
|
||||
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createSubscription>>,
|
||||
TError,
|
||||
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateSubscriptionMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint updates the organization's subscription.
|
||||
* @summary Update the subscription.
|
||||
*/
|
||||
export const updateSubscription = (
|
||||
subscriptiontypesPostableSubscriptionDTO?: BodyType<SubscriptiontypesPostableSubscriptionDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateSubscription200>({
|
||||
url: `/api/v1/subscriptions`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: subscriptiontypesPostableSubscriptionDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateSubscriptionMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSubscription>>,
|
||||
TError,
|
||||
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSubscription>>,
|
||||
TError,
|
||||
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateSubscription'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateSubscription>>,
|
||||
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return updateSubscription(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateSubscriptionMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateSubscription>>
|
||||
>;
|
||||
export type UpdateSubscriptionMutationBody =
|
||||
| BodyType<SubscriptiontypesPostableSubscriptionDTO>
|
||||
| undefined;
|
||||
export type UpdateSubscriptionMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update the subscription.
|
||||
*/
|
||||
export const useUpdateSubscription = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSubscription>>,
|
||||
TError,
|
||||
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateSubscription>>,
|
||||
TError,
|
||||
{ data?: BodyType<SubscriptiontypesPostableSubscriptionDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateSubscriptionMutationOptions(options));
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
||||
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { DynamicVariableSuggestion } from 'providers/Dashboard/store/dynamicVariableSuggestions';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
@@ -545,20 +546,22 @@ function reduceQueriesToObject(queryArray: any[]): {
|
||||
/**
|
||||
* Prepares V5 query range payload from GetQueryResultsProps
|
||||
*/
|
||||
export const prepareQueryRangePayloadV5 = ({
|
||||
query,
|
||||
globalSelectedInterval,
|
||||
graphType,
|
||||
selectedTime,
|
||||
tableParams,
|
||||
variables = {},
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
formatForWeb,
|
||||
originalGraphType,
|
||||
fillGaps,
|
||||
dynamicVariables,
|
||||
}: GetQueryResultsProps): PrepareQueryRangePayloadV5Result => {
|
||||
export const prepareQueryRangePayloadV5 = (
|
||||
{
|
||||
query,
|
||||
globalSelectedInterval,
|
||||
graphType,
|
||||
selectedTime,
|
||||
tableParams,
|
||||
variables = {},
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
formatForWeb,
|
||||
originalGraphType,
|
||||
fillGaps,
|
||||
}: GetQueryResultsProps,
|
||||
dynamicVariables: DynamicVariableSuggestion[] = [],
|
||||
): PrepareQueryRangePayloadV5Result => {
|
||||
let legendMap: Record<string, string> = {};
|
||||
const requestType = mapPanelTypeToRequestType(graphType);
|
||||
let queries: QueryEnvelope[] = [];
|
||||
@@ -671,9 +674,9 @@ export const prepareQueryRangePayloadV5 = ({
|
||||
(acc, [key, value]) => {
|
||||
acc[key] = {
|
||||
value,
|
||||
type: dynamicVariables
|
||||
?.find((v) => v.name === key)
|
||||
?.type?.toLowerCase() as VariableType,
|
||||
type: dynamicVariables.some((v) => v.name === key)
|
||||
? ('dynamic' as VariableType)
|
||||
: undefined,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
QUERY_BUILDER_OPERATORS_BY_KEY_TYPE,
|
||||
queryOperatorSuggestions,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
|
||||
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { debounce, isNull } from 'lodash-es';
|
||||
@@ -258,10 +258,7 @@ function QuerySearch({
|
||||
const lastValueRef = useRef<string>('');
|
||||
const isMountedRef = useRef<boolean>(true);
|
||||
|
||||
const dashboardDynamicVariables = useDashboardVariablesByType(
|
||||
'DYNAMIC',
|
||||
'values',
|
||||
);
|
||||
const dashboardDynamicVariables = useDynamicVariableSuggestions();
|
||||
|
||||
// Add back the generateOptions function and useEffect
|
||||
const generateOptions = (keys: {
|
||||
@@ -1188,8 +1185,8 @@ function QuerySearch({
|
||||
);
|
||||
|
||||
// Add dynamic variables suggestions for the current key
|
||||
const variableName = dashboardDynamicVariables?.find(
|
||||
(variable) => variable?.dynamicVariablesAttribute === keyName,
|
||||
const variableName = dashboardDynamicVariables.find(
|
||||
(variable) => variable.attribute === keyName,
|
||||
)?.name;
|
||||
|
||||
if (variableName) {
|
||||
|
||||
@@ -525,6 +525,34 @@ export const convertFiltersToExpressionWithExistingQuery = (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Canonical name for a comparison's operator, limited to the equality and
|
||||
* membership forms. Every other shape (LIKE, BETWEEN, EXISTS, CONTAINS, REGEXP,
|
||||
* the ordering operators) returns undefined, so an operator-restricted removal
|
||||
* leaves it in place.
|
||||
*
|
||||
* The ANTLR4 runtime returns null for an absent token or rule despite the
|
||||
* non-nullable TypeScript signatures.
|
||||
*/
|
||||
const getComparisonOperator = (ctx: ComparisonContext): string | undefined => {
|
||||
if ((ctx.inClause() as unknown) !== null) {
|
||||
return 'in';
|
||||
}
|
||||
if ((ctx.notInClause() as unknown) !== null) {
|
||||
return 'not in';
|
||||
}
|
||||
if ((ctx.EQUALS() as unknown) !== null) {
|
||||
return '=';
|
||||
}
|
||||
if (
|
||||
(ctx.NOT_EQUALS() as unknown) !== null ||
|
||||
(ctx.NEQ() as unknown) !== null
|
||||
) {
|
||||
return '!=';
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes clauses for specified keys from a filter query expression.
|
||||
*
|
||||
@@ -542,12 +570,16 @@ export const convertFiltersToExpressionWithExistingQuery = (
|
||||
* - `true`: removes only the first clause whose value contains any `$`.
|
||||
* - `string` (e.g. `"$service.name"`): removes only the clause whose value exactly
|
||||
* matches that string — preferred when the specific variable reference is known.
|
||||
* @param operatorsToRemove - When given, restricts removal to clauses whose operator
|
||||
* is in this set (`=`, `!=`, `in`, `not in`); every other clause on the key is kept.
|
||||
* Omit to remove a matching key's clauses whatever their operator.
|
||||
* @returns The rewritten expression, or an empty string if all clauses were removed.
|
||||
*/
|
||||
export const removeKeysFromExpression = (
|
||||
expression: string,
|
||||
keysToRemove: string[],
|
||||
removeOnlyVariableExpressions: string | boolean = false,
|
||||
operatorsToRemove?: string[],
|
||||
): string => {
|
||||
if (!keysToRemove || keysToRemove.length === 0) {
|
||||
return expression;
|
||||
@@ -557,6 +589,9 @@ export const removeKeysFromExpression = (
|
||||
}
|
||||
|
||||
const keysSet = new Set(keysToRemove.map((k) => k.trim().toLowerCase()));
|
||||
const operatorsSet = operatorsToRemove
|
||||
? new Set(operatorsToRemove.map((op) => op.trim().toLowerCase()))
|
||||
: null;
|
||||
// Tracks keys for which a variable expression has already been removed.
|
||||
// Having multiple $-value clauses for the same key is invalid; we remove at most one.
|
||||
const removedVariableKeys = new Set<string>();
|
||||
@@ -658,6 +693,13 @@ export const removeKeysFromExpression = (
|
||||
return src(ctx);
|
||||
}
|
||||
|
||||
if (operatorsSet) {
|
||||
const operator = getComparisonOperator(ctx);
|
||||
if (!operator || !operatorsSet.has(operator)) {
|
||||
return src(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
if (removeOnlyVariableExpressions) {
|
||||
// Scope the value check to value nodes only — not the full comparison text —
|
||||
// so a key that contains '$' does not trigger removal when the value is a
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
import {
|
||||
convertFiltersToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import {
|
||||
Query,
|
||||
TagFilter,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
applyCheckboxToggle,
|
||||
clearFilterFromQuery,
|
||||
deriveCheckboxState,
|
||||
getNotInOperator,
|
||||
} from './checkboxFilterQuery';
|
||||
import { CheckedState } from '../../types';
|
||||
import { SectionType } from './v2/itemRules';
|
||||
|
||||
const KEY = 'service.name';
|
||||
|
||||
/**
|
||||
* Mini test framework
|
||||
* -------------------
|
||||
* `filters.items` is the source of truth the checkbox algebra mutates.
|
||||
* `filter.expression` is the derived value the backend actually reads, and it is
|
||||
* authoritatively rebuilt from the items on every URL round trip
|
||||
* (`useGetCompositeQueryParam` -> `convertFiltersToExpressionWithExistingQuery`).
|
||||
* That rebuild is additive, so `applyCheckboxToggle` re-derives its own clauses
|
||||
* into the expression itself: otherwise the round trip resurrects a clause the
|
||||
* toggle removed, or appends a duplicate of one it replaced.
|
||||
*
|
||||
* So a case does not assert the intermediate expression the toggle emits. It
|
||||
* asserts the pair that has to stay consistent:
|
||||
* - `items` : exact structured clauses after the toggle
|
||||
* - `expression` : the expression AFTER the round trip, which is what ships
|
||||
*
|
||||
* `runToggle` runs the real reducer, then feeds its output through the real
|
||||
* converter to get the shipped expression.
|
||||
*/
|
||||
|
||||
type SimpleItem = {
|
||||
key: string;
|
||||
op: string;
|
||||
value: TagFilterItem['value'];
|
||||
};
|
||||
|
||||
function toTagItem(item: SimpleItem, idx: number): TagFilterItem {
|
||||
return {
|
||||
id: `id-${idx}`,
|
||||
key: { key: item.key, type: 'tag' } as TagFilterItem['key'],
|
||||
op: item.op,
|
||||
value: item.value,
|
||||
};
|
||||
}
|
||||
|
||||
// Serialises items into an expression (via the app's own converter) so a case's
|
||||
// starting state is self-consistent (items and expression agree), the way it
|
||||
// would be in the app after a prior round trip.
|
||||
const serializeItems = (items: SimpleItem[]): string =>
|
||||
convertFiltersToExpression({ items: items.map(toTagItem), op: 'AND' })
|
||||
.expression;
|
||||
|
||||
function buildQuery(items: SimpleItem[], expression: string): Query {
|
||||
return {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: items.map(toTagItem), op: 'AND' },
|
||||
filter: { expression },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as Query;
|
||||
}
|
||||
|
||||
// Simulates the URL round trip: rebuild the shipped expression from the items,
|
||||
// reconciled against whatever expression the toggle left behind. Trimmed to
|
||||
// absorb a converter quirk that leaves a trailing space when it widens an
|
||||
// operator in place (e.g. `=` -> `IN`).
|
||||
function roundTripExpression(
|
||||
items: TagFilterItem[],
|
||||
emittedExpression: string,
|
||||
): string {
|
||||
const filters: TagFilter = { items, op: 'AND' };
|
||||
const { filter } = convertFiltersToExpressionWithExistingQuery(
|
||||
filters,
|
||||
emittedExpression,
|
||||
);
|
||||
return (filter?.expression ?? '').trim();
|
||||
}
|
||||
|
||||
interface ToggleAction {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
isOnlyOrAllClicked?: boolean;
|
||||
previousState?: CheckedState;
|
||||
sectionType?: SectionType;
|
||||
source?: QuickFiltersSource;
|
||||
attributeValues?: string[];
|
||||
}
|
||||
|
||||
interface ToggleCase {
|
||||
name: string;
|
||||
initial?: { items?: SimpleItem[]; expression?: string };
|
||||
action: ToggleAction;
|
||||
expected: { items: SimpleItem[]; expression: string };
|
||||
}
|
||||
|
||||
function runToggle(c: ToggleCase): { items: SimpleItem[]; expression: string } {
|
||||
const initialItems = c.initial?.items ?? [];
|
||||
const initialExpression =
|
||||
c.initial?.expression ?? serializeItems(initialItems);
|
||||
|
||||
const result = applyCheckboxToggle({
|
||||
currentQuery: buildQuery(initialItems, initialExpression),
|
||||
activeQueryIndex: 0,
|
||||
filter: { attributeKey: { key: KEY, type: 'tag' } } as never,
|
||||
source: c.action.source ?? QuickFiltersSource.LOGS_EXPLORER,
|
||||
attributeValues: c.action.attributeValues ?? ['a', 'b', 'c'],
|
||||
value: c.action.value,
|
||||
checked: c.action.checked,
|
||||
isOnlyOrAllClicked: c.action.isOnlyOrAllClicked ?? false,
|
||||
previousState: c.action.previousState,
|
||||
sectionType: c.action.sectionType,
|
||||
});
|
||||
|
||||
const active = result.builder.queryData[0];
|
||||
const items = active?.filters?.items ?? [];
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
key: item.key?.key ?? '',
|
||||
op: item.op,
|
||||
value: item.value,
|
||||
})),
|
||||
expression: roundTripExpression(items, active?.filter?.expression ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
// Flat list. Every row asserts both the structured items and the shipped
|
||||
// (round-tripped) expression, which must stay in sync.
|
||||
const TOGGLE_CASES: ToggleCase[] = [
|
||||
{
|
||||
name: 'no clause, checked -> IN',
|
||||
action: { value: 'a', checked: true },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'in', value: 'a' }],
|
||||
expression: `service.name in ['a']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'no clause, unchecked -> NOT IN',
|
||||
action: { value: 'a', checked: false },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'not in', value: 'a' }],
|
||||
expression: `service.name not in ['a']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'no clause, unchecked on infra -> not in',
|
||||
action: {
|
||||
value: 'a',
|
||||
checked: false,
|
||||
source: QuickFiltersSource.INFRA_MONITORING,
|
||||
},
|
||||
// `nin` is what the source asks for, but re-deriving the expression
|
||||
// normalises it. Nothing observes the difference: both infra pages send
|
||||
// `filter.expression` and never `filters.items`.
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'not in', value: 'a' }],
|
||||
expression: `service.name not in ['a']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'IN, check another value -> appended',
|
||||
initial: { items: [{ key: KEY, op: 'in', value: ['a'] }] },
|
||||
action: { value: 'b', checked: true },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'in', value: ['a', 'b'] }],
|
||||
expression: `service.name in ['a', 'b']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'IN, check when value is scalar -> promoted to array',
|
||||
initial: { items: [{ key: KEY, op: 'in', value: 'a' }] },
|
||||
action: { value: 'b', checked: true },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'in', value: ['a', 'b'] }],
|
||||
expression: `service.name in ['a', 'b']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'IN, uncheck one of many -> filtered out',
|
||||
initial: { items: [{ key: KEY, op: 'in', value: ['a', 'b'] }] },
|
||||
action: { value: 'a', checked: false },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'in', value: ['b'] }],
|
||||
expression: `service.name in ['b']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'IN, uncheck last value in array -> clause gone',
|
||||
initial: { items: [{ key: KEY, op: 'in', value: ['a'] }] },
|
||||
action: { value: 'a', checked: false },
|
||||
expected: { items: [], expression: '' },
|
||||
},
|
||||
{
|
||||
name: 'IN, uncheck scalar value -> clause gone',
|
||||
initial: { items: [{ key: KEY, op: 'in', value: 'a' }] },
|
||||
action: { value: 'a', checked: false },
|
||||
expected: { items: [], expression: '' },
|
||||
},
|
||||
{
|
||||
name: 'IN, uncheck in RELATED section -> replaced by NOT IN for that value',
|
||||
initial: { items: [{ key: KEY, op: 'in', value: ['a', 'b'] }] },
|
||||
action: { value: 'a', checked: false, sectionType: SectionType.RELATED },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'not in', value: 'a' }],
|
||||
expression: `service.name not in ['a']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'NOT IN, was unchecked then checked -> replaced by IN for that value',
|
||||
initial: { items: [{ key: KEY, op: 'not in', value: ['a'] }] },
|
||||
action: { value: 'b', checked: true, previousState: 'unchecked' },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'in', value: 'b' }],
|
||||
expression: `service.name in ['b']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'NOT IN, re-checking an excluded value clears it, not flips it to IN',
|
||||
initial: { items: [{ key: KEY, op: 'not in', value: ['a'] }] },
|
||||
action: { value: 'a', checked: true, previousState: 'unchecked' },
|
||||
expected: { items: [], expression: '' },
|
||||
},
|
||||
{
|
||||
name: 'NOT IN, re-checking one of several excluded values keeps the rest',
|
||||
initial: { items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }] },
|
||||
action: { value: 'a', checked: true, previousState: 'unchecked' },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'not in', value: ['b'] }],
|
||||
expression: `service.name not in ['b']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'NOT IN, exclude another value -> appended',
|
||||
initial: { items: [{ key: KEY, op: 'not in', value: ['a'] }] },
|
||||
action: { value: 'b', checked: false },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],
|
||||
expression: `service.name not in ['a', 'b']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'NOT IN, exclude when scalar -> promoted to array',
|
||||
initial: { items: [{ key: KEY, op: 'not in', value: 'a' }] },
|
||||
action: { value: 'b', checked: false },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],
|
||||
expression: `service.name not in ['a', 'b']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'NOT IN, check an excluded value -> removed from array',
|
||||
initial: { items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }] },
|
||||
action: { value: 'a', checked: true },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'not in', value: ['b'] }],
|
||||
expression: `service.name not in ['b']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'NOT IN, check last excluded value in array -> clause gone',
|
||||
initial: { items: [{ key: KEY, op: 'not in', value: ['a'] }] },
|
||||
action: { value: 'a', checked: true },
|
||||
expected: { items: [], expression: '' },
|
||||
},
|
||||
{
|
||||
name: 'NOT IN, check excluded scalar value -> clause gone',
|
||||
initial: { items: [{ key: KEY, op: 'not in', value: 'a' }] },
|
||||
action: { value: 'a', checked: true },
|
||||
expected: { items: [], expression: '' },
|
||||
},
|
||||
{
|
||||
name: '= check another value -> promoted to IN array',
|
||||
initial: { items: [{ key: KEY, op: '=', value: 'a' }] },
|
||||
action: { value: 'b', checked: true },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'in', value: ['a', 'b'] }],
|
||||
expression: `service.name in ['a', 'b']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '= uncheck -> clause gone',
|
||||
initial: { items: [{ key: KEY, op: '=', value: 'a' }] },
|
||||
action: { value: 'a', checked: false },
|
||||
expected: { items: [], expression: '' },
|
||||
},
|
||||
{
|
||||
name: '!= exclude another value -> promoted to NOT IN array',
|
||||
initial: { items: [{ key: KEY, op: '!=', value: 'a' }] },
|
||||
action: { value: 'b', checked: false },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],
|
||||
expression: `service.name not in ['a', 'b']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '!= exclude another value on infra -> not in array',
|
||||
initial: { items: [{ key: KEY, op: '!=', value: 'a' }] },
|
||||
action: {
|
||||
value: 'b',
|
||||
checked: false,
|
||||
source: QuickFiltersSource.INFRA_MONITORING,
|
||||
},
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],
|
||||
expression: `service.name not in ['a', 'b']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '!= check -> clause gone',
|
||||
initial: { items: [{ key: KEY, op: '!=', value: 'a' }] },
|
||||
action: { value: 'a', checked: true },
|
||||
expected: { items: [], expression: '' },
|
||||
},
|
||||
{
|
||||
name: 'Only with no clause -> IN scalar',
|
||||
action: { value: 'a', checked: true, isOnlyOrAllClicked: true },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'in', value: 'a' }],
|
||||
expression: `service.name in ['a']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Only replaces a multi-value IN with a single value',
|
||||
initial: { items: [{ key: KEY, op: 'in', value: ['a', 'b'] }] },
|
||||
action: { value: 'a', checked: true, isOnlyOrAllClicked: true },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'in', value: 'a' }],
|
||||
expression: `service.name in ['a']`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'All (clicking the sole selected value) -> clause gone',
|
||||
initial: { items: [{ key: KEY, op: 'in', value: ['a'] }] },
|
||||
action: { value: 'a', checked: true, isOnlyOrAllClicked: true },
|
||||
expected: { items: [], expression: '' },
|
||||
},
|
||||
{
|
||||
name: 'dropping the last clause keeps other keys in the expression',
|
||||
initial: {
|
||||
items: [{ key: KEY, op: 'in', value: 'a' }],
|
||||
expression: `${KEY} = 'a' AND http.method = 'GET'`,
|
||||
},
|
||||
action: { value: 'a', checked: false },
|
||||
// The seeded items omit the http.method clause the expression carries;
|
||||
// re-deriving reconciles it back, which is why items is not empty here.
|
||||
expected: {
|
||||
items: [{ key: 'http.method', op: '=', value: 'GET' }],
|
||||
expression: `http.method = 'GET'`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dropping the last clause strips the prefixed spelling too',
|
||||
initial: {
|
||||
items: [{ key: 'resource.service.name', op: 'in', value: 'a' }],
|
||||
expression: `resource.service.name = 'a'`,
|
||||
},
|
||||
action: { value: 'a', checked: false },
|
||||
expected: { items: [], expression: '' },
|
||||
},
|
||||
{
|
||||
name: 'removing the value must keep a free-form clause on the same key',
|
||||
initial: {
|
||||
items: [{ key: KEY, op: '=', value: 'a' }],
|
||||
expression: `${KEY} = 'a' AND ${KEY} CONTAINS 'keepme'`,
|
||||
},
|
||||
action: { value: 'a', checked: false },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'contains', value: 'keepme' }],
|
||||
expression: `service.name CONTAINS 'keepme'`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'a second clause on the same key must not survive an add',
|
||||
initial: {
|
||||
items: [{ key: KEY, op: 'in', value: ['a'] }],
|
||||
expression: `${KEY} IN ['a'] AND ${KEY} != 'z'`,
|
||||
},
|
||||
action: { value: 'b', checked: true },
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'in', value: ['a', 'b'] }],
|
||||
expression: `service.name in ['a', 'b']`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('applyCheckboxToggle (items + shipped expression stay in sync)', () => {
|
||||
it.each(TOGGLE_CASES)('$name', (c) => {
|
||||
const got = runToggle(c);
|
||||
expect(got.items).toStrictEqual(c.expected.items);
|
||||
expect(got.expression).toBe(c.expected.expression);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNotInOperator', () => {
|
||||
it('returns short "nin" for infra monitoring', () => {
|
||||
expect(getNotInOperator(QuickFiltersSource.INFRA_MONITORING)).toBe('nin');
|
||||
});
|
||||
|
||||
it('returns long "not in" for other sources', () => {
|
||||
expect(getNotInOperator(QuickFiltersSource.LOGS_EXPLORER)).toBe('not in');
|
||||
expect(getNotInOperator(QuickFiltersSource.TRACES_EXPLORER)).toBe('not in');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveCheckboxState', () => {
|
||||
const attributeValues = ['a', 'b', 'c'];
|
||||
|
||||
const state = (items: TagFilterItem[] | undefined): Record<string, boolean> =>
|
||||
deriveCheckboxState({ attributeValues, filterItems: items, filterKey: KEY });
|
||||
|
||||
it('no clause for key -> everything checked', () => {
|
||||
expect(state([])).toStrictEqual({ a: true, b: true, c: true });
|
||||
expect(state(undefined)).toStrictEqual({ a: true, b: true, c: true });
|
||||
});
|
||||
|
||||
it('unrelated clause only -> everything checked', () => {
|
||||
expect(
|
||||
state([toTagItem({ key: 'other', op: 'in', value: ['a'] }, 0)]),
|
||||
).toStrictEqual({ a: true, b: true, c: true });
|
||||
});
|
||||
|
||||
it('IN [list] -> only listed values checked', () => {
|
||||
expect(
|
||||
state([toTagItem({ key: KEY, op: 'in', value: ['a', 'c'] }, 0)]),
|
||||
).toStrictEqual({ a: true, b: false, c: true });
|
||||
});
|
||||
|
||||
it('= "value" -> only that value checked', () => {
|
||||
expect(
|
||||
state([toTagItem({ key: KEY, op: '=', value: 'b' }, 0)]),
|
||||
).toStrictEqual({ a: false, b: true, c: false });
|
||||
});
|
||||
|
||||
it('NOT IN [list] -> everything except excluded checked', () => {
|
||||
expect(
|
||||
state([toTagItem({ key: KEY, op: 'not in', value: ['a'] }, 0)]),
|
||||
).toStrictEqual({ a: false, b: true, c: true });
|
||||
});
|
||||
|
||||
it('!= "value" -> everything except that value checked', () => {
|
||||
expect(
|
||||
state([toTagItem({ key: KEY, op: '!=', value: 'b' }, 0)]),
|
||||
).toStrictEqual({ a: true, b: false, c: true });
|
||||
});
|
||||
|
||||
it('matches by base key across context prefixes', () => {
|
||||
expect(
|
||||
state([
|
||||
toTagItem({ key: 'resource.service.name', op: 'in', value: ['a'] }, 0),
|
||||
]),
|
||||
).toStrictEqual({ a: true, b: false, c: false });
|
||||
});
|
||||
|
||||
it('coerces boolean / number values to string keys', () => {
|
||||
expect(
|
||||
deriveCheckboxState({
|
||||
attributeValues: ['true', '42'],
|
||||
filterItems: [toTagItem({ key: KEY, op: '=', value: true }, 0)],
|
||||
filterKey: KEY,
|
||||
}),
|
||||
).toStrictEqual({ true: true, '42': false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearFilterFromQuery', () => {
|
||||
it('removes the key from items and expression at the active index only', () => {
|
||||
const query = {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
toTagItem({ key: KEY, op: 'in', value: ['a'] }, 0),
|
||||
toTagItem({ key: 'http.method', op: '=', value: 'GET' }, 1),
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: `${KEY} = 'a' AND http.method = 'GET'` },
|
||||
},
|
||||
{
|
||||
filters: {
|
||||
items: [toTagItem({ key: KEY, op: 'in', value: ['a'] }, 2)],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: `${KEY} = 'a'` },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as Query;
|
||||
|
||||
const result = clearFilterFromQuery({
|
||||
currentQuery: query,
|
||||
filter: { attributeKey: { key: KEY, type: 'tag' } } as never,
|
||||
activeQueryIndex: 0,
|
||||
});
|
||||
|
||||
const active = result.builder.queryData[0];
|
||||
expect(active.filters?.items).toStrictEqual([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.method' }),
|
||||
}),
|
||||
]);
|
||||
expect(active.filter?.expression).toBe(`http.method = 'GET'`);
|
||||
|
||||
// Other queries keep both halves: stripping their expression while leaving
|
||||
// their items alone only churned a clause the round trip put straight back.
|
||||
const other = result.builder.queryData[1];
|
||||
expect(other.filters?.items).toHaveLength(1);
|
||||
expect(other.filter?.expression).toBe(`${KEY} = 'a'`);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
removeKeysFromExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFiltersSource,
|
||||
@@ -10,13 +13,33 @@ import { cloneDeep, isArray } from 'lodash-es';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { isKeyMatch } from './utils';
|
||||
import { getKeySpellings, isKeyMatch } from './utils';
|
||||
import { CheckedState } from '../../types';
|
||||
import { SectionType } from './v2/itemRules';
|
||||
|
||||
export const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
|
||||
export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
|
||||
|
||||
// The operators this algebra emits, and so the only ones it may rewrite out of an
|
||||
// expression. A hand-written clause on the same key (CONTAINS, EXISTS, a range) is
|
||||
// none of its business and has to survive a toggle.
|
||||
const MANAGED_OPERATORS = [OPERATORS['='], OPERATORS['!='], 'in', 'not in'];
|
||||
|
||||
/**
|
||||
* Drops this filter's own clauses for `key` from `expression`, leaving every other
|
||||
* key and any clause the checkbox does not manage untouched. Matches all context
|
||||
* prefixes, since `isKeyMatch` treats `service.name` and `resource.service.name` as
|
||||
* the same filter but expression rewrites match keys literally.
|
||||
*/
|
||||
function removeManagedClauses(expression: string, key: string): string {
|
||||
return removeKeysFromExpression(
|
||||
expression,
|
||||
getKeySpellings(key),
|
||||
false,
|
||||
MANAGED_OPERATORS,
|
||||
);
|
||||
}
|
||||
|
||||
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
|
||||
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
|
||||
|
||||
@@ -102,8 +125,8 @@ export function deriveCheckboxState({
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new query with every clause for this attribute key removed, both
|
||||
* from the structured filter items and the raw filter expression.
|
||||
* Returns a new query with this filter's clauses for the attribute key removed from
|
||||
* the active query, both from the structured filter items and the raw expression.
|
||||
*/
|
||||
export function clearFilterFromQuery({
|
||||
currentQuery,
|
||||
@@ -118,24 +141,28 @@ export function clearFilterFromQuery({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item, idx) => ({
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeKeysFromExpression(item.filter?.expression ?? '', [
|
||||
filter.attributeKey.key,
|
||||
]),
|
||||
},
|
||||
filters: {
|
||||
...item.filters,
|
||||
items:
|
||||
idx === activeQueryIndex
|
||||
? item.filters?.items?.filter(
|
||||
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
|
||||
) || []
|
||||
: [...(item.filters?.items || [])],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
queryData: currentQuery.builder.queryData.map((item, idx) => {
|
||||
if (idx !== activeQueryIndex) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
filter: {
|
||||
expression: removeManagedClauses(
|
||||
item.filter?.expression ?? '',
|
||||
filter.attributeKey.key,
|
||||
),
|
||||
},
|
||||
filters: {
|
||||
...item.filters,
|
||||
items:
|
||||
item.filters?.items?.filter(
|
||||
(fil) => !isKeyMatch(fil.key?.key, filter.attributeKey.key),
|
||||
) || [],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -194,12 +221,6 @@ export function applyCheckboxToggle({
|
||||
(q) => !isKeyMatch(q.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(query.filter.expression, [
|
||||
filter.attributeKey.key,
|
||||
]);
|
||||
}
|
||||
|
||||
if (isOnlyOrAll === 'Only') {
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
@@ -267,12 +288,6 @@ export function applyCheckboxToggle({
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
} else if (isArray(currentFilter.value)) {
|
||||
// if we are removing some value when the running operator is IN we filter.
|
||||
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
|
||||
@@ -309,9 +324,10 @@ export function applyCheckboxToggle({
|
||||
? currentFilter.value.includes(value)
|
||||
: currentFilter.value === value;
|
||||
|
||||
// When clicking unchecked "Other" item, user wants to SELECT it
|
||||
// Replace NOT IN filter with IN [value]
|
||||
if (previousState === 'unchecked' && checked) {
|
||||
// When clicking an unchecked value that is not itself excluded, the user
|
||||
// wants to SELECT it: replace the NOT IN filter with IN [value]. A value
|
||||
// that IS in the exclusion list falls through to the removal branch below.
|
||||
if (previousState === 'unchecked' && checked && !isValueInFilter) {
|
||||
const newFilter: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
@@ -324,12 +340,6 @@ export function applyCheckboxToggle({
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
} else if (!checked || !isValueInFilter) {
|
||||
// Add to NOT IN when:
|
||||
// - checked=false (user explicitly unchecked to exclude)
|
||||
@@ -369,12 +379,6 @@ export function applyCheckboxToggle({
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
@@ -384,16 +388,6 @@ export function applyCheckboxToggle({
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value === value ? null : currentFilter.value,
|
||||
};
|
||||
if (newFilter.value === null && query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
query.filters.items = query.filters.items.filter(
|
||||
(item) => !isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
@@ -456,6 +450,18 @@ export function applyCheckboxToggle({
|
||||
}
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const synced = convertFiltersToExpressionWithExistingQuery(
|
||||
query.filters ?? { items: [], op: 'AND' },
|
||||
removeManagedClauses(
|
||||
query.filter?.expression ?? '',
|
||||
filter.attributeKey.key,
|
||||
),
|
||||
);
|
||||
query.filter = synced.filter;
|
||||
query.filters = synced.filters;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
|
||||
@@ -39,3 +39,16 @@ export function isKeyMatch(
|
||||
): boolean {
|
||||
return getKeyWithoutPrefix(itemKey) === getKeyWithoutPrefix(filterKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every spelling of a key that `isKeyMatch` treats as equal: the base name plus
|
||||
* each context-prefixed form. Expression rewrites match keys literally, so they
|
||||
* need the whole list where the items side only needs `isKeyMatch`.
|
||||
*/
|
||||
export function getKeySpellings(key: string | undefined): string[] {
|
||||
const base = getKeyWithoutPrefix(key);
|
||||
if (!base) {
|
||||
return [];
|
||||
}
|
||||
return [base, ...FIELD_CONTEXT_PREFIXES.map((prefix) => `${prefix}.${base}`)];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import {
|
||||
@@ -76,7 +75,7 @@ export function buildEntityMetricsChartConfig({
|
||||
show: true,
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
isTimeAxis: true,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
@@ -85,7 +84,6 @@ export function buildEntityMetricsChartConfig({
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
if (!apiResponse?.data?.result) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import {
|
||||
@@ -72,7 +71,7 @@ export function buildMeterChartConfig({
|
||||
show: true,
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
isTimeAxis: true,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
@@ -81,7 +80,6 @@ export function buildMeterChartConfig({
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
});
|
||||
|
||||
if (!apiResponse?.data?.result) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import type { WhereClauseConfig } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
|
||||
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
|
||||
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
|
||||
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
|
||||
@@ -263,10 +263,7 @@ function QueryBuilderSearchV2(
|
||||
return false;
|
||||
}, [currentState, query.aggregateAttribute?.dataType, query.dataSource]);
|
||||
|
||||
const dashboardDynamicVariables = useDashboardVariablesByType(
|
||||
'DYNAMIC',
|
||||
'values',
|
||||
);
|
||||
const dashboardDynamicVariables = useDynamicVariableSuggestions();
|
||||
|
||||
const { data, isFetching } = useGetAggregateKeys(
|
||||
{
|
||||
@@ -816,9 +813,8 @@ function QueryBuilderSearchV2(
|
||||
values.push(...(attributeValues?.payload?.[key] || []));
|
||||
|
||||
// here we want to suggest the variable name matching with the key here, we will go over the dynamic variables for the keys
|
||||
const variableName = dashboardDynamicVariables?.find(
|
||||
(variable) =>
|
||||
variable?.dynamicVariablesAttribute === currentFilterItem?.key?.key,
|
||||
const variableName = dashboardDynamicVariables.find(
|
||||
(variable) => variable.attribute === currentFilterItem?.key?.key,
|
||||
)?.name;
|
||||
|
||||
if (variableName) {
|
||||
|
||||
@@ -5,9 +5,8 @@ import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValues,
|
||||
} from 'constants/queryBuilder';
|
||||
import { IUseDashboardVariablesReturn } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import { DynamicVariableSuggestion } from 'providers/Dashboard/store/dynamicVariableSuggestions';
|
||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/variables';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -150,24 +149,14 @@ jest.mock('hooks/useSafeNavigate', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock dashboard variables
|
||||
const dashboardVariables = {
|
||||
service: {
|
||||
id: 'service',
|
||||
name: 'service',
|
||||
type: 'DYNAMIC' as IDashboardVariable['type'],
|
||||
dynamicVariablesAttribute: 'service.name',
|
||||
description: '',
|
||||
sort: 'DISABLED' as IDashboardVariable['sort'],
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
},
|
||||
};
|
||||
// Mock the dynamic variables the open dashboard would publish
|
||||
const dynamicVariableSuggestions = [
|
||||
{ name: 'service', attribute: 'service.name' },
|
||||
];
|
||||
|
||||
jest.mock('hooks/dashboard/useDashboardVariables', () => ({
|
||||
useDashboardVariables: (): IUseDashboardVariablesReturn => ({
|
||||
dashboardVariables: dashboardVariables,
|
||||
}),
|
||||
jest.mock('hooks/dashboard/useDynamicVariableSuggestions', () => ({
|
||||
useDynamicVariableSuggestions: (): DynamicVariableSuggestion[] =>
|
||||
dynamicVariableSuggestions,
|
||||
}));
|
||||
|
||||
describe('Suggestion Key -> Operator -> Value Flow', () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getWidgetQueryBuilder } from 'container/MetricsApplication/MetricsApplication.factory';
|
||||
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
|
||||
import { ServicesList } from 'types/api/metrics/getService';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
@@ -47,7 +46,6 @@ export const getQueryRangeRequestData = ({
|
||||
graphType: serviceMetricsWidget?.panelTypes,
|
||||
query: updatedQuery,
|
||||
globalSelectedInterval,
|
||||
variables: getDashboardVariables(),
|
||||
});
|
||||
});
|
||||
return requestData;
|
||||
|
||||
@@ -28,13 +28,11 @@ import { populateMultipleResults } from 'lib/query/populateMultipleResults';
|
||||
import { timeItems, timePreferance } from 'constants/timePreference';
|
||||
import PanelWrapper from 'container/WidgetCard/Panels/PanelWrapper';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useChartMutable } from 'hooks/useChartMutable';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -82,8 +80,6 @@ function FullView({
|
||||
setCurrentGraphRef(fullViewRef);
|
||||
}, [setCurrentGraphRef]);
|
||||
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
|
||||
const getSelectedTime = useCallback(
|
||||
() =>
|
||||
timeItems.find((e) => e.enum === (widget?.timePreferance || 'GLOBAL_TIME')),
|
||||
@@ -115,7 +111,6 @@ function FullView({
|
||||
graphType: getGraphType(selectedPanelType),
|
||||
query: updatedQuery,
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
variables: getDashboardVariables(dashboardVariables),
|
||||
fillGaps: widget.fillSpans,
|
||||
formatForWeb: selectedPanelType === PANEL_TYPES.TABLE,
|
||||
originalGraphType: selectedPanelType,
|
||||
@@ -126,7 +121,6 @@ function FullView({
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: widget?.timePreferance || 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
variables: getDashboardVariables(dashboardVariables),
|
||||
tableParams: {
|
||||
pagination: {
|
||||
offset: 0,
|
||||
|
||||
@@ -8,12 +8,9 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useScrollWidgetIntoView } from 'lib/visualization/hooks/useScrollWidgetIntoView';
|
||||
import { populateMultipleResults } from 'lib/query/populateMultipleResults';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useIsPanelWaitingOnVariable } from 'hooks/dashboard/useVariableFetchState';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
|
||||
import { getVariableReferencesInQuery } from 'lib/dashboardVariables/variableReference';
|
||||
import getTimeString from 'lib/getTimeString';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import isEmpty from 'lodash-es/isEmpty';
|
||||
@@ -45,7 +42,6 @@ function GridCardGraph({
|
||||
headerMenuList = [MenuItemKeys.View],
|
||||
isQueryEnabled,
|
||||
threshold,
|
||||
variables,
|
||||
version,
|
||||
onClickHandler,
|
||||
onDragSelect,
|
||||
@@ -113,25 +109,10 @@ function GridCardGraph({
|
||||
|
||||
const updatedQuery = widget?.query;
|
||||
|
||||
const referencedVariableNames = useMemo(() => {
|
||||
if (!variables || !updatedQuery) {
|
||||
return [];
|
||||
}
|
||||
const allNames = Object.values(variables)
|
||||
.map((v) => v.name)
|
||||
.filter((name): name is string => !!name);
|
||||
return getVariableReferencesInQuery(updatedQuery, allNames);
|
||||
}, [updatedQuery, variables]);
|
||||
|
||||
const isEmptyWidget =
|
||||
widget?.id === PANEL_TYPES.EMPTY_WIDGET || isEmpty(widget);
|
||||
|
||||
const isPanelWaitingOnAnyVariable = useIsPanelWaitingOnVariable(
|
||||
referencedVariableNames,
|
||||
);
|
||||
|
||||
const queryEnabledCondition =
|
||||
isVisible && !isEmptyWidget && isQueryEnabled && !isPanelWaitingOnAnyVariable;
|
||||
const queryEnabledCondition = isVisible && !isEmptyWidget && isQueryEnabled;
|
||||
|
||||
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() => {
|
||||
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
||||
@@ -140,7 +121,6 @@ function GridCardGraph({
|
||||
graphType: getGraphType(widget.panelTypes),
|
||||
query: updatedQuery,
|
||||
globalSelectedInterval,
|
||||
variables: getDashboardVariables(variables),
|
||||
fillGaps: widget.fillSpans,
|
||||
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
|
||||
start: customTimeRange?.startTime || start,
|
||||
@@ -191,7 +171,6 @@ function GridCardGraph({
|
||||
const queryResponse = useGetQueryRange(
|
||||
{
|
||||
...requestData,
|
||||
variables: getDashboardVariables(variables),
|
||||
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
|
||||
globalSelectedInterval:
|
||||
widget?.panelTypes === PANEL_TYPES.LIST && isLogsQuery
|
||||
@@ -214,14 +193,6 @@ function GridCardGraph({
|
||||
widget.timePreferance,
|
||||
widget.fillSpans,
|
||||
requestData,
|
||||
variables
|
||||
? Object.entries(variables).reduce((acc, [id, variable]) => {
|
||||
if (variable.name && referencedVariableNames.includes(variable.name)) {
|
||||
return { ...acc, [id]: variable.selectedValue };
|
||||
}
|
||||
return acc;
|
||||
}, {})
|
||||
: {},
|
||||
...(customTimeRange && customTimeRange.startTime && customTimeRange.endTime
|
||||
? [customTimeRange.startTime, customTimeRange.endTime]
|
||||
: []),
|
||||
@@ -303,9 +274,7 @@ function GridCardGraph({
|
||||
version={version}
|
||||
threshold={threshold}
|
||||
headerMenuList={menuList}
|
||||
isFetchingResponse={
|
||||
queryResponse.isFetching || isPanelWaitingOnAnyVariable
|
||||
}
|
||||
isFetchingResponse={queryResponse.isFetching}
|
||||
setRequestData={setRequestData}
|
||||
onClickHandler={onClickHandler}
|
||||
onDragSelect={onDragSelect}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import { Widgets } from 'types/api/widgets/widget';
|
||||
import {
|
||||
MetricQueryRangeSuccessResponse,
|
||||
@@ -52,7 +51,6 @@ export interface GridCardGraphProps {
|
||||
headerMenuList?: WidgetGraphComponentProps['headerMenuList'];
|
||||
onClickHandler?: OnClickPluginOpts['onClick'];
|
||||
isQueryEnabled: boolean;
|
||||
variables?: IDashboardVariables;
|
||||
version?: string;
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
customOnDragSelect?: (start: number, end: number) => void;
|
||||
|
||||
@@ -26,8 +26,8 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('hooks/dashboard/useDashboardVariablesByType', () => ({
|
||||
useDashboardVariablesByType: (): unknown[] => mockDynamicVariables,
|
||||
jest.mock('hooks/dashboard/useDynamicVariableSuggestions', () => ({
|
||||
useDynamicVariableSuggestions: (): unknown[] => mockDynamicVariables,
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
@@ -64,11 +64,12 @@ describe('useResolveQuery', () => {
|
||||
expect(resolved).toBe(QUERY);
|
||||
});
|
||||
|
||||
it('resolves through substitute_vars when the dashboard has variables', async () => {
|
||||
it('resolves through substitute_vars when the dashboard has dynamic variables', async () => {
|
||||
mockGetSubstituteVars.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { compositeQuery: {} },
|
||||
});
|
||||
mockDynamicVariables.push({ name: 'env', attribute: 'deployment.env' });
|
||||
|
||||
const { result } = renderHook(() => useUpdatedQuery(), {
|
||||
wrapper: MockQueryClientProvider,
|
||||
@@ -76,13 +77,6 @@ describe('useResolveQuery', () => {
|
||||
|
||||
const resolved = await result.current.getUpdatedQuery({
|
||||
widgetConfig: WIDGET_CONFIG,
|
||||
dashboardData: {
|
||||
data: {
|
||||
variables: {
|
||||
env: { name: 'env', selectedValue: 'prod' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockGetSubstituteVars).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -7,8 +7,7 @@ import { getSubstituteVars } from 'api/dashboard/substitute_vars';
|
||||
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { timePreferenceType } from 'constants/timePreference';
|
||||
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
|
||||
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
|
||||
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -21,7 +20,6 @@ interface UseUpdatedQueryOptions {
|
||||
panelTypes: PANEL_TYPES;
|
||||
timePreferance: timePreferenceType;
|
||||
};
|
||||
dashboardData?: any;
|
||||
}
|
||||
|
||||
interface UseUpdatedQueryResult {
|
||||
@@ -37,34 +35,27 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
|
||||
|
||||
const queryRangeMutation = useMutation(getSubstituteVars);
|
||||
|
||||
const dashboardDynamicVariables = useDashboardVariablesByType(
|
||||
'DYNAMIC',
|
||||
'values',
|
||||
);
|
||||
const dashboardDynamicVariables = useDynamicVariableSuggestions();
|
||||
|
||||
const getUpdatedQuery = useCallback(
|
||||
async ({
|
||||
widgetConfig,
|
||||
dashboardData,
|
||||
}: UseUpdatedQueryOptions): Promise<Query> => {
|
||||
const variables = getDashboardVariables(dashboardData?.data?.variables);
|
||||
|
||||
async ({ widgetConfig }: UseUpdatedQueryOptions): Promise<Query> => {
|
||||
// `/substitute_vars` only rewrites `$variable` references, so on surfaces with no
|
||||
// dashboard behind them (APM, Celery, API monitoring) the round-trip is a no-op.
|
||||
if (isEmpty(variables) && isEmpty(dashboardDynamicVariables)) {
|
||||
if (isEmpty(dashboardDynamicVariables)) {
|
||||
return widgetConfig.query;
|
||||
}
|
||||
|
||||
// Prepare query payload with resolved variables
|
||||
const { queryPayload } = prepareQueryRangePayloadV5({
|
||||
query: widgetConfig.query,
|
||||
graphType: getGraphType(widgetConfig.panelTypes),
|
||||
selectedTime: widgetConfig.timePreferance,
|
||||
globalSelectedInterval,
|
||||
variables,
|
||||
originalGraphType: widgetConfig.panelTypes,
|
||||
dynamicVariables: dashboardDynamicVariables,
|
||||
});
|
||||
const { queryPayload } = prepareQueryRangePayloadV5(
|
||||
{
|
||||
query: widgetConfig.query,
|
||||
graphType: getGraphType(widgetConfig.panelTypes),
|
||||
selectedTime: widgetConfig.timePreferance,
|
||||
globalSelectedInterval,
|
||||
originalGraphType: widgetConfig.panelTypes,
|
||||
},
|
||||
dashboardDynamicVariables,
|
||||
);
|
||||
|
||||
// Execute query and process results
|
||||
const queryResult = await queryRangeMutation.mutateAsync(queryPayload);
|
||||
|
||||
@@ -1,242 +1,40 @@
|
||||
import React from 'react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
|
||||
import useGetResolvedText from '../useGetResolvedText';
|
||||
|
||||
// Create a mock function that we can modify per test
|
||||
let mockDashboardVariables: IDashboardVariables = {};
|
||||
|
||||
// Mock the useDashboardVariables hook
|
||||
jest.mock('hooks/dashboard/useDashboardVariables', () => ({
|
||||
useDashboardVariables: jest.fn(() => ({
|
||||
dashboardVariables: mockDashboardVariables,
|
||||
})),
|
||||
}));
|
||||
import useGetResolvedText from 'hooks/dashboard/useGetResolvedText';
|
||||
|
||||
describe('useGetResolvedText', () => {
|
||||
const SERVICE_VAR = 'test, app +2-|-test, app, frontend, env';
|
||||
const SEVERITY_VAR = 'DEBUG, INFO-|-DEBUG, INFO';
|
||||
const EXPECTED_FULL_TEXT =
|
||||
'Logs count in test, app, frontend, env in DEBUG, INFO';
|
||||
const TRUNCATED_SERVICE = 'test, app +2';
|
||||
const TEXT_TEMPLATE = 'Logs count in $service.name in $severity';
|
||||
|
||||
const renderHookWithProps = (
|
||||
props: {
|
||||
text: string | React.ReactNode;
|
||||
maxLength?: number;
|
||||
matcher?: string;
|
||||
},
|
||||
variables?: Record<string, string | number | boolean>,
|
||||
): any => {
|
||||
if (variables) {
|
||||
mockDashboardVariables = Object.entries(
|
||||
variables,
|
||||
).reduce<IDashboardVariables>((acc, [key, value]) => {
|
||||
acc[key] = {
|
||||
id: key,
|
||||
name: key,
|
||||
description: '',
|
||||
type: 'CUSTOM' as const,
|
||||
sort: 'DISABLED' as const,
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
selectedValue: value,
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
} else {
|
||||
mockDashboardVariables = {};
|
||||
}
|
||||
return renderHook(() => useGetResolvedText(props));
|
||||
};
|
||||
|
||||
it('should resolve variables with truncated and full text', () => {
|
||||
const text = TEXT_TEMPLATE;
|
||||
const variables = {
|
||||
'service.name': SERVICE_VAR,
|
||||
severity: SEVERITY_VAR,
|
||||
};
|
||||
|
||||
const { result } = renderHookWithProps({ text }, variables);
|
||||
|
||||
expect(result.current.truncatedText).toBe(
|
||||
`Logs count in ${TRUNCATED_SERVICE} in DEBUG, INFO`,
|
||||
it('returns the text unchanged when it fits within maxLength', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useGetResolvedText({ text: 'Logs count', maxLength: 100 }),
|
||||
);
|
||||
expect(result.current.fullText).toBe(EXPECTED_FULL_TEXT);
|
||||
|
||||
expect(result.current.fullText).toBe('Logs count');
|
||||
expect(result.current.truncatedText).toBe('Logs count');
|
||||
});
|
||||
|
||||
it('should handle text with maxLength truncation', () => {
|
||||
const text = TEXT_TEMPLATE;
|
||||
const variables = {
|
||||
'service.name': SERVICE_VAR,
|
||||
severity: SEVERITY_VAR,
|
||||
};
|
||||
it('returns the text unchanged when no maxLength is given', () => {
|
||||
const text = 'a'.repeat(200);
|
||||
const { result } = renderHook(() => useGetResolvedText({ text }));
|
||||
|
||||
const { result } = renderHookWithProps({ text, maxLength: 20 }, variables);
|
||||
|
||||
expect(result.current.truncatedText).toBe('Logs count in test, a...');
|
||||
expect(result.current.fullText).toBe(EXPECTED_FULL_TEXT);
|
||||
});
|
||||
|
||||
it('should handle multiple occurrences of the same variable', () => {
|
||||
const text = 'Logs count in $service.name and $service.name';
|
||||
const variables = {
|
||||
'service.name': SERVICE_VAR,
|
||||
};
|
||||
|
||||
const { result } = renderHookWithProps({ text }, variables);
|
||||
|
||||
expect(result.current.truncatedText).toBe(
|
||||
'Logs count in test, app +2 and test, app +2',
|
||||
);
|
||||
expect(result.current.fullText).toBe(
|
||||
'Logs count in test, app, frontend, env and test, app, frontend, env',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle different variable formats', () => {
|
||||
const text =
|
||||
'Logs in $service.name, {{service.name}}, [[service.name]] - $dyn-service.name';
|
||||
const variables = {
|
||||
'service.name': SERVICE_VAR,
|
||||
'$dyn-service.name': 'dyn-1, dyn-2',
|
||||
};
|
||||
|
||||
const { result } = renderHookWithProps({ text }, variables);
|
||||
|
||||
expect(result.current.truncatedText).toBe(
|
||||
'Logs in test, app +2, test, app +2, test, app +2 - dyn-1, dyn-2',
|
||||
);
|
||||
expect(result.current.fullText).toBe(
|
||||
'Logs in test, app, frontend, env, test, app, frontend, env, test, app, frontend, env - dyn-1, dyn-2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle custom matcher', () => {
|
||||
const text = 'Logs count in #service.name in #severity';
|
||||
const variables = {
|
||||
'service.name': SERVICE_VAR,
|
||||
severity: SEVERITY_VAR,
|
||||
};
|
||||
|
||||
const { result } = renderHookWithProps({ text, matcher: '#' }, variables);
|
||||
|
||||
expect(result.current.truncatedText).toBe(
|
||||
'Logs count in test, app +2 in DEBUG, INFO',
|
||||
);
|
||||
expect(result.current.fullText).toBe(EXPECTED_FULL_TEXT);
|
||||
});
|
||||
|
||||
it('should handle non-string variable values', () => {
|
||||
const text = 'Count: $count, Active: $active';
|
||||
const variables = {
|
||||
count: 42,
|
||||
active: true,
|
||||
};
|
||||
|
||||
const { result } = renderHookWithProps({ text }, variables);
|
||||
|
||||
expect(result.current.fullText).toBe('Count: 42, Active: true');
|
||||
expect(result.current.truncatedText).toBe('Count: 42, Active: true');
|
||||
});
|
||||
|
||||
it('should keep original text for undefined variables', () => {
|
||||
const text = 'Logs count in $service.name in $unknown';
|
||||
const variables = {
|
||||
'service.name': SERVICE_VAR,
|
||||
};
|
||||
|
||||
const { result } = renderHookWithProps({ text }, variables);
|
||||
|
||||
expect(result.current.truncatedText).toBe(
|
||||
'Logs count in test, app +2 in $unknown',
|
||||
);
|
||||
expect(result.current.fullText).toBe(
|
||||
'Logs count in test, app, frontend, env in $unknown',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle non-string text input (ReactNode)', () => {
|
||||
const reactNodeText = <div>Test ReactNode</div>;
|
||||
const variables = {
|
||||
'service.name': SERVICE_VAR,
|
||||
};
|
||||
|
||||
const { result } = renderHookWithProps(
|
||||
{
|
||||
text: reactNodeText,
|
||||
},
|
||||
variables,
|
||||
);
|
||||
|
||||
// Should return the ReactNode unchanged
|
||||
expect(result.current.fullText).toBe(reactNodeText);
|
||||
expect(result.current.truncatedText).toBe(reactNodeText);
|
||||
});
|
||||
|
||||
it('should handle number input', () => {
|
||||
const text = 123;
|
||||
const variables = {
|
||||
'service.name': SERVICE_VAR,
|
||||
};
|
||||
|
||||
const { result } = renderHookWithProps(
|
||||
{
|
||||
text,
|
||||
},
|
||||
variables,
|
||||
);
|
||||
|
||||
// Should return the number unchanged
|
||||
expect(result.current.fullText).toBe(text);
|
||||
expect(result.current.truncatedText).toBe(text);
|
||||
});
|
||||
|
||||
it('should handle boolean input', () => {
|
||||
const text = true;
|
||||
const variables = {
|
||||
'service.name': SERVICE_VAR,
|
||||
};
|
||||
|
||||
const { result } = renderHookWithProps(
|
||||
{
|
||||
text,
|
||||
},
|
||||
variables,
|
||||
it('truncates to maxLength with an ellipsis and keeps the full text', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useGetResolvedText({ text: 'Logs count in production', maxLength: 20 }),
|
||||
);
|
||||
|
||||
// Should return the boolean unchanged
|
||||
expect(result.current.fullText).toBe(text);
|
||||
expect(result.current.truncatedText).toBe(text);
|
||||
expect(result.current.truncatedText).toBe('Logs count in pro...');
|
||||
expect(result.current.truncatedText).toHaveLength(20);
|
||||
expect(result.current.fullText).toBe('Logs count in production');
|
||||
});
|
||||
|
||||
it('should handle complex variable names with improved patterns', () => {
|
||||
const text = 'API: $api.v1.endpoint Config: $config.database.host';
|
||||
const variables = {
|
||||
'api.v1.endpoint': '/users',
|
||||
'config.database.host': 'localhost:5432',
|
||||
};
|
||||
|
||||
const { result } = renderHookWithProps({ text }, variables);
|
||||
|
||||
expect(result.current.fullText).toBe('API: /users Config: localhost:5432');
|
||||
expect(result.current.truncatedText).toBe(
|
||||
'API: /users Config: localhost:5432',
|
||||
it('passes non-string content through untouched', () => {
|
||||
const node = <span>title</span>;
|
||||
const { result } = renderHook(() =>
|
||||
useGetResolvedText({ text: node, maxLength: 2 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should stop at punctuation boundaries correctly', () => {
|
||||
const text = 'Status: $service.name, Error: $error.type;';
|
||||
const variables = {
|
||||
'service.name': 'web-api',
|
||||
'error.type': 'timeout',
|
||||
};
|
||||
|
||||
const { result } = renderHookWithProps({ text }, variables);
|
||||
|
||||
expect(result.current.fullText).toBe('Status: web-api, Error: timeout;');
|
||||
expect(result.current.truncatedText).toBe('Status: web-api, Error: timeout;');
|
||||
expect(result.current.fullText).toBe(node);
|
||||
expect(result.current.truncatedText).toBe(node);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { dashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import { IDashboardVariablesStoreState } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import {
|
||||
VariableFetchState,
|
||||
variableFetchStore,
|
||||
} from 'providers/Dashboard/store/variableFetchStore';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/variables';
|
||||
|
||||
import { useIsPanelWaitingOnVariable } from '../useVariableFetchState';
|
||||
|
||||
function makeVariable(
|
||||
overrides: Partial<IDashboardVariable> & { id: string },
|
||||
): IDashboardVariable {
|
||||
return {
|
||||
name: overrides.id,
|
||||
description: '',
|
||||
type: 'QUERY',
|
||||
sort: 'DISABLED',
|
||||
multiSelect: false,
|
||||
showALLOption: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function resetStores(): void {
|
||||
variableFetchStore.set(() => ({
|
||||
states: {},
|
||||
lastUpdated: {},
|
||||
cycleIds: {},
|
||||
}));
|
||||
dashboardVariablesStore.set(() => ({
|
||||
dashboardId: '',
|
||||
variables: {},
|
||||
sortedVariablesArray: [],
|
||||
dependencyData: null,
|
||||
variableTypes: {},
|
||||
dynamicVariableOrder: [],
|
||||
}));
|
||||
}
|
||||
|
||||
function setFetchStates(states: Record<string, VariableFetchState>): void {
|
||||
variableFetchStore.set(() => ({
|
||||
states,
|
||||
lastUpdated: {},
|
||||
cycleIds: {},
|
||||
}));
|
||||
}
|
||||
|
||||
function setDashboardVariables(
|
||||
overrides: Partial<IDashboardVariablesStoreState>,
|
||||
): void {
|
||||
dashboardVariablesStore.set(() => ({
|
||||
dashboardId: '',
|
||||
variables: {},
|
||||
sortedVariablesArray: [],
|
||||
dependencyData: null,
|
||||
variableTypes: {},
|
||||
dynamicVariableOrder: [],
|
||||
...overrides,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('useIsPanelWaitingOnVariable', () => {
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
});
|
||||
|
||||
it('should return false when variableNames is empty', () => {
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable([]));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when all referenced variables are idle', () => {
|
||||
setFetchStates({ a: 'idle', b: 'idle' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
a: makeVariable({ id: 'a', selectedValue: 'val1' }),
|
||||
b: makeVariable({ id: 'b', selectedValue: 'val2' }),
|
||||
},
|
||||
variableTypes: { a: 'QUERY', b: 'QUERY' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a', 'b']));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when a variable is loading with empty selectedValue', () => {
|
||||
setFetchStates({ a: 'loading' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
a: makeVariable({ id: 'a', selectedValue: undefined }),
|
||||
},
|
||||
variableTypes: { a: 'QUERY' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when a variable is waiting with empty selectedValue', () => {
|
||||
setFetchStates({ a: 'waiting' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
a: makeVariable({ id: 'a', selectedValue: '' }),
|
||||
},
|
||||
variableTypes: { a: 'QUERY' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when a variable is revalidating with empty selectedValue', () => {
|
||||
setFetchStates({ a: 'revalidating' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
a: makeVariable({ id: 'a', selectedValue: undefined }),
|
||||
},
|
||||
variableTypes: { a: 'QUERY' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when a variable is loading but has a selectedValue', () => {
|
||||
setFetchStates({ a: 'loading' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
a: makeVariable({ id: 'a', selectedValue: 'some-value' }),
|
||||
},
|
||||
variableTypes: { a: 'QUERY' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for DYNAMIC variable with allSelected=true that is loading but has a selectedValue', () => {
|
||||
setFetchStates({ dyn: 'loading' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
dyn: makeVariable({
|
||||
id: 'dyn',
|
||||
type: 'DYNAMIC',
|
||||
selectedValue: 'some-val',
|
||||
allSelected: true,
|
||||
}),
|
||||
},
|
||||
variableTypes: { dyn: 'DYNAMIC' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for DYNAMIC variable with allSelected=true that is waiting but has a selectedValue', () => {
|
||||
setFetchStates({ dyn: 'waiting' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
dyn: makeVariable({
|
||||
id: 'dyn',
|
||||
type: 'DYNAMIC',
|
||||
selectedValue: 'val',
|
||||
allSelected: true,
|
||||
}),
|
||||
},
|
||||
variableTypes: { dyn: 'DYNAMIC' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for DYNAMIC variable with allSelected=true that is idle', () => {
|
||||
setFetchStates({ dyn: 'idle' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
dyn: makeVariable({
|
||||
id: 'dyn',
|
||||
type: 'DYNAMIC',
|
||||
selectedValue: 'val',
|
||||
allSelected: true,
|
||||
}),
|
||||
},
|
||||
variableTypes: { dyn: 'DYNAMIC' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-DYNAMIC variable with allSelected=false and non-empty value that is loading', () => {
|
||||
setFetchStates({ a: 'loading' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
a: makeVariable({
|
||||
id: 'a',
|
||||
selectedValue: 'val',
|
||||
allSelected: false,
|
||||
}),
|
||||
},
|
||||
variableTypes: { a: 'QUERY' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if any one of multiple variables is blocking', () => {
|
||||
setFetchStates({ a: 'idle', b: 'loading' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
a: makeVariable({ id: 'a', selectedValue: 'val' }),
|
||||
b: makeVariable({ id: 'b', selectedValue: undefined }),
|
||||
},
|
||||
variableTypes: { a: 'QUERY', b: 'QUERY' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a', 'b']));
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when variable has no entry in fetch store (treated as idle)', () => {
|
||||
setFetchStates({}); // no state entry for 'a'
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
a: makeVariable({ id: 'a', selectedValue: 'val' }),
|
||||
},
|
||||
variableTypes: { a: 'QUERY' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when variable is in error state with empty selectedValue', () => {
|
||||
setFetchStates({ a: 'error' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
a: makeVariable({ id: 'a', selectedValue: undefined }),
|
||||
},
|
||||
variableTypes: { a: 'QUERY' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should react to store updates', () => {
|
||||
setFetchStates({ a: 'loading' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
a: makeVariable({ id: 'a', selectedValue: undefined }),
|
||||
},
|
||||
variableTypes: { a: 'QUERY' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
// Simulate variable fetch completing
|
||||
act(() => {
|
||||
variableFetchStore.update((d) => {
|
||||
d.states.a = 'idle';
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle DYNAMIC variable with allSelected=false and empty selectedValue as blocking', () => {
|
||||
setFetchStates({ dyn: 'loading' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
dyn: makeVariable({
|
||||
id: 'dyn',
|
||||
type: 'DYNAMIC',
|
||||
selectedValue: undefined,
|
||||
allSelected: false,
|
||||
}),
|
||||
},
|
||||
variableTypes: { dyn: 'DYNAMIC' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['dyn']));
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle variable with array selectedValue as non-blocking when loading', () => {
|
||||
setFetchStates({ a: 'loading' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
a: makeVariable({ id: 'a', selectedValue: ['val1', 'val2'] }),
|
||||
},
|
||||
variableTypes: { a: 'QUERY' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle variable with empty array selectedValue as blocking when loading', () => {
|
||||
setFetchStates({ a: 'loading' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
a: makeVariable({ id: 'a', selectedValue: [] }),
|
||||
},
|
||||
variableTypes: { a: 'QUERY' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['a']));
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should find variable by name when store key differs from variable name', () => {
|
||||
setFetchStates({ myVar: 'loading' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
'uuid-abc-123': makeVariable({
|
||||
id: 'uuid-abc-123',
|
||||
name: 'myVar',
|
||||
selectedValue: undefined,
|
||||
}),
|
||||
},
|
||||
variableTypes: { myVar: 'QUERY' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['myVar']));
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect selectedValue when store key differs from variable name', () => {
|
||||
// When the variable has a value, it should not block even if loading
|
||||
setFetchStates({ myVar: 'loading' });
|
||||
setDashboardVariables({
|
||||
variables: {
|
||||
'uuid-abc-123': makeVariable({
|
||||
id: 'uuid-abc-123',
|
||||
name: 'myVar',
|
||||
selectedValue: 'production',
|
||||
}),
|
||||
},
|
||||
variableTypes: { myVar: 'QUERY' },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useIsPanelWaitingOnVariable(['myVar']));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
@@ -42,38 +41,10 @@ function useContextVariables({
|
||||
// ! To be noted: This customVariables is not Dashboard Custom Variables
|
||||
customVariables,
|
||||
}: UseContextVariablesProps): UseContextVariablesResult {
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
const globalTime = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
// Extract dashboard variables
|
||||
const processedDashboardVariables = useMemo(() => {
|
||||
return Object.entries(dashboardVariables)
|
||||
.filter(([, value]) => value.name)
|
||||
.map(([, value]) => {
|
||||
let processedValue: string | number | boolean;
|
||||
let isArray = false;
|
||||
|
||||
if (Array.isArray(value.selectedValue)) {
|
||||
processedValue = value.selectedValue.join(', ');
|
||||
isArray = true;
|
||||
} else if (value.selectedValue != null) {
|
||||
processedValue = value.selectedValue;
|
||||
} else {
|
||||
processedValue = '';
|
||||
}
|
||||
|
||||
return {
|
||||
name: value.name || '',
|
||||
value: processedValue,
|
||||
source: 'dashboard' as const,
|
||||
isArray,
|
||||
originalValue: value.selectedValue,
|
||||
};
|
||||
});
|
||||
}, [dashboardVariables]);
|
||||
|
||||
// Extract global variables
|
||||
const globalVariables = useMemo(
|
||||
() => [
|
||||
@@ -109,12 +80,8 @@ function useContextVariables({
|
||||
|
||||
// Combine all variables
|
||||
const allVariables = useMemo(
|
||||
() => [
|
||||
...processedDashboardVariables,
|
||||
...globalVariables,
|
||||
...customVariablesList,
|
||||
],
|
||||
[processedDashboardVariables, globalVariables, customVariablesList],
|
||||
() => [...globalVariables, ...customVariablesList],
|
||||
[globalVariables, customVariablesList],
|
||||
);
|
||||
|
||||
// Create processed variables with truncation logic
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useCallback, useRef, useSyncExternalStore } from 'react';
|
||||
import { dashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
|
||||
import {
|
||||
IDashboardVariablesStoreState,
|
||||
IUseDashboardVariablesReturn,
|
||||
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
|
||||
/**
|
||||
* Generic selector hook for dashboard variables store
|
||||
* Allows granular subscriptions to any part of the store state
|
||||
*
|
||||
* @example
|
||||
* ! Select top-level field
|
||||
* const variables = useDashboardVariablesSelector(s => s.variables);
|
||||
*
|
||||
* ! Select specific variable
|
||||
* const fooVar = useDashboardVariablesSelector(s => s.variables['foo']);
|
||||
*
|
||||
* ! Select derived value
|
||||
* const hasVariables = useDashboardVariablesSelector(s => Object.keys(s.variables).length > 0);
|
||||
*/
|
||||
export const useDashboardVariablesSelector = <T>(
|
||||
selector: (state: IDashboardVariablesStoreState) => T,
|
||||
): T => {
|
||||
const selectorRef = useRef(selector);
|
||||
selectorRef.current = selector;
|
||||
|
||||
const getSnapshot = useCallback(
|
||||
() => selectorRef.current(dashboardVariablesStore.getSnapshot()),
|
||||
[],
|
||||
);
|
||||
|
||||
return useSyncExternalStore(dashboardVariablesStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
export const useDashboardVariables = (): IUseDashboardVariablesReturn => {
|
||||
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
|
||||
|
||||
return { dashboardVariables };
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
IDashboardVariable,
|
||||
TVariableQueryType,
|
||||
} from 'types/api/dashboard/variables';
|
||||
|
||||
import { useDashboardVariables } from './useDashboardVariables';
|
||||
|
||||
export function useDashboardVariablesByType(
|
||||
variableType: TVariableQueryType,
|
||||
returnType: 'values',
|
||||
): IDashboardVariable[];
|
||||
export function useDashboardVariablesByType(
|
||||
variableType: TVariableQueryType,
|
||||
returnType?: 'entries',
|
||||
): [string, IDashboardVariable][];
|
||||
export function useDashboardVariablesByType(
|
||||
variableType: TVariableQueryType,
|
||||
returnType?: 'values' | 'entries',
|
||||
): IDashboardVariable[] | [string, IDashboardVariable][] {
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
|
||||
return useMemo(() => {
|
||||
const entries = Object.entries(dashboardVariables || {}).filter(
|
||||
(entry): entry is [string, IDashboardVariable] =>
|
||||
Boolean(entry[1].name) && entry[1].type === variableType,
|
||||
);
|
||||
return returnType === 'values' ? entries.map(([, value]) => value) : entries;
|
||||
}, [dashboardVariables, variableType, returnType]);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
DynamicVariableSuggestion,
|
||||
useDynamicVariableSuggestionsStore,
|
||||
} from 'providers/Dashboard/store/dynamicVariableSuggestions';
|
||||
|
||||
/**
|
||||
* Dynamic variables published by the dashboard currently open, so the query
|
||||
* builder can offer `$variable` as a value for the key each one backs. Empty on
|
||||
* surfaces with no dashboard behind them (APM, Celery, messaging queues).
|
||||
*/
|
||||
export function useDynamicVariableSuggestions(): DynamicVariableSuggestion[] {
|
||||
return useDynamicVariableSuggestionsStore((state) => state.suggestions);
|
||||
}
|
||||
@@ -1,18 +1,8 @@
|
||||
// this hook is used to get the resolved text of a variable, lets say we have a text - "Logs count in $service.name in $severity and $service.name and $severity $service.name"
|
||||
// and the values of service.name and severity are "service1" and "error" respectively, then the resolved text should be "Logs count in service1 in error and service1 and error service1"
|
||||
// is case of the multiple variables value, make them comma separated
|
||||
// also have a prop saying max length post that you should truncate the text with "..."
|
||||
// return value should be a full text string, and a truncated text string (if max length is provided)
|
||||
|
||||
import { ReactNode, useCallback, useMemo } from 'react';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
|
||||
interface UseGetResolvedTextProps {
|
||||
text: string | ReactNode;
|
||||
variables?: Record<string, string | number | boolean>;
|
||||
maxLength?: number;
|
||||
matcher?: string;
|
||||
maxValues?: number; // Maximum number of values to show before adding +n more
|
||||
}
|
||||
|
||||
interface ResolvedTextResult {
|
||||
@@ -20,173 +10,23 @@ interface ResolvedTextResult {
|
||||
truncatedText: string | ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a panel title alongside a copy truncated to `maxLength`, so a card can
|
||||
* show the short form and keep the full string for its tooltip. Non-string content
|
||||
* passes through untouched.
|
||||
*/
|
||||
function useGetResolvedText({
|
||||
text,
|
||||
maxLength,
|
||||
matcher = '$',
|
||||
maxValues = 2, // Default to showing 2 values before +n more
|
||||
}: UseGetResolvedTextProps): ResolvedTextResult {
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
const isString = typeof text === 'string';
|
||||
|
||||
const processedDashboardVariables = useMemo(() => {
|
||||
return Object.entries(dashboardVariables).reduce<
|
||||
Record<string, string | number | boolean>
|
||||
>((acc, [, value]) => {
|
||||
if (!value.name) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Handle array values
|
||||
if (Array.isArray(value.selectedValue)) {
|
||||
acc[value.name] = value.selectedValue.join(', ');
|
||||
} else if (value.selectedValue != null) {
|
||||
acc[value.name] = value.selectedValue;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}, [dashboardVariables]);
|
||||
|
||||
// Process array values to add +n more notation for truncated text
|
||||
const processedVariables = useMemo(() => {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
Object.entries(processedDashboardVariables).forEach(([key, value]) => {
|
||||
// If the value contains array data (comma-separated string), format it with +n more
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
!value.includes('-|-') &&
|
||||
value.includes(',')
|
||||
) {
|
||||
const values = value.split(',').map((v) => v.trim());
|
||||
if (values.length > maxValues) {
|
||||
const visibleValues = values.slice(0, maxValues);
|
||||
const remainingCount = values.length - maxValues;
|
||||
result[key] = `${visibleValues.join(
|
||||
', ',
|
||||
)} +${remainingCount}-|-${values.join(', ')}`;
|
||||
} else {
|
||||
result[key] = `${values.join(', ')}-|-${values.join(', ')}`;
|
||||
}
|
||||
} else {
|
||||
// For values already formatted with -|- or non-array values
|
||||
result[key] = String(value);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [processedDashboardVariables, maxValues]);
|
||||
|
||||
const combinedPattern = useMemo(() => {
|
||||
const escapedMatcher = matcher.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const variablePatterns = [
|
||||
`\\{\\{\\s*?\\.([^\\s}]+?)\\s*?\\}\\}`, // {{.var}}
|
||||
`\\{\\{\\s*([^\\s}]+?)\\s*\\}\\}`, // {{var}}
|
||||
`${escapedMatcher}([^\\s.,;)\\]}>]+(?:\\.[^\\s.,;)\\]}>]+)*)`, // $var.name.path - allows dots but stops at punctuation
|
||||
`\\[\\[\\s*([^\\s\\]]+?)\\s*\\]\\]`, // [[var]]
|
||||
];
|
||||
return new RegExp(variablePatterns.join('|'), 'g');
|
||||
}, [matcher]);
|
||||
|
||||
const extractVarName = useCallback(
|
||||
(match: string): string => {
|
||||
// Extract variable name from different formats
|
||||
const varNamePattern = '[a-zA-Z_\\-][a-zA-Z0-9_.\\-]*';
|
||||
if (match.startsWith('{{')) {
|
||||
const dotMatch = match.match(
|
||||
new RegExp(`\\{\\{\\s*\\.(${varNamePattern})\\s*\\}\\}`),
|
||||
);
|
||||
if (dotMatch) {
|
||||
return dotMatch[1].trim();
|
||||
}
|
||||
const normalMatch = match.match(
|
||||
new RegExp(`\\{\\{\\s*(${varNamePattern})\\s*\\}\\}`),
|
||||
);
|
||||
if (normalMatch) {
|
||||
return normalMatch[1].trim();
|
||||
}
|
||||
} else if (match.startsWith('[[')) {
|
||||
const bracketMatch = match.match(
|
||||
new RegExp(`\\[\\[\\s*(${varNamePattern})\\s*\\]\\]`),
|
||||
);
|
||||
if (bracketMatch) {
|
||||
return bracketMatch[1].trim();
|
||||
}
|
||||
} else if (match.startsWith(matcher)) {
|
||||
// For $ variables, we always want to strip the prefix
|
||||
// unless the full match exists in processedVariables
|
||||
const withoutPrefix = match.substring(matcher.length).trim();
|
||||
const fullMatch = match.trim();
|
||||
|
||||
// If the full match (with prefix) exists, use it
|
||||
if (processedVariables[fullMatch] !== undefined) {
|
||||
return fullMatch;
|
||||
}
|
||||
|
||||
// Otherwise return without prefix
|
||||
return withoutPrefix;
|
||||
}
|
||||
return match;
|
||||
},
|
||||
[matcher, processedVariables],
|
||||
);
|
||||
|
||||
const fullText = useMemo(() => {
|
||||
if (!isString) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return (text as string)?.replace(combinedPattern, (match) => {
|
||||
const varName = extractVarName(match);
|
||||
const value = processedVariables[varName];
|
||||
|
||||
if (value != null) {
|
||||
const parts = value.split('-|-');
|
||||
return parts.length > 1 ? parts[1] : value;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
}, [text, processedVariables, combinedPattern, extractVarName, isString]);
|
||||
|
||||
const truncatedText = useMemo(() => {
|
||||
if (!isString) {
|
||||
if (typeof text !== 'string' || !maxLength || text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
return `${text.substring(0, maxLength - 3)}...`;
|
||||
}, [text, maxLength]);
|
||||
|
||||
const result = (text as string)?.replace(combinedPattern, (match) => {
|
||||
const varName = extractVarName(match);
|
||||
const value = processedVariables[varName];
|
||||
|
||||
if (value != null) {
|
||||
const parts = value.split('-|-');
|
||||
return parts[0] || value;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
if (maxLength && result.length > maxLength) {
|
||||
// For the specific test case
|
||||
if (maxLength === 20 && result.startsWith('Logs count in')) {
|
||||
return 'Logs count in test, a...';
|
||||
}
|
||||
|
||||
// General case
|
||||
return `${result.substring(0, maxLength - 3)}...`;
|
||||
}
|
||||
return result;
|
||||
}, [
|
||||
text,
|
||||
processedVariables,
|
||||
combinedPattern,
|
||||
maxLength,
|
||||
extractVarName,
|
||||
isString,
|
||||
]);
|
||||
|
||||
return {
|
||||
fullText,
|
||||
truncatedText,
|
||||
};
|
||||
return { fullText: text, truncatedText };
|
||||
}
|
||||
|
||||
export default useGetResolvedText;
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import isEmpty from 'lodash-es/isEmpty';
|
||||
import {
|
||||
IVariableFetchStoreState,
|
||||
VariableFetchState,
|
||||
variableFetchStore,
|
||||
} from 'providers/Dashboard/store/variableFetchStore';
|
||||
|
||||
import { useDashboardVariablesSelector } from './useDashboardVariables';
|
||||
|
||||
/**
|
||||
* Generic selector hook for the variable fetch store.
|
||||
* Same pattern as useDashboardVariablesSelector.
|
||||
*/
|
||||
const useVariableFetchSelector = <T>(
|
||||
selector: (state: IVariableFetchStoreState) => T,
|
||||
): T => {
|
||||
const selectorRef = useRef(selector);
|
||||
selectorRef.current = selector;
|
||||
|
||||
const getSnapshot = useCallback(
|
||||
() => selectorRef.current(variableFetchStore.getSnapshot()),
|
||||
[],
|
||||
);
|
||||
|
||||
return useSyncExternalStore(variableFetchStore.subscribe, getSnapshot);
|
||||
};
|
||||
|
||||
interface UseVariableFetchStateReturn {
|
||||
/** The current fetch state for this variable */
|
||||
variableFetchState: VariableFetchState;
|
||||
/** Current fetch cycle — include in react-query keys to auto-cancel stale requests */
|
||||
variableFetchCycleId: number;
|
||||
/** True if this variable is idle (not waiting and not fetching) */
|
||||
isVariableSettled: boolean;
|
||||
/** True if this variable is actively fetching (loading or revalidating) */
|
||||
isVariableFetching: boolean;
|
||||
/** True if this variable has completed at least one fetch cycle */
|
||||
hasVariableFetchedOnce: boolean;
|
||||
/** True if any parent variable hasn't settled yet */
|
||||
isVariableWaitingForDependencies: boolean;
|
||||
/** Message describing what this variable is waiting on, or null if not waiting */
|
||||
variableDependencyWaitMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-variable hook that exposes the fetch state of a single variable.
|
||||
* Reusable by both variable input components and panel components.
|
||||
*
|
||||
* Subscribes to both variableFetchStore (for states) and
|
||||
* dashboardVariablesStore (for parent graph) to compute derived values.
|
||||
*/
|
||||
export function useVariableFetchState(
|
||||
variableName: string,
|
||||
): UseVariableFetchStateReturn {
|
||||
// This variable's fetch state (loading, waiting, idle, etc.)
|
||||
const variableFetchState = useVariableFetchSelector(
|
||||
(s) => s.states[variableName] || 'idle',
|
||||
) as VariableFetchState;
|
||||
|
||||
// All variable states — needed to check if parent variables are still in-flight
|
||||
const allStates = useVariableFetchSelector((s) => s.states);
|
||||
|
||||
// Parent dependency graph — maps each variable to its direct parents
|
||||
// e.g. { "childVariable": ["parentVariable"] } means "childVariable" depends on "parentVariable"
|
||||
const parentGraph = useDashboardVariablesSelector(
|
||||
(s) => s.dependencyData?.parentDependencyGraph,
|
||||
);
|
||||
|
||||
// Timestamp of last successful fetch — 0 means never fetched
|
||||
const lastUpdated = useVariableFetchSelector(
|
||||
(s) => s.lastUpdated[variableName] || 0,
|
||||
);
|
||||
|
||||
// Per-variable cycle counter — used as part of react-query keys
|
||||
// so changing it auto-cancels stale requests for this variable only
|
||||
const variableFetchCycleId = useVariableFetchSelector(
|
||||
(s) => s.cycleIds[variableName] || 0,
|
||||
);
|
||||
|
||||
const isVariableSettled = variableFetchState === 'idle';
|
||||
|
||||
const isVariableFetching =
|
||||
variableFetchState === 'loading' || variableFetchState === 'revalidating';
|
||||
// True after at least one successful fetch — used to show stale data while revalidating
|
||||
const hasVariableFetchedOnce = lastUpdated > 0;
|
||||
|
||||
// Variable type — needed to differentiate waiting messages
|
||||
const variableType = useDashboardVariablesSelector(
|
||||
(s) => s.variableTypes[variableName],
|
||||
);
|
||||
|
||||
// Parent variable names that haven't settled yet
|
||||
const unsettledParents = useMemo(() => {
|
||||
const parents = parentGraph?.[variableName] || [];
|
||||
return parents.filter((p) => (allStates[p] || 'idle') !== 'idle');
|
||||
}, [parentGraph, variableName, allStates]);
|
||||
|
||||
const isVariableWaitingForDependencies = unsettledParents.length > 0;
|
||||
|
||||
const variableDependencyWaitMessage = useMemo(() => {
|
||||
if (variableFetchState !== 'waiting') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (variableType === 'DYNAMIC') {
|
||||
return 'Waiting for all query variable options to load.';
|
||||
}
|
||||
|
||||
if (unsettledParents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const quoted = unsettledParents.map((p) => `"${p}"`);
|
||||
const names =
|
||||
quoted.length > 1
|
||||
? `${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]}`
|
||||
: quoted[0];
|
||||
return `Waiting for options of ${names} to load.`;
|
||||
}, [variableFetchState, variableType, unsettledParents]);
|
||||
|
||||
return {
|
||||
variableFetchState,
|
||||
isVariableSettled,
|
||||
isVariableWaitingForDependencies,
|
||||
variableDependencyWaitMessage,
|
||||
isVariableFetching,
|
||||
hasVariableFetchedOnce,
|
||||
variableFetchCycleId,
|
||||
};
|
||||
}
|
||||
|
||||
export function useIsPanelWaitingOnVariable(variableNames: string[]): boolean {
|
||||
const states = useVariableFetchSelector((s) => s.states);
|
||||
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
|
||||
|
||||
return variableNames.some((name) => {
|
||||
const variableFetchState = states[name];
|
||||
const variableData = Object.values(dashboardVariables).find(
|
||||
(v) => v.name === name,
|
||||
);
|
||||
const { selectedValue } = variableData || {};
|
||||
|
||||
const isVariableInFetchingOrWaitingState =
|
||||
variableFetchState === 'loading' ||
|
||||
variableFetchState === 'revalidating' ||
|
||||
variableFetchState === 'waiting';
|
||||
|
||||
return isEmpty(selectedValue) ? isVariableInFetchingOrWaitingState : false;
|
||||
});
|
||||
}
|
||||
@@ -32,12 +32,8 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('hooks/dashboard/useDashboardVariables', () => ({
|
||||
useDashboardVariables: (): unknown => ({ dashboardVariables: {} }),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/dashboard/useDashboardVariablesByType', () => ({
|
||||
useDashboardVariablesByType: (): unknown => ({}),
|
||||
jest.mock('hooks/dashboard/useDynamicVariableSuggestions', () => ({
|
||||
useDynamicVariableSuggestions: (): unknown[] => [],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
@@ -46,10 +42,6 @@ jest.mock('hooks/useNotifications', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('lib/dashboardVariables/getDashboardVariables', () => ({
|
||||
getDashboardVariables: (): unknown => ({}),
|
||||
}));
|
||||
|
||||
jest.mock('utils/getGraphType', () => ({
|
||||
getGraphType: jest.fn().mockReturnValue('time_series'),
|
||||
}));
|
||||
|
||||
@@ -11,10 +11,8 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { MenuItemKeys } from 'container/WidgetCard/Header/contants';
|
||||
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
|
||||
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
|
||||
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -38,11 +36,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
const dashboardDynamicVariables = useDashboardVariablesByType(
|
||||
'DYNAMIC',
|
||||
'values',
|
||||
);
|
||||
const dashboardDynamicVariables = useDynamicVariableSuggestions();
|
||||
|
||||
return useCallback(() => {
|
||||
if (!widget) {
|
||||
@@ -63,15 +57,16 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
queryType: widget.query.queryType,
|
||||
});
|
||||
}
|
||||
const { queryPayload } = prepareQueryRangePayloadV5({
|
||||
query: widget.query,
|
||||
globalSelectedInterval,
|
||||
graphType: getGraphType(widget.panelTypes),
|
||||
selectedTime: widget.timePreferance,
|
||||
variables: getDashboardVariables(dashboardVariables),
|
||||
originalGraphType: widget.panelTypes,
|
||||
dynamicVariables: dashboardDynamicVariables,
|
||||
});
|
||||
const { queryPayload } = prepareQueryRangePayloadV5(
|
||||
{
|
||||
query: widget.query,
|
||||
globalSelectedInterval,
|
||||
graphType: getGraphType(widget.panelTypes),
|
||||
selectedTime: widget.timePreferance,
|
||||
originalGraphType: widget.panelTypes,
|
||||
},
|
||||
dashboardDynamicVariables,
|
||||
);
|
||||
queryRangeMutation.mutate(queryPayload, {
|
||||
onSuccess: (data) => {
|
||||
const updatedQuery = mapQueryDataFromApi(data.data.compositeQuery);
|
||||
@@ -107,7 +102,6 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
|
||||
globalSelectedInterval,
|
||||
notifications,
|
||||
queryRangeMutation,
|
||||
dashboardVariables,
|
||||
dashboardDynamicVariables,
|
||||
widget,
|
||||
]);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MAX_QUERY_RETRIES } from 'constants/reactQuery';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { updateBarStepInterval } from 'container/WidgetCard/utils';
|
||||
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
|
||||
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
|
||||
import {
|
||||
GetMetricQueryRange,
|
||||
GetQueryResultsProps,
|
||||
@@ -33,10 +33,7 @@ export const useGetQueryRange: UseGetQueryRange = (
|
||||
options,
|
||||
headers,
|
||||
) => {
|
||||
const dashboardDynamicVariables = useDashboardVariablesByType(
|
||||
'DYNAMIC',
|
||||
'values',
|
||||
);
|
||||
const dashboardDynamicVariables = useDynamicVariableSuggestions();
|
||||
|
||||
const newRequestData: GetQueryResultsProps = useMemo(() => {
|
||||
const firstQueryData = requestData.query.builder?.queryData[0];
|
||||
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import { convertNewDataToOld } from 'lib/newQueryBuilder/convertNewDataToOld';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { DynamicVariableSuggestion } from 'providers/Dashboard/store/dynamicVariableSuggestions';
|
||||
import { SuccessResponseV2, Warning } from 'types/api';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/variables';
|
||||
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
@@ -179,7 +179,7 @@ export const getLegend = (
|
||||
export async function GetMetricQueryRange(
|
||||
props: GetQueryResultsProps,
|
||||
version: string,
|
||||
dynamicVariables?: IDashboardVariable[],
|
||||
dynamicVariables: DynamicVariableSuggestion[] = [],
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<MetricQueryRangeSuccessResponse> {
|
||||
@@ -226,10 +226,7 @@ export async function GetMetricQueryRange(
|
||||
}
|
||||
|
||||
if (version === ENTITY_VERSION_V5) {
|
||||
const v5Result = prepareQueryRangePayloadV5({
|
||||
...props,
|
||||
dynamicVariables,
|
||||
});
|
||||
const v5Result = prepareQueryRangePayloadV5(props, dynamicVariables);
|
||||
legendMap = v5Result.legendMap;
|
||||
|
||||
// atleast one query should be there to make call to v5 api
|
||||
@@ -364,5 +361,4 @@ export interface GetQueryResultsProps {
|
||||
end?: number;
|
||||
step?: number;
|
||||
originalGraphType?: PANEL_TYPES;
|
||||
dynamicVariables?: IDashboardVariable[];
|
||||
}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
|
||||
import { IDependencyData } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/variables';
|
||||
|
||||
/**
|
||||
* Inter-variable dependency graph over the shared dashboard-variables store. A
|
||||
* QUERY variable "depends on" another when its query text references that
|
||||
* variable, so changing a value must refetch its dependents.
|
||||
*
|
||||
* Keyed on `IDashboardVariable`. The V2 editor has a parallel implementation
|
||||
* over its own flat form model in
|
||||
* `pages/DashboardPage/DashboardContainer/VariablesBar/utils/variableDependencies.ts`.
|
||||
*/
|
||||
|
||||
export type VariableGraph = Record<string, string[]>;
|
||||
|
||||
/** Names of QUERY variables whose query references `variableName`. */
|
||||
const getDependentVariablesBasedOnVariableName = (
|
||||
variableName: string,
|
||||
variables: IDashboardVariable[],
|
||||
): string[] => {
|
||||
if (!variables || !Array.isArray(variables)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return variables
|
||||
.map((variable) => {
|
||||
if (variable.type === 'QUERY') {
|
||||
const queryValue = variable.queryValue || '';
|
||||
if (textContainsVariableReference(queryValue, variableName)) {
|
||||
return variable.name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((val): val is string => val !== null);
|
||||
};
|
||||
|
||||
/** variable name → its direct dependents (children). */
|
||||
export const buildDependencies = (
|
||||
variables: IDashboardVariable[],
|
||||
): VariableGraph => {
|
||||
const graph: VariableGraph = {};
|
||||
|
||||
// Initialize empty arrays for all variables first
|
||||
variables.forEach((variable) => {
|
||||
if (variable.name) {
|
||||
graph[variable.name] = [];
|
||||
}
|
||||
});
|
||||
|
||||
// For each QUERY variable, add it as a dependent to its referenced variables
|
||||
variables.forEach((variable) => {
|
||||
if (variable.name) {
|
||||
const dependentVariables = getDependentVariablesBasedOnVariableName(
|
||||
variable.name,
|
||||
variables,
|
||||
);
|
||||
|
||||
// For each referenced variable, add the current query as a dependent
|
||||
graph[variable.name] = dependentVariables;
|
||||
}
|
||||
});
|
||||
|
||||
return graph;
|
||||
};
|
||||
|
||||
/** Invert a child graph into a parent graph. */
|
||||
export const buildParentDependencyGraph = (
|
||||
graph: VariableGraph,
|
||||
): VariableGraph => {
|
||||
const parentGraph: VariableGraph = {};
|
||||
|
||||
// Initialize empty arrays for all nodes
|
||||
Object.keys(graph).forEach((node) => {
|
||||
parentGraph[node] = [];
|
||||
});
|
||||
|
||||
// For each node and its children in the original graph
|
||||
Object.entries(graph).forEach(([node, children]) => {
|
||||
// For each child, add the current node as its parent
|
||||
children.forEach((child) => {
|
||||
if (!parentGraph[child]) {
|
||||
parentGraph[child] = [];
|
||||
}
|
||||
parentGraph[child].push(node);
|
||||
});
|
||||
});
|
||||
|
||||
return parentGraph;
|
||||
};
|
||||
|
||||
const collectCyclePath = (
|
||||
graph: VariableGraph,
|
||||
start: string,
|
||||
end: string,
|
||||
): string[] => {
|
||||
const path: string[] = [];
|
||||
let current = start;
|
||||
|
||||
const findParent = (node: string): string | undefined =>
|
||||
Object.keys(graph).find((key) => graph[key]?.includes(node));
|
||||
|
||||
while (current !== end) {
|
||||
const parent = findParent(current);
|
||||
if (!parent) {
|
||||
break;
|
||||
}
|
||||
path.push(parent);
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return [start, ...path];
|
||||
};
|
||||
|
||||
const detectCycle = (
|
||||
graph: VariableGraph,
|
||||
node: string,
|
||||
visited: Set<string>,
|
||||
recStack: Set<string>,
|
||||
): string[] | null => {
|
||||
if (!visited.has(node)) {
|
||||
visited.add(node);
|
||||
recStack.add(node);
|
||||
|
||||
const neighbors = graph[node] || [];
|
||||
let cycleNodes: string[] | null = null;
|
||||
|
||||
neighbors.some((neighbor) => {
|
||||
if (!visited.has(neighbor)) {
|
||||
const foundCycle = detectCycle(graph, neighbor, visited, recStack);
|
||||
if (foundCycle) {
|
||||
cycleNodes = foundCycle;
|
||||
return true;
|
||||
}
|
||||
} else if (recStack.has(neighbor)) {
|
||||
// Found a cycle, collect the cycle nodes
|
||||
cycleNodes = collectCyclePath(graph, node, neighbor);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (cycleNodes) {
|
||||
return cycleNodes;
|
||||
}
|
||||
}
|
||||
recStack.delete(node);
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Topological order, parent graph, transitive descendants and cycle info. */
|
||||
export const buildDependencyGraph = (
|
||||
dependencies: VariableGraph,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): IDependencyData => {
|
||||
const inDegree: Record<string, number> = {};
|
||||
const adjList: VariableGraph = {};
|
||||
|
||||
// Initialize in-degree and adjacency list
|
||||
Object.keys(dependencies).forEach((node) => {
|
||||
if (!inDegree[node]) {
|
||||
inDegree[node] = 0;
|
||||
}
|
||||
if (!adjList[node]) {
|
||||
adjList[node] = [];
|
||||
}
|
||||
dependencies[node]?.forEach((child) => {
|
||||
if (!inDegree[child]) {
|
||||
inDegree[child] = 0;
|
||||
}
|
||||
inDegree[child]++;
|
||||
adjList[node].push(child);
|
||||
});
|
||||
});
|
||||
|
||||
// Detect cycles
|
||||
const visited = new Set<string>();
|
||||
const recStack = new Set<string>();
|
||||
let cycleNodes: string[] | undefined;
|
||||
|
||||
Object.keys(dependencies).some((node) => {
|
||||
if (!visited.has(node)) {
|
||||
const foundCycle = detectCycle(dependencies, node, visited, recStack);
|
||||
if (foundCycle) {
|
||||
cycleNodes = foundCycle;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Topological sort using Kahn's Algorithm
|
||||
const queue: string[] = Object.keys(inDegree).filter(
|
||||
(node) => inDegree[node] === 0,
|
||||
);
|
||||
const topologicalOrder: string[] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (current === undefined) {
|
||||
break;
|
||||
}
|
||||
topologicalOrder.push(current);
|
||||
|
||||
adjList[current]?.forEach((neighbor) => {
|
||||
inDegree[neighbor]--;
|
||||
if (inDegree[neighbor] === 0) {
|
||||
queue.push(neighbor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const hasCycle = topologicalOrder.length !== Object.keys(dependencies)?.length;
|
||||
|
||||
// Pre-compute transitive descendants by walking topological order in reverse.
|
||||
// Each node's transitive descendants = direct children + their transitive descendants.
|
||||
const transitiveDescendants: VariableGraph = {};
|
||||
for (let i = topologicalOrder.length - 1; i >= 0; i--) {
|
||||
const node = topologicalOrder[i];
|
||||
const desc = new Set<string>();
|
||||
for (const child of adjList[node] || []) {
|
||||
desc.add(child);
|
||||
for (const d of transitiveDescendants[child] || []) {
|
||||
desc.add(d);
|
||||
}
|
||||
}
|
||||
transitiveDescendants[node] = Array.from(desc);
|
||||
}
|
||||
|
||||
return {
|
||||
order: topologicalOrder,
|
||||
graph: adjList,
|
||||
parentDependencyGraph: buildParentDependencyGraph(adjList),
|
||||
transitiveDescendants,
|
||||
hasCycle,
|
||||
cycleNodes,
|
||||
};
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
|
||||
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
|
||||
import store from 'store';
|
||||
|
||||
export const getDashboardVariables = (
|
||||
variables?: IDashboardVariables,
|
||||
): Record<string, unknown> => {
|
||||
if (!variables) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const { globalTime } = store.getState();
|
||||
const { start, end } = getStartEndRangeTime({
|
||||
type: 'GLOBAL_TIME',
|
||||
interval: globalTime.selectedTime,
|
||||
});
|
||||
|
||||
const variablesTuple: Record<string, unknown> = {
|
||||
SIGNOZ_START_TIME: parseInt(start, 10) * 1e3,
|
||||
SIGNOZ_END_TIME: parseInt(end, 10) * 1e3,
|
||||
};
|
||||
|
||||
Object.entries(variables).forEach(([, value]) => {
|
||||
if (value?.name) {
|
||||
variablesTuple[value.name] =
|
||||
value?.type === 'DYNAMIC' &&
|
||||
value?.allSelected &&
|
||||
value?.showALLOption &&
|
||||
value?.multiSelect
|
||||
? '__all__'
|
||||
: value?.selectedValue;
|
||||
}
|
||||
});
|
||||
|
||||
return variablesTuple;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return {};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import uPlot, { Axis } from 'uplot';
|
||||
|
||||
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
|
||||
@@ -7,11 +6,6 @@ import getGridColor from '../../uPlotLib/utils/getGridColor';
|
||||
import { buildYAxisSizeCalculator } from '../utils/axis';
|
||||
import { AxisProps, ConfigBuilder } from './types';
|
||||
|
||||
const PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT = [
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
PANEL_TYPES.BAR,
|
||||
];
|
||||
|
||||
/**
|
||||
* Builder for uPlot axis configuration
|
||||
* Handles creation and merging of axis settings
|
||||
@@ -67,12 +61,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
* Build values formatter for X-axis (time)
|
||||
*/
|
||||
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
|
||||
const { panelType } = this.props;
|
||||
const { isTimeAxis } = this.props;
|
||||
|
||||
if (
|
||||
panelType &&
|
||||
PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT.includes(panelType)
|
||||
) {
|
||||
if (isTimeAxis) {
|
||||
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
|
||||
}
|
||||
|
||||
@@ -157,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;
|
||||
|
||||
@@ -188,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;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
@@ -137,11 +136,11 @@ describe('UPlotAxisBuilder', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses time-based X-axis values formatter for time-series like panels', () => {
|
||||
it('uses time-based X-axis values formatter when the caller declares a time axis', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
isTimeAxis: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -150,11 +149,11 @@ describe('UPlotAxisBuilder', () => {
|
||||
expect(config.values).toBe(uPlotXAxisValuesFormat);
|
||||
});
|
||||
|
||||
it('does not attach X-axis datetime formatter when panel type is not supported', () => {
|
||||
it('does not attach X-axis datetime formatter for a non-time axis', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.LIST, // not in PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT
|
||||
isTimeAxis: false,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -290,22 +289,9 @@ describe('UPlotAxisBuilder', () => {
|
||||
expect(config.space).toBe(50);
|
||||
});
|
||||
|
||||
it('includes PANEL_TYPES.BAR and PANEL_TYPES.TIME_SERIES in X-axis datetime formatter', () => {
|
||||
const barBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
}),
|
||||
);
|
||||
expect(barBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
|
||||
|
||||
const timeSeriesBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
);
|
||||
expect(timeSeriesBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
|
||||
it('omits the X-axis datetime formatter when no time axis is declared', () => {
|
||||
const builder = new UPlotAxisBuilder(createAxisProps({ scaleKey: 'x' }));
|
||||
expect(builder.getConfig().values).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the existing size when cycleNum > 1', () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import uPlot, { Series } from 'uplot';
|
||||
|
||||
import { ThresholdsDrawHookOptions } from '../hooks/types';
|
||||
@@ -53,31 +52,53 @@ export interface ConfigBuilderProps {
|
||||
* Props for configuring an axis
|
||||
*/
|
||||
export interface AxisProps {
|
||||
/** Scale this axis is drawn against — `'x'` / `'y'`, matching an `addScale` key. Also
|
||||
* selects the default tick formatter and sizing (x: time, y: value + unit). */
|
||||
scaleKey: string;
|
||||
/** Axis title drawn alongside the ticks; omitted when there's nothing to name. */
|
||||
label?: string;
|
||||
/** Render the axis at all; false keeps the scale but draws no ticks or labels. */
|
||||
show?: boolean;
|
||||
side?: 0 | 1 | 2 | 3; // top, right, bottom, left
|
||||
/** Which edge of the plot the axis sits on: 0 | 1 | 2 | 3 — top, right, bottom, left. */
|
||||
side?: 0 | 1 | 2 | 3;
|
||||
/** Tick/label color. Defaults to black or white from `isDarkMode`. */
|
||||
stroke?: string;
|
||||
/** Partial override of the grid lines; unset keys fall back to the theme defaults. */
|
||||
grid?: {
|
||||
stroke?: string;
|
||||
width?: number;
|
||||
show?: boolean;
|
||||
};
|
||||
/** Partial override of the tick marks; provided as-is to uPlot when set. */
|
||||
ticks?: {
|
||||
stroke?: string;
|
||||
width?: number;
|
||||
show?: boolean;
|
||||
size?: number;
|
||||
};
|
||||
/** 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. */
|
||||
size?: uPlot.Axis.Size;
|
||||
formatValue?: (v: number) => string;
|
||||
space?: number; // Space for log scale axes
|
||||
/** Minimum pixels between ticks, capping how many uPlot draws. For log scale axes. */
|
||||
space?: number;
|
||||
/** Picks the dark or light default for stroke and grid color. */
|
||||
isDarkMode?: boolean;
|
||||
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
|
||||
isLogScale?: boolean;
|
||||
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`). */
|
||||
yAxisUnit?: string;
|
||||
panelType?: PANEL_TYPES;
|
||||
/**
|
||||
* X axis carries timestamps, so its ticks format as dates/times. Declared by the caller
|
||||
* rather than inferred from a panel type — a chart whose x axis is buckets or categories
|
||||
* (histogram) leaves it off.
|
||||
*/
|
||||
isTimeAxis?: boolean;
|
||||
/** Decimal places for y axis tick values; unset lets the unit formatter decide. */
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
310
frontend/src/lib/visualization/charts/Heatmap/Heatmap.tsx
Normal file
310
frontend/src/lib/visualization/charts/Heatmap/Heatmap.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
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,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
} = 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}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
/>
|
||||
),
|
||||
[
|
||||
legendItems,
|
||||
legendPosition,
|
||||
focusedSeriesIndex,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
],
|
||||
);
|
||||
|
||||
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,252 @@
|
||||
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('.legend-item');
|
||||
// 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 {
|
||||
const item = screen.getByText(label).closest('.legend-item');
|
||||
if (!item) {
|
||||
throw new Error(`no legend item for ${label}`);
|
||||
}
|
||||
return item as HTMLElement;
|
||||
}
|
||||
|
||||
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)).not.toHaveClass('legend-item-off');
|
||||
expect(legendItem(CHECKOUT)).not.toHaveClass('legend-item-off');
|
||||
});
|
||||
|
||||
it('isolates a group when its label is clicked', async () => {
|
||||
renderHeatmap();
|
||||
|
||||
await userEvent.click(screen.getByText(CART));
|
||||
|
||||
expect(legendItem(CART)).not.toHaveClass('legend-item-off');
|
||||
expect(legendItem(CHECKOUT)).toHaveClass('legend-item-off');
|
||||
});
|
||||
|
||||
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)).not.toHaveClass('legend-item-off');
|
||||
});
|
||||
|
||||
it('excludes just one group when its marker is clicked', async () => {
|
||||
renderHeatmap();
|
||||
|
||||
await userEvent.click(marker(CHECKOUT));
|
||||
|
||||
expect(legendItem(CHECKOUT)).toHaveClass('legend-item-off');
|
||||
expect(legendItem(CART)).not.toHaveClass('legend-item-off');
|
||||
});
|
||||
|
||||
/** 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,147 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import { useHeatmapGroupLegend } from '../useHeatmapGroupLegend';
|
||||
|
||||
const GROUPS = ['cart', 'checkout', 'payments'];
|
||||
|
||||
/** Mimics a click on an item's label, as the shared Legend renders it. */
|
||||
function labelClick(seriesIndex: number): MouseEvent<HTMLDivElement> {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.setAttribute('data-legend-item-id', String(seriesIndex));
|
||||
const label = document.createElement('span');
|
||||
wrapper.appendChild(label);
|
||||
return { target: label } as unknown as MouseEvent<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/** Mimics a click on the item's marker circle. */
|
||||
function markerClick(seriesIndex: number): MouseEvent<HTMLDivElement> {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.setAttribute('data-legend-item-id', String(seriesIndex));
|
||||
const marker = document.createElement('div');
|
||||
marker.dataset.isLegendMarker = 'true';
|
||||
wrapper.appendChild(marker);
|
||||
return { target: marker } as unknown as MouseEvent<HTMLDivElement>;
|
||||
}
|
||||
|
||||
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 its label is clicked', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(labelClick(2)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
|
||||
});
|
||||
|
||||
it('restores every group when the isolated label is clicked again', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(labelClick(2)));
|
||||
act(() => result.current.onLegendClick(labelClick(2)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('moves the isolation when a different label is clicked', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(labelClick(1)));
|
||||
act(() => result.current.onLegendClick(labelClick(3)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['payments']);
|
||||
});
|
||||
|
||||
it('excludes just one group when its marker is clicked', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(markerClick(2)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['cart', 'payments']);
|
||||
});
|
||||
|
||||
it('re-includes a group when its marker is clicked again', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(markerClick(2)));
|
||||
act(() => result.current.onLegendClick(markerClick(2)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('excludes more than one group', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(markerClick(1)));
|
||||
act(() => result.current.onLegendClick(markerClick(3)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
|
||||
});
|
||||
|
||||
it('drops the isolation when a marker is clicked, so the label can isolate again', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(labelClick(1)));
|
||||
// Re-including cart by marker leaves it enabled but no longer isolated.
|
||||
act(() => result.current.onLegendClick(markerClick(2)));
|
||||
act(() => result.current.onLegendClick(labelClick(1)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['cart']);
|
||||
});
|
||||
|
||||
it('allows every group to be excluded, as the other legends do', () => {
|
||||
const { result } = render();
|
||||
|
||||
GROUPS.forEach((_, index) =>
|
||||
act(() => result.current.onLegendClick(markerClick(index + 1))),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('ignores clicks that miss an entry', () => {
|
||||
const { result } = render();
|
||||
const stray = {
|
||||
target: document.createElement('div'),
|
||||
} as unknown as MouseEvent<HTMLDivElement>;
|
||||
|
||||
act(() => result.current.onLegendClick(stray));
|
||||
|
||||
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.onLegendClick(markerClick(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.onLegendMouseMove(labelClick(2)));
|
||||
expect(result.current.focusedSeriesIndex).toBe(2);
|
||||
|
||||
act(() => result.current.onLegendMouseLeave());
|
||||
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,100 @@
|
||||
import { MouseEvent, useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export interface UseHeatmapGroupLegendResult {
|
||||
/** Groups currently enabled. The grid sums exactly these. */
|
||||
visibleGroups: string[];
|
||||
focusedSeriesIndex: number | null;
|
||||
onLegendClick: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseMove: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseLeave: () => void;
|
||||
}
|
||||
|
||||
/** The shared Legend tags each item and delegates interaction to the container. */
|
||||
function getLegendIndex(event: MouseEvent<HTMLDivElement>): number | null {
|
||||
const element = (event.target as HTMLElement | null)?.closest<HTMLElement>(
|
||||
'[data-legend-item-id]',
|
||||
);
|
||||
const id = element?.dataset.legendItemId;
|
||||
return id === undefined ? null : Number(id);
|
||||
}
|
||||
|
||||
function isMarkerClick(event: MouseEvent<HTMLDivElement>): boolean {
|
||||
return Boolean((event.target as HTMLElement).dataset.isLegendMarker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group visibility for the heatmap legend, matching every other legend in the
|
||||
* product: the label isolates a group, the marker excludes one, and 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 isolatedRef = useRef<string | null>(null);
|
||||
|
||||
const visibleGroups = useMemo(
|
||||
() => groups.filter((group) => !hidden.has(group)),
|
||||
[groups, hidden],
|
||||
);
|
||||
|
||||
const onLegendClick = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(event);
|
||||
const group = index === null ? undefined : groups[index - 1];
|
||||
if (group === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMarkerClick(event)) {
|
||||
isolatedRef.current = null;
|
||||
setHidden((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(group)) {
|
||||
next.delete(group);
|
||||
} else {
|
||||
next.add(group);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Label click isolates; clicking the isolated group again restores all.
|
||||
const isReset = isolatedRef.current === group;
|
||||
isolatedRef.current = isReset ? null : group;
|
||||
setHidden(
|
||||
isReset ? new Set() : new Set(groups.filter((entry) => entry !== group)),
|
||||
);
|
||||
},
|
||||
[groups],
|
||||
);
|
||||
|
||||
const onLegendMouseMove = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>): void => {
|
||||
setFocusedSeriesIndex(getLegendIndex(event));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onLegendMouseLeave = useCallback((): void => {
|
||||
setFocusedSeriesIndex(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
visibleGroups,
|
||||
focusedSeriesIndex,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
};
|
||||
}
|
||||
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
|
||||
|
||||
@@ -14,6 +14,7 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import { plotsTimeOnXAxis } from 'lib/visualization/panels/utils/panelAxis';
|
||||
|
||||
export interface BaseConfigBuilderProps {
|
||||
id: string;
|
||||
@@ -124,7 +125,7 @@ export function buildBaseConfig({
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
isLogScale,
|
||||
panelType,
|
||||
isTimeAxis: plotsTimeOnXAxis(panelType),
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
@@ -134,7 +135,6 @@ export function buildBaseConfig({
|
||||
isDarkMode,
|
||||
isLogScale,
|
||||
yAxisUnit,
|
||||
panelType,
|
||||
});
|
||||
|
||||
return builder;
|
||||
|
||||
9
frontend/src/lib/visualization/panels/utils/panelAxis.ts
Normal file
9
frontend/src/lib/visualization/panels/utils/panelAxis.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
/**
|
||||
* Whether the panel type plots time on X. Graph and bar do; the rest drawn through
|
||||
* `buildBaseConfig` — histogram buckets, billing categories — plot a value there instead.
|
||||
*/
|
||||
export function plotsTimeOnXAxis(panelType: PANEL_TYPES): boolean {
|
||||
return panelType === PANEL_TYPES.TIME_SERIES || panelType === PANEL_TYPES.BAR;
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.sche
|
||||
import PromQLIcon from 'assets/Dashboard/PromQl';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ClickHouseQueryContainer from 'container/QueryBuilder/rawQueryEditors/ClickHouse';
|
||||
import PromQLQueryContainer from 'container/QueryBuilder/rawQueryEditors/PromQL';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
@@ -64,8 +63,12 @@ function PanelEditorQueryBuilder({
|
||||
footer,
|
||||
stickyHeader = true,
|
||||
}: PanelEditorQueryBuilderProps): JSX.Element {
|
||||
// The shared QueryBuilderV2 / list-view checks still speak the legacy PANEL_TYPES.
|
||||
// The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the
|
||||
// builder offers for this kind comes from the kind's own declaration.
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
// Raw rows: the builder drops its aggregation controls, and with them the trace
|
||||
// operator that combines aggregated trace queries (V1 parity).
|
||||
const isListViewPanel = panelKind === 'signoz/ListPanel';
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -112,9 +115,9 @@ function PanelEditorQueryBuilder({
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
filterConfigs={filterConfigs}
|
||||
showTraceOperator={panelType !== PANEL_TYPES.LIST}
|
||||
showTraceOperator={!isListViewPanel}
|
||||
version="v3"
|
||||
isListViewPanel={panelType === PANEL_TYPES.LIST}
|
||||
isListViewPanel={isListViewPanel}
|
||||
queryComponents={{}}
|
||||
signalSourceChangeEnabled
|
||||
savePreviousQuery
|
||||
@@ -148,7 +151,7 @@ function PanelEditorQueryBuilder({
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
}));
|
||||
}, [panelKind, panelType, filterConfigs, isDarkMode]);
|
||||
}, [panelKind, panelType, filterConfigs, isDarkMode, isListViewPanel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -60,6 +60,7 @@ function renderBuilder(
|
||||
function lastQueryBuilderProps(): {
|
||||
panelType: string;
|
||||
isListViewPanel: boolean;
|
||||
showTraceOperator: boolean;
|
||||
filterConfigs: unknown;
|
||||
} {
|
||||
const calls = mockQueryBuilderV2.mock.calls;
|
||||
@@ -115,6 +116,9 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
|
||||
const props = lastQueryBuilderProps();
|
||||
expect(props.panelType).toBe('graph');
|
||||
expect(props.isListViewPanel).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({});
|
||||
});
|
||||
|
||||
@@ -124,6 +128,7 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
|
||||
const props = lastQueryBuilderProps();
|
||||
expect(props.panelType).toBe('list');
|
||||
expect(props.isListViewPanel).toBe(true);
|
||||
expect(props.showTraceOperator).toBe(false);
|
||||
expect(props.filterConfigs).toStrictEqual({
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
having: { isHidden: true, isDisabled: true },
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import { Spline } from '@signozhq/icons';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import QueryTypeTag from 'components/QueryTypeTag/QueryTypeTag';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
interface PlotTagProps {
|
||||
/** Authoring mode of the panel's query; undefined when no query exists yet. */
|
||||
queryType: EQueryType | undefined;
|
||||
panelType: PANEL_TYPES;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Plotted with <query mode>" chip for the editor preview; V2 counterpart of V1's
|
||||
* PlotTag (duplicated per the split policy). Hidden for list panels and before a
|
||||
* query exists, where the mode is irrelevant.
|
||||
*/
|
||||
function PlotTag({
|
||||
queryType,
|
||||
panelType,
|
||||
className,
|
||||
}: PlotTagProps): JSX.Element | null {
|
||||
if (queryType === undefined || panelType === PANEL_TYPES.LIST) {
|
||||
function PlotTag({ queryType, className }: PlotTagProps): JSX.Element | null {
|
||||
if (queryType === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import PanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsL
|
||||
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import { getPanelQueryType } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getPanelQueryType';
|
||||
import type {
|
||||
@@ -72,7 +71,6 @@ function PreviewPane({
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
|
||||
const queryType = getPanelQueryType(panel);
|
||||
|
||||
// Search term is ephemeral preview state, threaded to header + renderer but
|
||||
@@ -84,11 +82,7 @@ function PreviewPane({
|
||||
<div className={styles.preview}>
|
||||
{!hideHeader && (
|
||||
<div className={styles.header}>
|
||||
<PlotTag
|
||||
queryType={queryType}
|
||||
panelType={panelType}
|
||||
className={styles.queryType}
|
||||
/>
|
||||
<PlotTag queryType={queryType} className={styles.queryType} />
|
||||
<div className={styles.dateTimeSelector}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import PlotTag from '../PlotTag';
|
||||
|
||||
describe('PlotTag', () => {
|
||||
it('renders the resolved query mode', () => {
|
||||
render(
|
||||
<PlotTag queryType={EQueryType.PROM} panelType={PANEL_TYPES.TIME_SERIES} />,
|
||||
);
|
||||
render(<PlotTag queryType={EQueryType.PROM} />);
|
||||
expect(screen.getByTestId('panel-editor-plot-tag')).toBeInTheDocument();
|
||||
expect(screen.getByText('PromQL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when there is no query yet', () => {
|
||||
render(<PlotTag queryType={undefined} panelType={PANEL_TYPES.TIME_SERIES} />);
|
||||
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing for list panels (query mode is irrelevant)', () => {
|
||||
render(
|
||||
<PlotTag
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
panelType={PANEL_TYPES.LIST}
|
||||
/>,
|
||||
);
|
||||
render(<PlotTag queryType={undefined} />);
|
||||
expect(screen.queryByTestId('panel-editor-plot-tag')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import {
|
||||
getPanelDefinition,
|
||||
isPanelKindSupported,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
@@ -91,8 +94,9 @@ export function usePanelEditSession({
|
||||
const query = usePanelQuery({
|
||||
panel: draft,
|
||||
panelId,
|
||||
queryCapabilities: panelDefinition.queryCapabilities,
|
||||
time,
|
||||
enabled: !!panelDefinition,
|
||||
enabled: isPanelKindSupported(panelKind),
|
||||
});
|
||||
|
||||
const { runQuery, isQueryDirty, buildSaveSpec } = usePanelEditorQuerySync({
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
handleQueryChange,
|
||||
type PartialPanelTypes,
|
||||
@@ -146,7 +146,7 @@ export function usePanelTypeSwitch({
|
||||
);
|
||||
// Match a fresh list panel's default order so the builder's Order By isn't empty.
|
||||
const nextQuery =
|
||||
newPanelType === PANEL_TYPES.LIST
|
||||
newKind === 'signoz/ListPanel'
|
||||
? withDefaultListOrder(transformed)
|
||||
: transformed;
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
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';
|
||||
import { getPanelDefinition, isPanelKindSupported } from '../registry';
|
||||
import type { PanelQueryCapabilities } from '../types/panelCapabilities';
|
||||
import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
|
||||
import {
|
||||
getHiddenQueryBuilderFields,
|
||||
getSupportedQueryTypes,
|
||||
@@ -15,6 +22,7 @@ import type { PanelKind } from '../types/panelKind';
|
||||
|
||||
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
|
||||
const { logs, traces, metrics } = TelemetrytypesSignalDTO;
|
||||
const { time_series, scalar, raw } = Querybuildertypesv5RequestTypeDTO;
|
||||
|
||||
const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
|
||||
'signoz/TimeSeriesPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
@@ -37,9 +45,117 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
|
||||
'signoz/ListPanel': [logs, traces],
|
||||
};
|
||||
|
||||
// Exhaustive over PanelKind, so a new kind can't ship without stating how its request is
|
||||
// shaped — the check that used to be implicit in a legacy PANEL_TYPES switch.
|
||||
const EXPECTED_QUERY_CAPABILITIES: Record<PanelKind, PanelQueryCapabilities> = {
|
||||
'signoz/TimeSeriesPanel': {
|
||||
requestType: time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
// Bar bins client-side, so it asks for a widened step interval over a raw series.
|
||||
'signoz/BarChartPanel': {
|
||||
requestType: time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: true,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
'signoz/HistogramPanel': {
|
||||
requestType: time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
'signoz/NumberPanel': {
|
||||
requestType: scalar,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
'signoz/PieChartPanel': {
|
||||
requestType: scalar,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
// Only Table asks the server to transpose its scalar result into UI rows.
|
||||
'signoz/TablePanel': {
|
||||
requestType: scalar,
|
||||
formatTableResultForUI: true,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
// Only List reads raw rows, pages them server-side, and needs an order tiebreaker.
|
||||
'signoz/ListPanel': {
|
||||
requestType: raw,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: true,
|
||||
serverPaginated: true,
|
||||
},
|
||||
};
|
||||
|
||||
const ALL_KINDS = Object.keys(EXPECTED_QUERY_TYPES) as PanelKind[];
|
||||
|
||||
describe('panel capabilities guard', () => {
|
||||
describe('query capabilities', () => {
|
||||
it.each(ALL_KINDS)('declares how %s shapes its request', (kind) => {
|
||||
expect(getPanelDefinition(kind).queryCapabilities).toStrictEqual(
|
||||
EXPECTED_QUERY_CAPABILITIES[kind],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// A dashboard spec written by a newer SigNoz can name a kind this build has no
|
||||
// definition for. The registry answers with UNSUPPORTED_PANEL rather than nothing, so
|
||||
// every guard below reads it without first proving a definition exists.
|
||||
describe('a kind this build cannot render', () => {
|
||||
const unknownKind = 'signoz/SomeFutureKindPanel' as PanelKind;
|
||||
|
||||
it('is not reported as supported', () => {
|
||||
expect(isPanelKindSupported(unknownKind)).toBe(false);
|
||||
expect(isPanelKindSupported('signoz/TimeSeriesPanel')).toBe(true);
|
||||
});
|
||||
|
||||
it('still resolves to a definition', () => {
|
||||
expect(getPanelDefinition(unknownKind)).toBe(UNSUPPORTED_PANEL);
|
||||
});
|
||||
|
||||
it('declares nothing, so it is never offered as authorable', () => {
|
||||
expect(getSupportedSignals(unknownKind)).toStrictEqual([]);
|
||||
expect(getSupportedQueryTypes(unknownKind)).toStrictEqual([]);
|
||||
expect(isSignalSupported(unknownKind, logs)).toBe(false);
|
||||
expect(
|
||||
isPanelCombinationValid({ kind: unknownKind, queryType: QUERY_BUILDER }),
|
||||
).toBe(false);
|
||||
expect(getHiddenQueryBuilderFields(unknownKind, logs)).toStrictEqual({});
|
||||
expect(getPanelDefinition(unknownKind).sections).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('offers no actions', () => {
|
||||
expect(getPanelDefinition(unknownKind).actions).toStrictEqual(
|
||||
NO_PANEL_ACTIONS,
|
||||
);
|
||||
expect(NO_PANEL_ACTIONS.view).toBe(false);
|
||||
expect(NO_PANEL_ACTIONS.edit).toBe(false);
|
||||
expect(NO_PANEL_ACTIONS.drilldown).toBe(false);
|
||||
});
|
||||
|
||||
it('carries an inert query shape, so a stray request can do no harm', () => {
|
||||
const { queryCapabilities } = getPanelDefinition(unknownKind);
|
||||
expect(queryCapabilities.requestType).toBe(time_series);
|
||||
expect(queryCapabilities.serverPaginated).toBe(false);
|
||||
expect(queryCapabilities.formatTableResultForUI).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('query type support', () => {
|
||||
it.each(ALL_KINDS)('declares the expected query types for %s', (kind) => {
|
||||
expect(getSupportedQueryTypes(kind)).toStrictEqual(
|
||||
|
||||
@@ -20,8 +20,12 @@ interface NoDataProps {
|
||||
isFetching?: boolean;
|
||||
/** When provided, renders a Retry button that re-runs the query. */
|
||||
onRetry?: () => void;
|
||||
/** Hides the global "Extend time range" action when this panel is locked to a fixed time preference. */
|
||||
panel?: DashboardtypesPanelDTO;
|
||||
/**
|
||||
* The panel this empty state stands in for. Every renderer has it, and it decides
|
||||
* whether the global "Extend time range" action applies (a panel locked to a fixed
|
||||
* time preference can't be widened by it) as well as what the action events report.
|
||||
*/
|
||||
panel: DashboardtypesPanelDTO;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -43,19 +47,17 @@ function NoData({
|
||||
const globalExtend = useExtendTimeWindow();
|
||||
// The View modal's local extender wins; the global one only applies to a panel that
|
||||
// follows the ambient window (a fixed preference can't be widened by it).
|
||||
const hasFixedTimePreference = panel
|
||||
? panelHasFixedTimePreference(panel)
|
||||
: false;
|
||||
const activeExtend =
|
||||
viewExtend ?? (hasFixedTimePreference ? undefined : globalExtend);
|
||||
viewExtend ?? (panelHasFixedTimePreference(panel) ? undefined : globalExtend);
|
||||
|
||||
if (isFetching) {
|
||||
return <PanelLoader />;
|
||||
}
|
||||
|
||||
const panelType = panel
|
||||
? PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind]
|
||||
: undefined;
|
||||
// `panelType` stays on the event so existing reports keep resolving; `panelKind` is the
|
||||
// V2 identity, and the only one that can tell two kinds sharing a panel type apart.
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
|
||||
const extendAction: PanelMessageAction | undefined =
|
||||
activeExtend?.canExtend && activeExtend.actionLabel
|
||||
@@ -65,6 +67,7 @@ function NoData({
|
||||
void logEvent(DashboardDetailEvents.NoDataAction, {
|
||||
action: 'extendTime',
|
||||
panelType,
|
||||
panelKind,
|
||||
});
|
||||
activeExtend.extend();
|
||||
},
|
||||
@@ -79,6 +82,7 @@ function NoData({
|
||||
void logEvent(DashboardDetailEvents.NoDataAction, {
|
||||
action: 'retry',
|
||||
panelType,
|
||||
panelKind,
|
||||
});
|
||||
onRetry();
|
||||
},
|
||||
|
||||
@@ -33,7 +33,12 @@ function panelWith(
|
||||
timePreference?: DashboardtypesTimePreferenceDTO,
|
||||
): DashboardtypesPanelDTO {
|
||||
return {
|
||||
spec: { plugin: { spec: { visualization: { timePreference } } } },
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { visualization: { timePreference } },
|
||||
},
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
@@ -44,7 +49,7 @@ describe('NoData', () => {
|
||||
});
|
||||
|
||||
it('renders the empty-state title and hint', () => {
|
||||
render(<NoData />);
|
||||
render(<NoData panel={panelWith()} />);
|
||||
|
||||
expect(screen.getByTestId('panel-no-data')).toBeInTheDocument();
|
||||
expect(screen.getByText('No data in this time range')).toBeInTheDocument();
|
||||
@@ -55,7 +60,7 @@ describe('NoData', () => {
|
||||
|
||||
it('offers to extend the window as the primary action', () => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(<NoData />);
|
||||
render(<NoData panel={panelWith()} />);
|
||||
|
||||
const action = screen.getByTestId('panel-no-data-action');
|
||||
expect(action).toHaveTextContent('Extend time range');
|
||||
@@ -68,7 +73,7 @@ describe('NoData', () => {
|
||||
it('renders both Extend (primary) and Retry (secondary) when a retry handler is given', () => {
|
||||
const onRetry = jest.fn();
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(<NoData onRetry={onRetry} />);
|
||||
render(<NoData onRetry={onRetry} panel={panelWith()} />);
|
||||
|
||||
expect(screen.getByTestId('panel-no-data-action')).toHaveTextContent(
|
||||
'Extend time range',
|
||||
@@ -82,7 +87,7 @@ describe('NoData', () => {
|
||||
|
||||
it('falls back to Retry as the sole action when the window cannot be widened', () => {
|
||||
const onRetry = jest.fn();
|
||||
render(<NoData onRetry={onRetry} />);
|
||||
render(<NoData onRetry={onRetry} panel={panelWith()} />);
|
||||
|
||||
const action = screen.getByTestId('panel-no-data-action');
|
||||
expect(action).toHaveTextContent('Retry');
|
||||
@@ -101,7 +106,7 @@ describe('NoData', () => {
|
||||
useViewPanelStore.setState({
|
||||
viewPanelExtendWindow: extender({ extend: storeExtend }),
|
||||
});
|
||||
render(<NoData />);
|
||||
render(<NoData panel={panelWith()} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-no-data-action'));
|
||||
expect(storeExtend).toHaveBeenCalledTimes(1);
|
||||
@@ -109,7 +114,7 @@ describe('NoData', () => {
|
||||
});
|
||||
|
||||
it('renders no action when nothing can be widened and no retry handler', () => {
|
||||
render(<NoData />);
|
||||
render(<NoData panel={panelWith()} />);
|
||||
|
||||
expect(screen.queryByTestId('panel-no-data-action')).not.toBeInTheDocument();
|
||||
expect(
|
||||
@@ -119,7 +124,7 @@ describe('NoData', () => {
|
||||
|
||||
it('shows the panel loader (not the empty state) while refetching', () => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(<NoData isFetching />);
|
||||
render(<NoData isFetching panel={panelWith()} />);
|
||||
|
||||
expect(screen.getByTestId('panel-loading')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('panel-no-data')).not.toBeInTheDocument();
|
||||
@@ -128,7 +133,7 @@ describe('NoData', () => {
|
||||
|
||||
it('honours the data-testid override for the number panel', () => {
|
||||
mockUseExtendTimeWindow.mockReturnValue(extender());
|
||||
render(<NoData data-testid="number-panel-no-data" />);
|
||||
render(<NoData data-testid="number-panel-no-data" panel={panelWith()} />);
|
||||
|
||||
expect(screen.getByTestId('number-panel-no-data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
@@ -20,6 +23,15 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
EQueryType.PROM,
|
||||
],
|
||||
queryBuilderFields: {},
|
||||
// Bars are binned client-side from a raw time series, so the request asks for a
|
||||
// step interval wide enough to keep the bar count readable (V1 parity).
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: true,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import { buildBaseConfig } from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import {
|
||||
buildBaseConfig,
|
||||
type TimeAxisChromeArgs,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { toClickPluginPayload } from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
export interface BuildBarChartConfigArgs {
|
||||
panelId: string;
|
||||
export interface BuildBarChartConfigArgs extends TimeAxisChromeArgs {
|
||||
spec: DashboardtypesBarChartPanelSpecDTO;
|
||||
/** Flat list of builder queries (see `getBuilderQueries`); powers per-query legend resolution. */
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
/** Per-query step intervals from the response exec stats. */
|
||||
stepIntervals?: Record<string, number>;
|
||||
isDarkMode: boolean;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
onDragSelect?: (start: number, end: number) => void;
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
}
|
||||
|
||||
/** Builds a `UPlotConfigBuilder` for a Bar chart panel: shared scaffolding, optional stacking, one bar series per result. */
|
||||
@@ -47,7 +36,7 @@ export function buildBarChartConfig({
|
||||
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
isTimeAxis: true,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
@@ -20,6 +23,15 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
EQueryType.PROM,
|
||||
],
|
||||
queryBuilderFields: {},
|
||||
// Buckets are computed client-side from the raw series, so the request is a plain
|
||||
// time series — the bucket count is a display concern, not a query one.
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import { buildBaseConfig } from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import {
|
||||
buildBaseConfig,
|
||||
type PanelChromeArgs,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
@@ -16,16 +16,12 @@ const BAR_WIDTH_FACTOR = 1;
|
||||
const MERGED_SERIES_LINE_COLOR = '#3f5ecc';
|
||||
const MERGED_SERIES_FILL_COLOR = '#4E74F8';
|
||||
|
||||
export interface BuildHistogramConfigArgs {
|
||||
panelId: string;
|
||||
export interface BuildHistogramConfigArgs extends PanelChromeArgs {
|
||||
spec: DashboardtypesHistogramPanelSpecDTO;
|
||||
/** Builder queries on this panel — used to resolve per-series labels. */
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
isDarkMode: boolean;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,7 +40,7 @@ export function buildHistogramConfig({
|
||||
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
panelType: PANEL_TYPES.HISTOGRAM,
|
||||
isTimeAxis: false,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
@@ -30,6 +33,15 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
},
|
||||
},
|
||||
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.
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.raw,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: true,
|
||||
serverPaginated: true,
|
||||
},
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
@@ -20,6 +23,13 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
EQueryType.PROM,
|
||||
],
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
@@ -16,6 +19,13 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
@@ -16,6 +19,14 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
queryBuilderFields: {},
|
||||
// The only kind that asks the server to transpose its scalar result into UI rows.
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
formatTableResultForUI: true,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
|
||||
actions: {
|
||||
view: true,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
@@ -20,6 +23,13 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
EQueryType.PROM,
|
||||
],
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import {
|
||||
buildBaseConfig,
|
||||
minStepInterval,
|
||||
type TimeAxisChromeArgs,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import {
|
||||
FILL_MODE_MAP,
|
||||
@@ -19,7 +17,6 @@ import {
|
||||
toClickPluginPayload,
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import {
|
||||
DrawStyle,
|
||||
FillMode,
|
||||
@@ -31,22 +28,12 @@ import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
const DEFAULT_POINT_SIZE = 5;
|
||||
|
||||
export interface BuildTimeSeriesConfigArgs {
|
||||
panelId: string;
|
||||
export interface BuildTimeSeriesConfigArgs extends TimeAxisChromeArgs {
|
||||
spec: DashboardtypesTimeSeriesPanelSpecDTO;
|
||||
/** Flat list of builder queries (see `getBuilderQueries`); powers per-query legend resolution. */
|
||||
builderQueries: BuilderQuery[];
|
||||
/** Flattened V5 series (see `flattenTimeSeries`). */
|
||||
series: PanelSeries[];
|
||||
/** Per-query step intervals from the response exec stats. */
|
||||
stepIntervals?: Record<string, number>;
|
||||
isDarkMode: boolean;
|
||||
timezone: Timezone;
|
||||
panelMode: PanelMode;
|
||||
onDragSelect?: (start: number, end: number) => void;
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
}
|
||||
|
||||
/** Builds a `UPlotConfigBuilder` for a TimeSeries panel: shared scaffolding plus one series per result. */
|
||||
@@ -66,7 +53,7 @@ export function buildTimeSeriesConfig({
|
||||
}: BuildTimeSeriesConfigArgs): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
panelId,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
isTimeAxis: true,
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelMode,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CircleHelp } from '@signozhq/icons';
|
||||
|
||||
import PanelMessage from '../../components/PanelMessage/PanelMessage';
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
|
||||
/**
|
||||
* Body for a panel whose kind this build has no renderer for — a spec written by a newer
|
||||
* SigNoz names a visualization that didn't exist when this client shipped. Says so in
|
||||
* place of the chart, so the panel keeps its slot in the layout instead of leaving a hole.
|
||||
*/
|
||||
function UnsupportedPanelRenderer(): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
data-testid="unsupported-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
<PanelMessage
|
||||
icon={<CircleHelp size={18} />}
|
||||
title="Unsupported panel type"
|
||||
description="This panel was built with a newer version of SigNoz. Upgrade to view it."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnsupportedPanelRenderer;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
NO_PANEL_ACTIONS,
|
||||
type RenderablePanelDefinition,
|
||||
} from '../../types/panelDefinition';
|
||||
import Renderer from './Renderer';
|
||||
|
||||
/**
|
||||
* Stand-in definition for a kind that isn't in the registry, so `getPanelDefinition`
|
||||
* always resolves and no caller has to branch on a missing one. It declares nothing: no
|
||||
* signals, no query types, no config sections and no actions — an unknown kind can't be
|
||||
* queried, configured or acted on, only shown as unsupported.
|
||||
*
|
||||
* `kind` carries a sentinel that no API enum value can collide with; the cast is the one
|
||||
* place this definition steps outside `PanelKind`.
|
||||
*/
|
||||
export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
|
||||
kind: '<unsupported>' as RenderablePanelDefinition['kind'],
|
||||
displayName: 'Unsupported panel',
|
||||
Renderer,
|
||||
sections: [],
|
||||
supportedSignals: [],
|
||||
supportedQueryTypes: [],
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
actions: NO_PANEL_ACTIONS,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user