mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-06 16:30:42 +00:00
Compare commits
21 Commits
nv/exec-st
...
feat/distr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7d6181bf4 | ||
|
|
754bd6bd85 | ||
|
|
cd4c937524 | ||
|
|
277c30183d | ||
|
|
6049122a32 | ||
|
|
cedc15a291 | ||
|
|
10b476f6f1 | ||
|
|
c9af301ab8 | ||
|
|
3096c38438 | ||
|
|
572e700ed2 | ||
|
|
753c26bc00 | ||
|
|
c500b2204f | ||
|
|
6a0fff2492 | ||
|
|
3fa8901c7c | ||
|
|
34ceb50c65 | ||
|
|
9ab9b8f7e0 | ||
|
|
b31c1d7488 | ||
|
|
1b3298fc4c | ||
|
|
16efc6a01c | ||
|
|
d36c4ba843 | ||
|
|
85ef5e8e3f |
@@ -4482,6 +4482,104 @@ components:
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesDistributionAxes:
|
||||
properties:
|
||||
x:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionXAxis'
|
||||
description: The bucket axis.
|
||||
"y":
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionYAxis'
|
||||
description: The count axis.
|
||||
type: object
|
||||
DashboardtypesDistributionChartAppearance:
|
||||
properties:
|
||||
percentiles:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionPercentiles'
|
||||
description: Percentile markers drawn across the bucket axis.
|
||||
stack:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesStackMode'
|
||||
description: 'How groups combine in a bucket: normal (default) stacks them,
|
||||
none draws them side by side, percent stacks them as shares of the bucket.'
|
||||
required:
|
||||
- stack
|
||||
type: object
|
||||
DashboardtypesDistributionPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionAxes'
|
||||
description: The X (bucket) and Y (count) axis scales.
|
||||
chartAppearance:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionChartAppearance'
|
||||
description: Stacking and percentile markers.
|
||||
formatting:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
description: Unit and decimal precision of the bucket bounds; counts take
|
||||
no unit.
|
||||
legend:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesLegend'
|
||||
description: Legend placement and series colors.
|
||||
visualization:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
description: The time range the bucket counts are summed over.
|
||||
type: object
|
||||
DashboardtypesDistributionPercentiles:
|
||||
properties:
|
||||
enabled:
|
||||
description: Whether percentile markers are drawn. Defaults to true.
|
||||
type: boolean
|
||||
values:
|
||||
description: The percentiles to mark, each in (0, 100), at most 5, no duplicates.
|
||||
Defaults to [50, 90, 99].
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- enabled
|
||||
- values
|
||||
type: object
|
||||
DashboardtypesDistributionXAxis:
|
||||
properties:
|
||||
scale:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionXScale'
|
||||
description: 'How bucket bounds are spaced: auto (default), linear, log,
|
||||
symlog, or ordinal (equal widths labelled by range).'
|
||||
required:
|
||||
- scale
|
||||
type: object
|
||||
DashboardtypesDistributionXScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
- ordinal
|
||||
type: string
|
||||
DashboardtypesDistributionYAxis:
|
||||
properties:
|
||||
scale:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionYScale'
|
||||
description: 'How counts are spaced: linear (default) or log.'
|
||||
required:
|
||||
- scale
|
||||
type: object
|
||||
DashboardtypesDistributionYScale:
|
||||
enum:
|
||||
- linear
|
||||
- log
|
||||
type: string
|
||||
DashboardtypesDynamicVariableSignal:
|
||||
enum:
|
||||
- traces
|
||||
@@ -5252,6 +5350,7 @@ components:
|
||||
mapping:
|
||||
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/DistributionPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
|
||||
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
@@ -5274,6 +5373,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -5288,6 +5388,7 @@ components:
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
- signoz/DistributionPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -5319,6 +5420,21 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
description: The panel kind.
|
||||
enum:
|
||||
- signoz/DistributionPanel
|
||||
type: string
|
||||
spec:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesDistributionPanelSpec'
|
||||
description: The options for this panel kind. {} accepts every default.
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -5472,9 +5588,9 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPlugin'
|
||||
description: 'The visualization: kind is one of signoz/TimeSeriesPanel,
|
||||
signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel,
|
||||
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel
|
||||
or signoz/HeatmapPanel, and spec holds that kind''s options ({} accepts
|
||||
every default).'
|
||||
signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel,
|
||||
signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind''s
|
||||
options ({} accepts every default).'
|
||||
queries:
|
||||
description: The queries the panel runs. Use exactly one entry for a data
|
||||
panel and [] for signoz/TextPanel. To run several named queries or formulas,
|
||||
@@ -5610,8 +5726,8 @@ components:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5RequestType'
|
||||
description: 'The result shape the panel requests: time_series for time
|
||||
series, bar, area and histogram panels, scalar for number, table and pie
|
||||
panels, raw for list panels, heatmap for heatmap panels, trace for trace
|
||||
panels.'
|
||||
panels, raw for list panels, heatmap for heatmap panels, distribution
|
||||
for distribution panels, trace for trace panels.'
|
||||
spec:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesQuerySpec'
|
||||
@@ -5768,9 +5884,9 @@ components:
|
||||
(builder query over AI observability traces), signoz/CompositeQuery (several
|
||||
named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL
|
||||
or signoz/TraceOperator, and spec holds that kind''s query. Pie and table
|
||||
panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery
|
||||
and signoz/TraceOperator; list panels accept only signoz/BuilderQuery
|
||||
and signoz/AIBuilderQuery.'
|
||||
panels reject signoz/PromQLQuery; heatmap and distribution panels reject
|
||||
signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only
|
||||
signoz/BuilderQuery and signoz/AIBuilderQuery.'
|
||||
required:
|
||||
- plugin
|
||||
type: object
|
||||
@@ -9565,9 +9681,9 @@ components:
|
||||
Querybuildertypesv5AggregationMeta:
|
||||
properties:
|
||||
buckets:
|
||||
description: The ascending upper bounds of the value bands, set only for
|
||||
heatmap results; each point then carries one count per bound plus a trailing
|
||||
overflow count in values.
|
||||
description: The ascending upper bounds of the value buckets, set for heatmap
|
||||
and distribution results; each point then carries one count per bound
|
||||
plus a trailing overflow count in values.
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
@@ -9722,6 +9838,56 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5Distribution:
|
||||
properties:
|
||||
counts:
|
||||
description: One count per bound in meta.buckets plus a trailing overflow
|
||||
count, summed over every step of the requested time range.
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
nullable: true
|
||||
type: array
|
||||
labels:
|
||||
description: The group-by field values that identify this distribution.
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Label'
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5DistributionAggregation:
|
||||
properties:
|
||||
alias:
|
||||
description: The alias of the aggregation, or the internal name __result_N,
|
||||
with N its index, when no alias was given.
|
||||
type: string
|
||||
distributions:
|
||||
description: One distribution per group-by combination.
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Distribution'
|
||||
nullable: true
|
||||
type: array
|
||||
index:
|
||||
description: The zero-based position of the aggregation in the query.
|
||||
type: integer
|
||||
meta:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
|
||||
description: Display metadata for the aggregation, including the bucket
|
||||
upper bounds the counts are positional against.
|
||||
type: object
|
||||
Querybuildertypesv5DistributionData:
|
||||
properties:
|
||||
aggregations:
|
||||
description: One entry per aggregation of the query. The order is not guaranteed;
|
||||
match entries to aggregations by index.
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5DistributionAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
queryName:
|
||||
description: The name of the query that produced this result.
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5ExecStats:
|
||||
description: 'Execution cost of the request: rows and bytes scanned and execution
|
||||
time. A measure of cost, not of whether data exists.'
|
||||
@@ -10545,10 +10711,12 @@ components:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5TimeSeriesData'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5ScalarData'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5RawData'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5DistributionData'
|
||||
properties:
|
||||
results:
|
||||
description: 'One result per enabled query, typed by the request type: TimeSeriesData
|
||||
for time_series, ScalarData for scalar and RawData for raw requests.'
|
||||
for time_series and heatmap, ScalarData for scalar, RawData for raw and
|
||||
DistributionData for distribution requests.'
|
||||
items: {}
|
||||
nullable: true
|
||||
type: array
|
||||
@@ -10738,8 +10906,9 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5QueryRangeResponse:
|
||||
description: 'Response from the v5 query range endpoint. The data.results array
|
||||
contains typed results depending on the requestType: TimeSeriesData for time_series,
|
||||
ScalarData for scalar, or RawData for raw requests.'
|
||||
contains typed results depending on the requestType: TimeSeriesData for time_series
|
||||
and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for
|
||||
distribution requests.'
|
||||
properties:
|
||||
data:
|
||||
allOf:
|
||||
@@ -10836,6 +11005,7 @@ components:
|
||||
- raw_stream
|
||||
- trace
|
||||
- heatmap
|
||||
- distribution
|
||||
type: string
|
||||
Querybuildertypesv5ScalarData:
|
||||
properties:
|
||||
@@ -10947,8 +11117,8 @@ components:
|
||||
format: double
|
||||
type: number
|
||||
values:
|
||||
description: The per-band counts for heatmap results, in the order of meta.buckets
|
||||
plus a trailing overflow count.
|
||||
description: The per-bucket counts for heatmap results, in the order of
|
||||
meta.buckets plus a trailing overflow count.
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
|
||||
@@ -6921,6 +6921,108 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesScatterPlotPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel kind.
|
||||
*/
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind {
|
||||
'signoz/DistributionPanel' = 'signoz/DistributionPanel',
|
||||
}
|
||||
export enum DashboardtypesDistributionXScaleDTO {
|
||||
auto = 'auto',
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
symlog = 'symlog',
|
||||
ordinal = 'ordinal',
|
||||
}
|
||||
export interface DashboardtypesDistributionXAxisDTO {
|
||||
/**
|
||||
* @description How bucket bounds are spaced: auto (default), linear, log, symlog, or ordinal (equal widths labelled by range).
|
||||
*/
|
||||
scale: DashboardtypesDistributionXScaleDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesDistributionYScaleDTO {
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
}
|
||||
export interface DashboardtypesDistributionYAxisDTO {
|
||||
/**
|
||||
* @description How counts are spaced: linear (default) or log.
|
||||
*/
|
||||
scale: DashboardtypesDistributionYScaleDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionAxesDTO {
|
||||
/**
|
||||
* @description The bucket axis.
|
||||
*/
|
||||
x?: DashboardtypesDistributionXAxisDTO;
|
||||
/**
|
||||
* @description The count axis.
|
||||
*/
|
||||
y?: DashboardtypesDistributionYAxisDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionPercentilesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Whether percentile markers are drawn. Defaults to true.
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description The percentiles to mark, each in (0, 100), at most 5, no duplicates. Defaults to [50, 90, 99].
|
||||
*/
|
||||
values: number[] | null;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionChartAppearanceDTO {
|
||||
/**
|
||||
* @description Percentile markers drawn across the bucket axis.
|
||||
*/
|
||||
percentiles?: DashboardtypesDistributionPercentilesDTO;
|
||||
/**
|
||||
* @description How groups combine in a bucket: normal (default) stacks them, none draws them side by side, percent stacks them as shares of the bucket.
|
||||
*/
|
||||
stack: DashboardtypesStackModeDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesDistributionPanelSpecDTO {
|
||||
/**
|
||||
* @description The X (bucket) and Y (count) axis scales.
|
||||
*/
|
||||
axes?: DashboardtypesDistributionAxesDTO;
|
||||
/**
|
||||
* @description Stacking and percentile markers.
|
||||
*/
|
||||
chartAppearance?: DashboardtypesDistributionChartAppearanceDTO;
|
||||
/**
|
||||
* @description Unit and decimal precision of the bucket bounds; counts take no unit.
|
||||
*/
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
/**
|
||||
* @description Legend placement and series colors.
|
||||
*/
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @description The time range the bucket counts are summed over.
|
||||
*/
|
||||
visualization?: DashboardtypesBasicVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/DistributionPanel
|
||||
* @type string
|
||||
* @description The panel kind.
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind;
|
||||
/**
|
||||
* @description The options for this panel kind. {} accepts every default.
|
||||
*/
|
||||
spec: DashboardtypesDistributionPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -6932,7 +7034,8 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -6941,6 +7044,7 @@ export enum Querybuildertypesv5RequestTypeDTO {
|
||||
raw_stream = 'raw_stream',
|
||||
trace = 'trace',
|
||||
heatmap = 'heatmap',
|
||||
distribution = 'distribution',
|
||||
}
|
||||
/**
|
||||
* The query kind.
|
||||
@@ -7415,14 +7519,14 @@ export interface DashboardtypesQuerySpecDTO {
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @description The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery.
|
||||
* @description The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap and distribution panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery.
|
||||
*/
|
||||
plugin: DashboardtypesQueryPluginDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesQueryDTO {
|
||||
/**
|
||||
* @description The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, trace for trace panels.
|
||||
* @description The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, distribution for distribution panels, trace for trace panels.
|
||||
*/
|
||||
kind: Querybuildertypesv5RequestTypeDTO;
|
||||
/**
|
||||
@@ -7442,7 +7546,7 @@ export interface DashboardtypesPanelSpecDTO {
|
||||
*/
|
||||
links?: DashboardtypesLinkDTO[] | null;
|
||||
/**
|
||||
* @description The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel or signoz/HeatmapPanel, and spec holds that kind's options ({} accepts every default).
|
||||
* @description The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel, signoz/HeatmapPanel or signoz/DistributionPanel, and spec holds that kind's options ({} accepts every default).
|
||||
*/
|
||||
plugin: DashboardtypesPanelPluginDTO;
|
||||
/**
|
||||
@@ -8213,6 +8317,7 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
|
||||
'signoz/DistributionPanel' = 'signoz/DistributionPanel',
|
||||
}
|
||||
/**
|
||||
* The RFC 6902 operations, applied in order against the dashboard's postable shape.
|
||||
@@ -10977,7 +11082,7 @@ export interface Querybuildertypesv5TimeSeriesValueDTO {
|
||||
value?: number;
|
||||
/**
|
||||
* @type array
|
||||
* @description The per-band counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
|
||||
* @description The per-bucket counts for heatmap results, in the order of meta.buckets plus a trailing overflow count.
|
||||
*/
|
||||
values?: number[];
|
||||
}
|
||||
@@ -11598,7 +11703,7 @@ export interface PromotetypesPostableIndexMaterializedPathsDTO {
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @description The ascending upper bounds of the value bands, set only for heatmap results; each point then carries one count per bound plus a trailing overflow count in values.
|
||||
* @description The ascending upper bounds of the value buckets, set for heatmap and distribution results; each point then carries one count per bound plus a trailing overflow count in values.
|
||||
*/
|
||||
buckets?: number[];
|
||||
/**
|
||||
@@ -11719,6 +11824,54 @@ export interface Querybuildertypesv5ColumnDescriptorDTO {
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One count per bound in meta.buckets plus a trailing overflow count, summed over every step of the requested time range.
|
||||
*/
|
||||
counts?: number[] | null;
|
||||
/**
|
||||
* @type array
|
||||
* @description The group-by field values that identify this distribution.
|
||||
*/
|
||||
labels?: Querybuildertypesv5LabelDTO[];
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionAggregationDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The alias of the aggregation, or the internal name __result_N, with N its index, when no alias was given.
|
||||
*/
|
||||
alias?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One distribution per group-by combination.
|
||||
*/
|
||||
distributions?: Querybuildertypesv5DistributionDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @description The zero-based position of the aggregation in the query.
|
||||
*/
|
||||
index?: number;
|
||||
/**
|
||||
* @description Display metadata for the aggregation, including the bucket upper bounds the counts are positional against.
|
||||
*/
|
||||
meta?: Querybuildertypesv5AggregationMetaDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5DistributionDataDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One entry per aggregation of the query. The order is not guaranteed; match entries to aggregations by index.
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5DistributionAggregationDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @description The name of the query that produced this result.
|
||||
*/
|
||||
queryName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step interval in seconds that was used for each query, keyed by query name.
|
||||
*/
|
||||
@@ -11974,21 +12127,28 @@ export type Querybuildertypesv5QueryDataDTO =
|
||||
| (Querybuildertypesv5TimeSeriesDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5ScalarDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5RawDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series, ScalarData for scalar and RawData for raw requests.
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
})
|
||||
| (Querybuildertypesv5DistributionDataDTO & {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description One result per enabled query, typed by the request type: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw and DistributionData for distribution requests.
|
||||
*/
|
||||
results?: unknown[] | null;
|
||||
});
|
||||
@@ -12122,7 +12282,7 @@ export interface Querybuildertypesv5QueryRangeRequestDTO {
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series, ScalarData for scalar, or RawData for raw requests.
|
||||
* Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series and heatmap, ScalarData for scalar, RawData for raw, or DistributionData for distribution requests.
|
||||
*/
|
||||
export interface Querybuildertypesv5QueryRangeResponseDTO {
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,6 @@ import { SuccessResponse, Warning } from 'types/api';
|
||||
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
import {
|
||||
BuilderQuery,
|
||||
DistributionData,
|
||||
MetricRangePayloadV5,
|
||||
QueryEnvelope,
|
||||
QueryRangeRequestV5,
|
||||
@@ -326,20 +325,6 @@ function convertRawData(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts V5 DistributionData to legacy format
|
||||
*/
|
||||
function convertDistributionData(
|
||||
distributionData: DistributionData,
|
||||
legendMap: Record<string, string>,
|
||||
): any {
|
||||
// Convert V5 distribution format to legacy histogram format
|
||||
return {
|
||||
...distributionData,
|
||||
legendMap,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to convert V5 data based on type
|
||||
*/
|
||||
@@ -387,15 +372,6 @@ function convertV5DataByType(
|
||||
result: traceData.map((trace) => convertRawData(trace, legendMap)),
|
||||
};
|
||||
}
|
||||
case 'distribution': {
|
||||
const distributionData = v5Data.data.results as DistributionData[];
|
||||
return {
|
||||
resultType: 'distribution',
|
||||
result: distributionData.map((distribution) =>
|
||||
convertDistributionData(distribution, legendMap),
|
||||
),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
resultType: '',
|
||||
|
||||
@@ -906,6 +906,10 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
});
|
||||
|
||||
describe('mapPanelTypeToRequestType', () => {
|
||||
it('sends a histogram as time_series, which it bins in the browser', () => {
|
||||
expect(mapPanelTypeToRequestType(PANEL_TYPES.HISTOGRAM)).toBe('time_series');
|
||||
});
|
||||
|
||||
it('sends a scatter plot as scalar, like a table', () => {
|
||||
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
|
||||
});
|
||||
|
||||
@@ -47,8 +47,11 @@ type PrepareQueryRangePayloadV5Result = {
|
||||
*/
|
||||
export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
|
||||
switch (panelType) {
|
||||
// Histogram bins a time series in the browser; `distribution` is the
|
||||
// Distribution panel's request type.
|
||||
case PANEL_TYPES.TIME_SERIES:
|
||||
case PANEL_TYPES.BAR:
|
||||
case PANEL_TYPES.HISTOGRAM:
|
||||
return 'time_series';
|
||||
case PANEL_TYPES.TABLE:
|
||||
case PANEL_TYPES.SCATTER:
|
||||
@@ -59,8 +62,6 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
|
||||
return 'trace';
|
||||
case PANEL_TYPES.LIST:
|
||||
return 'raw';
|
||||
case PANEL_TYPES.HISTOGRAM:
|
||||
return 'distribution';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
import { Tooltip, TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { isBucketCountingPanelType } from 'lib/query/bucketCountingPanelTypes';
|
||||
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
|
||||
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
|
||||
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
|
||||
@@ -98,7 +99,7 @@ function QueryAddOns({
|
||||
}
|
||||
|
||||
if (
|
||||
panelType === PANEL_TYPES.HEATMAP &&
|
||||
isBucketCountingPanelType(panelType) &&
|
||||
query.dataSource === DataSource.METRICS &&
|
||||
!HISTOGRAM_ATTRIBUTE_TYPES.has(query.aggregateAttribute?.type ?? '')
|
||||
) {
|
||||
|
||||
@@ -107,8 +107,8 @@ export const BASE_ADD_ONS = (
|
||||
|
||||
export const REDUCE_TO = toAddOn(QueryBuilderField.ReduceTo);
|
||||
|
||||
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
|
||||
// against, and no other panel type sends one. A histogram brings its own buckets.
|
||||
// Offered only by a heatmap or distribution over metrics: the bucket axis is what they
|
||||
// plot against, and no other panel type sends one. A histogram brings its own buckets.
|
||||
export const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
|
||||
@@ -431,6 +431,24 @@ describe('QueryAddOns', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is offered on a metrics distribution too', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ dataSource: DataSource.METRICS })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.DISTRIBUTION}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('query-add-on-bucket_options'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is not offered on other panel types', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
|
||||
@@ -34,6 +34,7 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.HISTOGRAM]: Uplot,
|
||||
// V2-only kind; it renders through the V2 panel registry.
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
[PANEL_TYPES.DISTRIBUTION]: null,
|
||||
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.SCATTER]: null,
|
||||
|
||||
@@ -340,6 +340,7 @@ export enum PANEL_TYPES {
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
HEATMAP = 'heatmap',
|
||||
DISTRIBUTION = 'distribution',
|
||||
TEXT = 'text',
|
||||
EMPTY_WIDGET = 'EMPTY_WIDGET',
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Col, Input, Row, Select } from 'antd';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import BucketOptions from 'components/QueryBuilderV2/QueryV2/QueryAddOns/BucketOptions/BucketOptions';
|
||||
import { LEGEND } from 'constants/global';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { isBucketCountingPanelType } from 'lib/query/bucketCountingPanelTypes';
|
||||
// ** Hooks
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
@@ -178,9 +178,9 @@ export function Formula({
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
{/* A heatmap draws its one enabled query, which is the formula when its
|
||||
inputs are disabled — so the formula states its own bucket axis. */}
|
||||
{isQBV2 && panelType === PANEL_TYPES.HEATMAP && (
|
||||
{/* A heatmap or distribution draws its one enabled query, which is the formula
|
||||
when its inputs are disabled — so the formula states its own bucket axis. */}
|
||||
{isQBV2 && isBucketCountingPanelType(panelType) && (
|
||||
<Col span={24}>
|
||||
<BucketOptions
|
||||
bucketOptions={formula.bucketOptions}
|
||||
|
||||
@@ -32,6 +32,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
HEATMAP: false,
|
||||
DISTRIBUTION: false,
|
||||
TEXT: false,
|
||||
EMPTY_WIDGET: false,
|
||||
};
|
||||
|
||||
@@ -24,4 +24,5 @@ export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
|
||||
// V2-only kind; it renders through the V2 panel registry.
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
[PANEL_TYPES.DISTRIBUTION]: null,
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
} from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
import { getFormatedLegend } from 'utils/getFormatedLegend';
|
||||
import { isBucketCountingPanelType } from 'lib/query/bucketCountingPanelTypes';
|
||||
|
||||
export const useQueryOperations: UseQueryOperations = ({
|
||||
query,
|
||||
@@ -177,10 +178,9 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
(aggregateAttribute?.type as ATTRIBUTE_TYPES) || ATTRIBUTE_TYPES.GAUGE,
|
||||
});
|
||||
|
||||
const histogramSpaceAggregationOptions =
|
||||
panelType === PANEL_TYPES.HEATMAP
|
||||
? metricsHeatmapHistogramSpaceAggregateOperatorOptions
|
||||
: metricsHistogramSpaceAggregateOperatorOptions;
|
||||
const histogramSpaceAggregationOptions = isBucketCountingPanelType(panelType)
|
||||
? metricsHeatmapHistogramSpaceAggregateOperatorOptions
|
||||
: metricsHistogramSpaceAggregateOperatorOptions;
|
||||
|
||||
switch (aggregateAttribute?.type) {
|
||||
case ATTRIBUTE_TYPES.SUM:
|
||||
@@ -307,10 +307,9 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
// A heatmap cell holds a count of observations per `le` band, which is
|
||||
// the one option the kind offers — a percentile default would sit in the
|
||||
// selector with nothing behind it.
|
||||
spaceAggregation:
|
||||
panelType === PANEL_TYPES.HEATMAP
|
||||
? MetricAggregateOperator.COUNT
|
||||
: MetricAggregateOperator.P90,
|
||||
spaceAggregation: isBucketCountingPanelType(panelType)
|
||||
? MetricAggregateOperator.COUNT
|
||||
: MetricAggregateOperator.P90,
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import { isBucketCountingPanelType } from '../bucketCountingPanelTypes';
|
||||
|
||||
describe('isBucketCountingPanelType', () => {
|
||||
it('is true for heatmap and distribution only', () => {
|
||||
expect(isBucketCountingPanelType(PANEL_TYPES.HEATMAP)).toBe(true);
|
||||
expect(isBucketCountingPanelType(PANEL_TYPES.DISTRIBUTION)).toBe(true);
|
||||
expect(isBucketCountingPanelType(PANEL_TYPES.HISTOGRAM)).toBe(false);
|
||||
expect(isBucketCountingPanelType(PANEL_TYPES.TIME_SERIES)).toBe(false);
|
||||
expect(isBucketCountingPanelType(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,21 @@ describe('handleQueryChange bucket options', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps both axes when a heatmap switches to a distribution', () => {
|
||||
const result = handleQueryChange(
|
||||
PANEL_TYPES.DISTRIBUTION,
|
||||
queryWithFormulaAxis(),
|
||||
PANEL_TYPES.HEATMAP,
|
||||
);
|
||||
|
||||
expect(result.builder.queryData[0].bucketOptions).toStrictEqual(
|
||||
bucketOptions,
|
||||
);
|
||||
expect(result.builder.queryFormulas[0].bucketOptions).toStrictEqual(
|
||||
bucketOptions,
|
||||
);
|
||||
});
|
||||
|
||||
// The request rejects an axis on any type but heatmap, so a switch has to shed it
|
||||
// from the query (via the field allowlist) and from the formula (by hand).
|
||||
it('drops both axes when switching to another panel type', () => {
|
||||
|
||||
14
frontend/src/lib/query/bucketCountingPanelTypes.ts
Normal file
14
frontend/src/lib/query/bucketCountingPanelTypes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
/**
|
||||
* Panel types whose request counts observations into buckets (`heatmap`,
|
||||
* `distribution`): they share the bucket axis, its query-builder options and the
|
||||
* count aggregation a histogram metric gets.
|
||||
*/
|
||||
export function isBucketCountingPanelType(
|
||||
panelType: PANEL_TYPES | null | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
panelType === PANEL_TYPES.HEATMAP || panelType === PANEL_TYPES.DISTRIBUTION
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { cloneDeep, isEqual, set, unset } from 'lodash-es';
|
||||
import { isBucketCountingPanelType } from 'lib/query/bucketCountingPanelTypes';
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
@@ -102,13 +103,13 @@ export const getIsQueryModified = (
|
||||
/**
|
||||
* Formulas are carried across a panel-type switch whole, not rebuilt from
|
||||
* `panelTypeDataSourceFormValuesMap` the way `queryData` is, so a bucket axis has to be
|
||||
* dropped by hand. The request rejects one on any type but heatmap.
|
||||
* dropped by hand. The request rejects one on any type that does not count buckets.
|
||||
*/
|
||||
const withHeatmapBucketOptions = (
|
||||
formulas: IBuilderFormula[],
|
||||
newPanelType: keyof PartialPanelTypes,
|
||||
): IBuilderFormula[] => {
|
||||
if (newPanelType === PANEL_TYPES.HEATMAP) {
|
||||
if (isBucketCountingPanelType(newPanelType)) {
|
||||
return formulas;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export type PartialPanelTypes = {
|
||||
[PANEL_TYPES.SCATTER]: 'scatter';
|
||||
[PANEL_TYPES.HISTOGRAM]: 'histogram';
|
||||
[PANEL_TYPES.HEATMAP]: 'heatmap';
|
||||
[PANEL_TYPES.DISTRIBUTION]: 'distribution';
|
||||
};
|
||||
|
||||
export type BuilderField = keyof IBuilderQuery;
|
||||
@@ -149,6 +150,7 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
[PANEL_TYPES.BAR]: bySource(SERIES, SERIES_METRICS),
|
||||
[PANEL_TYPES.HISTOGRAM]: bySource(SERIES, SERIES_METRICS),
|
||||
[PANEL_TYPES.HEATMAP]: bySource(HEATMAP, HEATMAP_METRICS),
|
||||
[PANEL_TYPES.DISTRIBUTION]: bySource(HEATMAP, HEATMAP_METRICS),
|
||||
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
|
||||
[PANEL_TYPES.SCATTER]: bySource(SERIES, SCALAR_METRICS),
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
// Same surface as the shared and heatmap tooltips.
|
||||
.container {
|
||||
--gutter: 14px;
|
||||
--row-inset: 6px;
|
||||
|
||||
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: 240px;
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-6) var(--gutter);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--popover-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.range {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.total {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-4) calc(var(--gutter) - var(--row-inset))
|
||||
var(--spacing-6);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.columnHeader,
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(0, 1fr) auto 36px 36px;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.columnHeader {
|
||||
padding: 0 var(--row-inset) var(--spacing-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.columnLabel {
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
|
||||
.row {
|
||||
padding: var(--row-inset);
|
||||
border-radius: var(--radius);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rowHovered {
|
||||
background: var(--l3-background);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.marker {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius);
|
||||
border-style: solid;
|
||||
border-width: 1.5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.rowLabel,
|
||||
.rowValue {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rowValue {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.share {
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.percentiles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-4) var(--gutter) var(--spacing-6);
|
||||
}
|
||||
|
||||
.percentile {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { resolveRowIndex } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
formatPercentileName,
|
||||
resolveHoveredSeries,
|
||||
} from 'lib/visualization/charts/Distribution/percentiles';
|
||||
|
||||
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
|
||||
import {
|
||||
buildDistributionTooltipRows,
|
||||
formatBucketRange,
|
||||
formatPercent,
|
||||
} from './distributionTooltipContent';
|
||||
import { DistributionTooltipProps } from './types';
|
||||
|
||||
import Styles from './DistributionTooltip.module.scss';
|
||||
|
||||
/**
|
||||
* The bucket comes from the live cursor, not uPlot's `cursor.idx`: that snaps to the
|
||||
* nearest bucket centre, which on uneven widths is often the neighbour.
|
||||
*/
|
||||
export default function DistributionTooltip({
|
||||
uPlotInstance,
|
||||
xAxis,
|
||||
layout,
|
||||
labels,
|
||||
colors,
|
||||
counts,
|
||||
visible,
|
||||
stack,
|
||||
markers,
|
||||
formatBound,
|
||||
isPinned,
|
||||
dismiss,
|
||||
renderTooltipFooter,
|
||||
}: DistributionTooltipProps): JSX.Element | null {
|
||||
// Read outside the memo: uPlot mutates the same instance on every move.
|
||||
const { left = -10, top = -10 } = uPlotInstance.cursor;
|
||||
|
||||
const hover = useMemo(() => {
|
||||
if (left < 0 || top < 0) {
|
||||
return null;
|
||||
}
|
||||
const x = uPlotInstance.posToVal(left, 'x');
|
||||
const bucket = resolveRowIndex(xAxis.edges, x);
|
||||
if (bucket === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
bucket,
|
||||
series: resolveHoveredSeries(layout, x, uPlotInstance.posToVal(top, 'y')),
|
||||
};
|
||||
}, [left, top, uPlotInstance, xAxis, layout]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
hover
|
||||
? buildDistributionTooltipRows({
|
||||
labels,
|
||||
colors,
|
||||
counts,
|
||||
visible,
|
||||
bucket: hover.bucket,
|
||||
hoveredSeries: hover.series,
|
||||
stack,
|
||||
})
|
||||
: [],
|
||||
[hover, labels, colors, counts, visible, stack],
|
||||
);
|
||||
|
||||
if (!hover) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bucketTotal = rows.reduce((sum, row) => sum + row.count, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
|
||||
data-pinned={isPinned}
|
||||
data-testid="distribution-tooltip"
|
||||
>
|
||||
<div className={Styles.header}>
|
||||
<span className={Styles.range} data-testid="distribution-tooltip-range">
|
||||
{formatBucketRange(xAxis.rows[hover.bucket], formatBound)}
|
||||
</span>
|
||||
<span className={Styles.total} data-testid="distribution-tooltip-total">
|
||||
{bucketTotal.toLocaleString()}
|
||||
</span>
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} />
|
||||
|
||||
<div className={Styles.rows}>
|
||||
<div className={Styles.columnHeader}>
|
||||
<span className={Styles.columnLabel}>Group</span>
|
||||
<span>Count</span>
|
||||
<span className={Styles.share}>%</span>
|
||||
<span className={Styles.share}>≤ %</span>
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={cx(Styles.row, { [Styles.rowHovered]: row.isHovered })}
|
||||
data-testid="distribution-tooltip-row"
|
||||
data-hovered={row.isHovered}
|
||||
>
|
||||
<span
|
||||
className={Styles.marker}
|
||||
style={{ borderColor: row.color, backgroundColor: row.color }}
|
||||
/>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowValue}>{row.count.toLocaleString()}</span>
|
||||
<span className={Styles.share}>{formatPercent(row.share)}</span>
|
||||
<span className={Styles.share}>{formatPercent(row.cumulativeShare)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{markers.length > 0 && (
|
||||
<>
|
||||
<span className={Styles.divider} />
|
||||
<div
|
||||
className={Styles.percentiles}
|
||||
data-testid="distribution-tooltip-percentiles"
|
||||
>
|
||||
{markers.map((marker) => (
|
||||
<div key={marker.percentile} className={Styles.percentile}>
|
||||
<span>{formatPercentileName(marker.percentile)}</span>
|
||||
<span className={Styles.rowValue}>
|
||||
{marker.isOverflow
|
||||
? `> ${formatBound(marker.value)}`
|
||||
: `~${formatBound(marker.value)}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
resolveDistributionBars,
|
||||
resolveDistributionXAxis,
|
||||
} from 'lib/visualization/charts/Distribution/layout';
|
||||
import { computeDistributionPercentiles } from 'lib/visualization/charts/Distribution/percentiles';
|
||||
import { DistributionXScale } from 'lib/visualization/charts/Distribution/types';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import DistributionTooltip from '../DistributionTooltip';
|
||||
|
||||
// Buckets: ≤ 10, (10, 20], (20, 40], > 40, on a linear axis.
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40], DistributionXScale.Linear);
|
||||
const counts = [
|
||||
[1, 4, 2, 0],
|
||||
[0, 6, 1, 1],
|
||||
];
|
||||
const visible = [true, true];
|
||||
const layout = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
visible,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
/** Pixels are values: the cursor at (15, 2) is over the first group in (10, 20]. */
|
||||
function plotAt(left: number, top: number): uPlot {
|
||||
return {
|
||||
cursor: { left, top },
|
||||
posToVal: (position: number) => position,
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
function renderTooltip(uPlotInstance: uPlot): void {
|
||||
render(
|
||||
<DistributionTooltip
|
||||
uPlotInstance={uPlotInstance}
|
||||
isPinned={false}
|
||||
dismiss={jest.fn()}
|
||||
xAxis={xAxis}
|
||||
layout={layout}
|
||||
labels={['cart', 'checkout']}
|
||||
colors={['#f00', '#0f0']}
|
||||
counts={counts}
|
||||
visible={visible}
|
||||
stack={StackMode.Normal}
|
||||
markers={computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [1, 10, 3, 1],
|
||||
percentiles: [50],
|
||||
})}
|
||||
formatBound={(value): string => `${value} ms`}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('DistributionTooltip', () => {
|
||||
it('names the bucket under the cursor and its total', () => {
|
||||
renderTooltip(plotAt(15, 2));
|
||||
|
||||
expect(screen.getByTestId('distribution-tooltip-range')).toHaveTextContent(
|
||||
'(10 ms, 20 ms]',
|
||||
);
|
||||
expect(screen.getByTestId('distribution-tooltip-total')).toHaveTextContent(
|
||||
'10',
|
||||
);
|
||||
});
|
||||
|
||||
it('lists each group, the hovered one highlighted', () => {
|
||||
renderTooltip(plotAt(15, 2));
|
||||
|
||||
const rows = screen.getAllByTestId('distribution-tooltip-row');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toHaveTextContent('cart');
|
||||
expect(rows[0]).toHaveAttribute('data-hovered', 'true');
|
||||
expect(rows[1]).toHaveAttribute('data-hovered', 'false');
|
||||
});
|
||||
|
||||
it('lists the percentile estimates', () => {
|
||||
renderTooltip(plotAt(15, 2));
|
||||
|
||||
expect(
|
||||
screen.getByTestId('distribution-tooltip-percentiles'),
|
||||
).toHaveTextContent('p50~16.5 ms');
|
||||
});
|
||||
|
||||
it('renders nothing off the plot', () => {
|
||||
renderTooltip(plotAt(-1, -1));
|
||||
|
||||
expect(screen.queryByTestId('distribution-tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import {
|
||||
buildDistributionTooltipRows,
|
||||
formatBucketRange,
|
||||
formatPercent,
|
||||
} from '../distributionTooltipContent';
|
||||
|
||||
const formatBound = (value: number): string => `${value} ms`;
|
||||
|
||||
describe('formatBucketRange', () => {
|
||||
it('names a finite bucket by both bounds and the open ones by their one', () => {
|
||||
const base = {
|
||||
lower: 250,
|
||||
upper: 500,
|
||||
isUnderflow: false,
|
||||
isOverflow: false,
|
||||
};
|
||||
|
||||
expect(formatBucketRange(base, formatBound)).toBe('(250 ms, 500 ms]');
|
||||
expect(formatBucketRange({ ...base, isUnderflow: true }, formatBound)).toBe(
|
||||
'≤ 500 ms',
|
||||
);
|
||||
expect(formatBucketRange({ ...base, isOverflow: true }, formatBound)).toBe(
|
||||
'> 250 ms',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPercent', () => {
|
||||
it('rounds, and never shows a real share as 0%', () => {
|
||||
expect(formatPercent(0.256)).toBe('26%');
|
||||
expect(formatPercent(0.004)).toBe('<1%');
|
||||
expect(formatPercent(0)).toBe('0%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDistributionTooltipRows', () => {
|
||||
const args = {
|
||||
labels: ['cart', 'checkout', 'search'],
|
||||
colors: ['#f00', '#0f0', '#00f'],
|
||||
counts: [
|
||||
[2, 6, 2],
|
||||
[5, 0, 5],
|
||||
[1, 1, 8],
|
||||
],
|
||||
visible: [true, true, false],
|
||||
bucket: 1,
|
||||
hoveredSeries: 0,
|
||||
};
|
||||
|
||||
it("gives each visible group's count, share and cumulative share", () => {
|
||||
const rows = buildDistributionTooltipRows({
|
||||
...args,
|
||||
stack: StackMode.Normal,
|
||||
});
|
||||
|
||||
expect(rows).toStrictEqual([
|
||||
{
|
||||
label: 'cart',
|
||||
color: '#f00',
|
||||
count: 6,
|
||||
share: 0.6,
|
||||
cumulativeShare: 0.8,
|
||||
isHovered: true,
|
||||
},
|
||||
{
|
||||
label: 'checkout',
|
||||
color: '#0f0',
|
||||
count: 0,
|
||||
share: 0,
|
||||
cumulativeShare: 0.5,
|
||||
isHovered: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows only the hovered group side by side', () => {
|
||||
const rows = buildDistributionTooltipRows({ ...args, stack: StackMode.None });
|
||||
|
||||
expect(rows.map(({ label }) => label)).toStrictEqual(['cart']);
|
||||
});
|
||||
|
||||
it('shows every visible group side by side when none is hovered', () => {
|
||||
const rows = buildDistributionTooltipRows({
|
||||
...args,
|
||||
hoveredSeries: null,
|
||||
stack: StackMode.None,
|
||||
});
|
||||
|
||||
expect(rows.map(({ label }) => label)).toStrictEqual(['cart', 'checkout']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { DistributionBucket, DistributionTooltipRow } from './types';
|
||||
|
||||
/** `(250 ms, 500 ms]`; the open-ended buckets name their one real bound. */
|
||||
export function formatBucketRange(
|
||||
bucket: DistributionBucket,
|
||||
formatBound: (value: number) => string,
|
||||
): string {
|
||||
if (bucket.isOverflow) {
|
||||
return `> ${formatBound(bucket.lower)}`;
|
||||
}
|
||||
if (bucket.isUnderflow) {
|
||||
return `≤ ${formatBound(bucket.upper)}`;
|
||||
}
|
||||
return `(${formatBound(bucket.lower)}, ${formatBound(bucket.upper)}]`;
|
||||
}
|
||||
|
||||
/** `<1%` rather than `0%`, so a share that exists never reads as none. */
|
||||
export function formatPercent(share: number): string {
|
||||
if (share > 0 && share < 0.01) {
|
||||
return '<1%';
|
||||
}
|
||||
return `${Math.round(share * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per visible group with its count in `bucket`. Side by side shows only the
|
||||
* hovered group, since the cursor is over one of the sub-bars, not the bucket.
|
||||
*/
|
||||
export function buildDistributionTooltipRows({
|
||||
labels,
|
||||
colors,
|
||||
counts,
|
||||
visible,
|
||||
bucket,
|
||||
hoveredSeries,
|
||||
stack,
|
||||
}: {
|
||||
labels: string[];
|
||||
colors: string[];
|
||||
counts: number[][];
|
||||
visible: boolean[];
|
||||
bucket: number;
|
||||
hoveredSeries: number | null;
|
||||
stack: StackMode;
|
||||
}): DistributionTooltipRow[] {
|
||||
const onlyHovered = stack === StackMode.None && hoveredSeries !== null;
|
||||
|
||||
return labels.flatMap((label, series) => {
|
||||
if (!visible[series] || (onlyHovered && series !== hoveredSeries)) {
|
||||
return [];
|
||||
}
|
||||
const seriesCounts = counts[series] ?? [];
|
||||
let total = 0;
|
||||
let atOrBelow = 0;
|
||||
seriesCounts.forEach((count, index) => {
|
||||
total += count;
|
||||
if (index <= bucket) {
|
||||
atOrBelow += count;
|
||||
}
|
||||
});
|
||||
const count = seriesCounts[bucket] ?? 0;
|
||||
return [
|
||||
{
|
||||
label,
|
||||
color: colors[series],
|
||||
count,
|
||||
share: total > 0 ? count / total : 0,
|
||||
cumulativeShare: total > 0 ? atOrBelow / total : 0,
|
||||
isHovered: series === hoveredSeries,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import type { HeatmapRow } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import type { DistributionBarsLayout } from 'lib/visualization/charts/Distribution/layout';
|
||||
import type { DistributionPercentile } from 'lib/visualization/charts/Distribution/percentiles';
|
||||
import type { DistributionXAxis } from 'lib/visualization/charts/Distribution/types';
|
||||
|
||||
import type {
|
||||
IRenderTooltipFooterArgs,
|
||||
TooltipRenderArgs,
|
||||
} from '../../../types';
|
||||
|
||||
export type DistributionBucket = HeatmapRow;
|
||||
|
||||
export interface DistributionTooltipRow {
|
||||
label: string;
|
||||
color: string;
|
||||
count: number;
|
||||
/** Of the group's own total over the range. */
|
||||
share: number;
|
||||
/** Of the group's total, at or below this bucket. */
|
||||
cumulativeShare: number;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
export interface DistributionTooltipProps extends Pick<
|
||||
TooltipRenderArgs,
|
||||
'uPlotInstance' | 'isPinned' | 'dismiss'
|
||||
> {
|
||||
xAxis: DistributionXAxis;
|
||||
layout: DistributionBarsLayout;
|
||||
labels: string[];
|
||||
colors: string[];
|
||||
/** `counts[series][bucket]`. */
|
||||
counts: number[][];
|
||||
visible: boolean[];
|
||||
stack: StackMode;
|
||||
markers: DistributionPercentile[];
|
||||
formatBound: (value: number) => string;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { createVariableWidthBarsPathBuilder } from '../variableWidthBars';
|
||||
|
||||
class FakePath2D {
|
||||
rects: number[][] = [];
|
||||
|
||||
rect(x: number, y: number, width: number, height: number): void {
|
||||
this.rects.push([x, y, width, height]);
|
||||
}
|
||||
}
|
||||
|
||||
/** 10 px per unit on both axes; y grows downward from 100. */
|
||||
const plot = {
|
||||
valToPos: (value: number, scaleKey: string): number =>
|
||||
scaleKey === 'x' ? value * 10 : 100 - value * 10,
|
||||
ctx: {},
|
||||
} as unknown as uPlot;
|
||||
|
||||
describe('createVariableWidthBarsPathBuilder', () => {
|
||||
const originalPath2D = globalThis.Path2D;
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.Path2D = FakePath2D as unknown as typeof Path2D;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.Path2D = originalPath2D;
|
||||
});
|
||||
|
||||
function build(
|
||||
pattern: CanvasPattern | null = null,
|
||||
): ReturnType<uPlot.Series.PathBuilder> {
|
||||
return createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [
|
||||
{ x0: 0, x1: 1, y0: 0, y1: 2, hatched: false },
|
||||
{ x0: 1, x1: 4, y0: 0, y1: 5, hatched: false },
|
||||
{ x0: 4, x1: 6, y0: 1, y1: 3, hatched: true },
|
||||
],
|
||||
fill: '#ff0000',
|
||||
hatchedFill: '#ff000066',
|
||||
getHatchPattern: () => pattern,
|
||||
gapPx: 2,
|
||||
})(plot, 1, 0, 0);
|
||||
}
|
||||
|
||||
it('draws each bar at its own width, inset by half the gap', () => {
|
||||
const fills = build()?.fill as unknown as Map<string, FakePath2D>;
|
||||
|
||||
expect(fills.get('#ff0000')?.rects).toStrictEqual([
|
||||
[1, 80, 8, 20],
|
||||
[11, 50, 28, 50],
|
||||
]);
|
||||
});
|
||||
|
||||
it('draws hatched bars in their own fill, with the pattern on top', () => {
|
||||
const pattern = {} as CanvasPattern;
|
||||
const fills = build(pattern)?.fill as unknown as Map<unknown, FakePath2D>;
|
||||
|
||||
expect([...fills.keys()]).toStrictEqual(['#ff0000', '#ff000066', pattern]);
|
||||
expect(fills.get(pattern)?.rects).toStrictEqual([[41, 70, 18, 20]]);
|
||||
});
|
||||
it('reads a fill callback on every draw', () => {
|
||||
let fill = '#ff0000';
|
||||
const builder = createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false }],
|
||||
fill: () => fill,
|
||||
hatchedFill: '#00ff00',
|
||||
getHatchPattern: () => null,
|
||||
gapPx: 0,
|
||||
});
|
||||
|
||||
const first = builder(plot, 1, 0, 0)?.fill as unknown as Map<string, unknown>;
|
||||
fill = '#0000ff';
|
||||
const second = builder(plot, 1, 0, 0)?.fill as unknown as Map<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect([...first.keys()]).toStrictEqual(['#ff0000']);
|
||||
expect([...second.keys()]).toStrictEqual(['#0000ff']);
|
||||
});
|
||||
|
||||
it('keeps solid bars when the hatched fill is the same colour', () => {
|
||||
const fills = createVariableWidthBarsPathBuilder({
|
||||
getBars: () => [
|
||||
{ x0: 0, x1: 1, y0: 0, y1: 1, hatched: false },
|
||||
{ x0: 1, x1: 2, y0: 0, y1: 1, hatched: true },
|
||||
],
|
||||
fill: 'red',
|
||||
hatchedFill: 'red',
|
||||
getHatchPattern: () => null,
|
||||
gapPx: 0,
|
||||
})(plot, 1, 0, 0)?.fill as unknown as Map<string, FakePath2D>;
|
||||
|
||||
expect(fills.get('red')?.rects).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
79
frontend/src/lib/uPlotV2/paths/variableWidthBars.ts
Normal file
79
frontend/src/lib/uPlotV2/paths/variableWidthBars.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
/** A bar in scale space. Unlike uPlot's own bars, each one carries its own width. */
|
||||
export interface VariableWidthBar {
|
||||
x0: number;
|
||||
x1: number;
|
||||
y0: number;
|
||||
y1: number;
|
||||
/** Drawn in `hatchedFill` with the hatch pattern on top. */
|
||||
hatched: boolean;
|
||||
}
|
||||
|
||||
/** A fill, or one read on every draw (e.g. to dim a series on hover). */
|
||||
type BarFill = string | (() => string);
|
||||
|
||||
/** Bars narrower than this, in CSS pixels, keep no gap between them. */
|
||||
const MIN_WIDTH_FOR_GAP_PX = 3;
|
||||
|
||||
/**
|
||||
* Draws `[x0, x1] × [y0, y1]` rects on the `x` and `y` scales. uPlot's bar builder
|
||||
* assumes one width for every bar, which a bucket axis with uneven bounds breaks.
|
||||
*
|
||||
* `getBars` runs on every redraw, so a caller can derive the bars from the plot's
|
||||
* current state (e.g. which series are shown).
|
||||
*/
|
||||
export function createVariableWidthBarsPathBuilder({
|
||||
getBars,
|
||||
fill,
|
||||
hatchedFill,
|
||||
getHatchPattern,
|
||||
gapPx,
|
||||
}: {
|
||||
getBars: (u: uPlot, seriesIdx: number) => VariableWidthBar[];
|
||||
fill: BarFill;
|
||||
hatchedFill: BarFill;
|
||||
getHatchPattern: (ctx: CanvasRenderingContext2D) => CanvasPattern | null;
|
||||
/** Space between neighbouring bars, in CSS pixels. */
|
||||
gapPx: number;
|
||||
}): uPlot.Series.PathBuilder {
|
||||
return (u, seriesIdx): uPlot.Series.Paths => {
|
||||
const solidFill = resolveFill(fill);
|
||||
const overflowFill = resolveFill(hatchedFill);
|
||||
const solidPath = new Path2D();
|
||||
// A Map keyed by fill would let the second path overwrite the first.
|
||||
const hatchedPath = overflowFill === solidFill ? solidPath : new Path2D();
|
||||
const inset = (gapPx * uPlot.pxRatio) / 2;
|
||||
const minWidthForGap = MIN_WIDTH_FOR_GAP_PX * uPlot.pxRatio;
|
||||
let hasHatched = false;
|
||||
|
||||
getBars(u, seriesIdx).forEach((bar) => {
|
||||
const left = u.valToPos(bar.x0, 'x', true);
|
||||
const right = u.valToPos(bar.x1, 'x', true);
|
||||
const top = u.valToPos(bar.y1, 'y', true);
|
||||
const bottom = u.valToPos(bar.y0, 'y', true);
|
||||
const width = right - left;
|
||||
const gap = width > minWidthForGap ? inset : 0;
|
||||
const path = bar.hatched ? hatchedPath : solidPath;
|
||||
path.rect(left + gap, top, width - 2 * gap, bottom - top);
|
||||
hasHatched ||= bar.hatched;
|
||||
});
|
||||
|
||||
const fills = new Map<CanvasRenderingContext2D['fillStyle'], Path2D>([
|
||||
[solidFill, solidPath],
|
||||
]);
|
||||
if (hasHatched) {
|
||||
fills.set(overflowFill, hatchedPath);
|
||||
const pattern = getHatchPattern(u.ctx);
|
||||
if (pattern) {
|
||||
fills.set(pattern, hatchedPath);
|
||||
}
|
||||
}
|
||||
|
||||
return { stroke: null, fill: fills, clip: null, band: null, flags: 0 };
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFill(fill: BarFill): string {
|
||||
return typeof fill === 'function' ? fill() : fill;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.notice {
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import DistributionTooltip from 'lib/uPlotV2/components/Tooltip/components/DistributionTooltip/DistributionTooltip';
|
||||
import {
|
||||
LegendPosition,
|
||||
type TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import { resolveBoundaryPrecision } from 'lib/visualization/charts/Heatmap/utils';
|
||||
import { DistributionChartProps } from 'lib/visualization/charts/types';
|
||||
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
|
||||
import type { LegendLayout } from 'lib/visualization/layout/ChartLayout/ChartLayout';
|
||||
|
||||
import {
|
||||
cropDistributionXAxis,
|
||||
resolveDistributionBars,
|
||||
resolveDistributionXAxis,
|
||||
sumVisibleCounts,
|
||||
} from './layout';
|
||||
import {
|
||||
computeDistributionPercentiles,
|
||||
resolveHoveredSeries,
|
||||
} from './percentiles';
|
||||
import {
|
||||
groupIntoOther,
|
||||
resolveClickedGroup,
|
||||
resolveMaxDrawnGroups,
|
||||
} from './topGroups';
|
||||
import { DistributionXScale, DistributionYScale } from './types';
|
||||
|
||||
import styles from './Distribution.module.scss';
|
||||
import {
|
||||
buildDistributionConfig,
|
||||
createBoundFormatter,
|
||||
resolveOtherColor,
|
||||
prepareDistributionChartData,
|
||||
resolveSeriesColor,
|
||||
} from './utils';
|
||||
|
||||
/** Rough share of the panel width the plot gets, for fitting side-by-side bars. */
|
||||
const PLOT_SHARE = 0.9;
|
||||
const PLOT_SHARE_WITH_RIGHT_LEGEND = 0.65;
|
||||
/** Vertical space the Other notice takes out of the container. */
|
||||
const NOTICE_HEIGHT = 20;
|
||||
|
||||
export default function Distribution({
|
||||
id,
|
||||
buckets,
|
||||
series,
|
||||
width,
|
||||
height,
|
||||
isDarkMode,
|
||||
xScale = DistributionXScale.Auto,
|
||||
yScale = DistributionYScale.Linear,
|
||||
stack = StackMode.Normal,
|
||||
xAxisUnit,
|
||||
decimalPrecision,
|
||||
customColors,
|
||||
percentiles = [],
|
||||
onBarClick,
|
||||
showLegend = true,
|
||||
legendPosition = LegendPosition.BOTTOM,
|
||||
showTooltip = true,
|
||||
canPinTooltip = false,
|
||||
renderTooltipFooter,
|
||||
tooltipPortalRoot,
|
||||
layoutChildren,
|
||||
'data-testid': testId,
|
||||
}: DistributionChartProps): JSX.Element {
|
||||
const labels = useMemo(() => series.map((entry) => entry.label), [series]);
|
||||
const counts = useMemo(() => series.map((entry) => entry.counts), [series]);
|
||||
|
||||
const { visibleKeys, focusedSeriesIndex, onLegendAction } =
|
||||
useLegendVisibility({ keys: labels, indexOffset: 1, id });
|
||||
|
||||
const visible = useMemo(
|
||||
() => labels.map((label) => visibleKeys.includes(label)),
|
||||
[labels, visibleKeys],
|
||||
);
|
||||
|
||||
const colors = useMemo(
|
||||
() =>
|
||||
labels.map((label) => resolveSeriesColor(label, isDarkMode, customColors)),
|
||||
[labels, isDarkMode, customColors],
|
||||
);
|
||||
|
||||
const { xAxis, totals } = useMemo(() => {
|
||||
const full = resolveDistributionXAxis(buckets, xScale);
|
||||
const visibleTotals = sumVisibleCounts(counts, visible, full.rows.length);
|
||||
return {
|
||||
xAxis: cropDistributionXAxis(full, visibleTotals),
|
||||
totals: visibleTotals,
|
||||
};
|
||||
}, [buckets, xScale, counts, visible]);
|
||||
|
||||
// Over the visible groups combined, so a legend toggle moves them without a refetch.
|
||||
const markers = useMemo(
|
||||
() => computeDistributionPercentiles({ xAxis, totals, percentiles }),
|
||||
[xAxis, totals, percentiles],
|
||||
);
|
||||
|
||||
const isLogY =
|
||||
yScale === DistributionYScale.Log && stack !== StackMode.Percent;
|
||||
|
||||
// The legend lists every group; the plot draws the largest and sums the rest into
|
||||
// Other, so a toggle re-ranks without a refetch.
|
||||
const drawn = useMemo(
|
||||
() =>
|
||||
groupIntoOther({
|
||||
labels,
|
||||
counts,
|
||||
colors,
|
||||
visible,
|
||||
maxGroups: resolveMaxDrawnGroups({
|
||||
stack,
|
||||
xAxis,
|
||||
plotWidth:
|
||||
width *
|
||||
(legendPosition === LegendPosition.RIGHT
|
||||
? PLOT_SHARE_WITH_RIGHT_LEGEND
|
||||
: PLOT_SHARE),
|
||||
}),
|
||||
otherColor: resolveOtherColor(isDarkMode),
|
||||
}),
|
||||
[
|
||||
labels,
|
||||
counts,
|
||||
colors,
|
||||
visible,
|
||||
stack,
|
||||
xAxis,
|
||||
width,
|
||||
legendPosition,
|
||||
isDarkMode,
|
||||
],
|
||||
);
|
||||
const drawnVisible = useMemo(() => drawn.labels.map(() => true), [drawn]);
|
||||
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: drawn.counts,
|
||||
visible: drawnVisible,
|
||||
stack,
|
||||
isLogY,
|
||||
}),
|
||||
[xAxis, drawn, drawnVisible, stack, isLogY],
|
||||
);
|
||||
|
||||
const boundaryPrecision = useMemo(
|
||||
() =>
|
||||
resolveBoundaryPrecision({
|
||||
yAxis: xAxis,
|
||||
yAxisUnit: xAxisUnit,
|
||||
decimalPrecision,
|
||||
}),
|
||||
[xAxis, xAxisUnit, decimalPrecision],
|
||||
);
|
||||
|
||||
const formatBound = useMemo(
|
||||
() => createBoundFormatter(xAxisUnit, boundaryPrecision),
|
||||
[xAxisUnit, boundaryPrecision],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildDistributionConfig({
|
||||
id,
|
||||
xAxis,
|
||||
layout,
|
||||
labels: drawn.labels,
|
||||
colors: drawn.colors,
|
||||
visible: drawnVisible,
|
||||
stack,
|
||||
isLogY,
|
||||
isDarkMode,
|
||||
markers,
|
||||
xAxisUnit,
|
||||
decimalPrecision: boundaryPrecision,
|
||||
}),
|
||||
[
|
||||
id,
|
||||
xAxis,
|
||||
layout,
|
||||
drawn,
|
||||
drawnVisible,
|
||||
stack,
|
||||
isLogY,
|
||||
isDarkMode,
|
||||
markers,
|
||||
xAxisUnit,
|
||||
boundaryPrecision,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => (
|
||||
<DistributionTooltip
|
||||
uPlotInstance={args.uPlotInstance}
|
||||
isPinned={args.isPinned}
|
||||
dismiss={args.dismiss}
|
||||
xAxis={xAxis}
|
||||
layout={layout}
|
||||
labels={drawn.labels}
|
||||
colors={drawn.colors}
|
||||
counts={drawn.counts}
|
||||
visible={drawnVisible}
|
||||
stack={stack}
|
||||
markers={markers}
|
||||
formatBound={formatBound}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
/>
|
||||
),
|
||||
[
|
||||
xAxis,
|
||||
layout,
|
||||
drawn,
|
||||
drawnVisible,
|
||||
stack,
|
||||
markers,
|
||||
formatBound,
|
||||
renderTooltipFooter,
|
||||
],
|
||||
);
|
||||
|
||||
const onBarClickRef = useRef(onBarClick);
|
||||
onBarClickRef.current = onBarClick;
|
||||
|
||||
const handleClick = useCallback(
|
||||
({
|
||||
xValue,
|
||||
yValue,
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
}: ChartClickData): void => {
|
||||
const clicked = resolveClickedGroup(
|
||||
drawn,
|
||||
resolveHoveredSeries(layout, xValue, yValue),
|
||||
series,
|
||||
);
|
||||
if (clicked) {
|
||||
onBarClickRef.current?.({
|
||||
...clicked,
|
||||
coordinates: { x: absoluteMouseX, y: absoluteMouseY },
|
||||
});
|
||||
}
|
||||
},
|
||||
[layout, drawn, series],
|
||||
);
|
||||
|
||||
const data = useMemo(
|
||||
() => prepareDistributionChartData(xAxis, drawn.counts),
|
||||
[xAxis, drawn],
|
||||
);
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
labels.map((label, index) => ({
|
||||
seriesIndex: index + 1,
|
||||
label,
|
||||
color: colors[index],
|
||||
show: visible[index],
|
||||
})),
|
||||
[labels, colors, visible],
|
||||
);
|
||||
|
||||
const groupLegend = useCallback(
|
||||
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => (
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={legendPosition}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showSearch}
|
||||
itemLabel="groups"
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
),
|
||||
[legendItems, legendPosition, focusedSeriesIndex, onLegendAction],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
config={config}
|
||||
data={data}
|
||||
width={width}
|
||||
height={
|
||||
drawn.otherGroupCount > 0 ? Math.max(0, height - NOTICE_HEIGHT) : height
|
||||
}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
showLegend={showLegend}
|
||||
customLegend={groupLegend}
|
||||
legendLabels={labels}
|
||||
showTooltip={showTooltip}
|
||||
canPinTooltip={canPinTooltip}
|
||||
decimalPrecision={decimalPrecision}
|
||||
customTooltip={renderTooltip}
|
||||
onClick={onBarClick ? handleClick : undefined}
|
||||
contentFooter={
|
||||
drawn.otherGroupCount > 0 && (
|
||||
<div className={styles.notice} data-testid="distribution-other-notice">
|
||||
Showing top {drawn.labels.length - 1} of {drawn.visibleGroupCount} groups;
|
||||
the rest are summed into Other.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
tooltipPortalRoot={tooltipPortalRoot}
|
||||
layoutChildren={layoutChildren}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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 Distribution from '../Distribution';
|
||||
import type { DistributionSeries } from '../types';
|
||||
|
||||
// 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 = [5, 10, 100, 1000];
|
||||
|
||||
const SERIES: DistributionSeries[] = [
|
||||
{ label: 'service.name=cart', counts: [3, 40, 12, 1, 0] },
|
||||
{ label: 'service.name=checkout', counts: [1, 10, 30, 6, 2] },
|
||||
];
|
||||
|
||||
function renderDistribution(
|
||||
props: Partial<React.ComponentProps<typeof Distribution>> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<Distribution
|
||||
id="panel-1"
|
||||
buckets={BUCKETS}
|
||||
series={SERIES}
|
||||
width={800}
|
||||
height={400}
|
||||
isDarkMode
|
||||
data-testid="distribution"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Distribution', () => {
|
||||
const CART = 'service.name=cart';
|
||||
const CHECKOUT = 'service.name=checkout';
|
||||
|
||||
// The selection persists under the panel id, which every case here shares.
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
function legendItem(label: string): HTMLElement {
|
||||
return screen.getByRole('switch', { name: label });
|
||||
}
|
||||
|
||||
it('renders the plot container', () => {
|
||||
renderDistribution();
|
||||
|
||||
expect(screen.getByTestId('distribution')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists every group in the legend, all shown', () => {
|
||||
renderDistribution();
|
||||
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('isolates a group when its label is clicked', async () => {
|
||||
renderDistribution();
|
||||
|
||||
await userEvent.click(screen.getByText(CART));
|
||||
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('hides only that group when its marker is clicked', async () => {
|
||||
renderDistribution();
|
||||
|
||||
const marker = legendItem(CHECKOUT).querySelector<HTMLElement>(
|
||||
'[data-is-legend-marker]',
|
||||
);
|
||||
if (!marker) {
|
||||
throw new Error('no marker for checkout');
|
||||
}
|
||||
await userEvent.click(marker);
|
||||
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
it('sums groups past the stacked cap into Other, with a notice', () => {
|
||||
renderDistribution({
|
||||
series: Array.from({ length: 12 }, (_, index) => ({
|
||||
label: `group-${index}`,
|
||||
counts: [index, 1, 1, 0, 0],
|
||||
})),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('distribution-other-notice')).toHaveTextContent(
|
||||
'Showing top 9 of 12 groups; the rest are summed into Other.',
|
||||
);
|
||||
// The legend still lists every group, so any can be isolated.
|
||||
expect(legendItem('group-0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no notice when every group fits', () => {
|
||||
renderDistribution();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('distribution-other-notice'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import {
|
||||
cropDistributionXAxis,
|
||||
LOG_Y_BASE,
|
||||
resolveDistributionBars,
|
||||
resolveDistributionXAxis,
|
||||
sumVisibleCounts,
|
||||
thinLabelledSplits,
|
||||
} from '../layout';
|
||||
import { DistributionXScale } from '../types';
|
||||
|
||||
function widths(edges: number[]): number[] {
|
||||
return edges.slice(1).map((edge, index) => edge - edges[index]);
|
||||
}
|
||||
|
||||
describe('resolveDistributionXAxis', () => {
|
||||
it('lays N bounds out as N + 1 buckets, overflow last', () => {
|
||||
const axis = resolveDistributionXAxis(
|
||||
[10, 20, 30],
|
||||
DistributionXScale.Linear,
|
||||
);
|
||||
|
||||
expect(axis.rows).toHaveLength(4);
|
||||
expect(axis.rows[0].isUnderflow).toBe(true);
|
||||
expect(axis.rows[3]).toMatchObject({ lower: 30, isOverflow: true });
|
||||
expect(axis.edges).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('keeps auto linear when the bounds span under two decades', () => {
|
||||
const auto = resolveDistributionXAxis(
|
||||
[100, 200, 400],
|
||||
DistributionXScale.Auto,
|
||||
);
|
||||
const linear = resolveDistributionXAxis(
|
||||
[100, 200, 400],
|
||||
DistributionXScale.Linear,
|
||||
);
|
||||
|
||||
expect(auto.edges).toStrictEqual(linear.edges);
|
||||
});
|
||||
|
||||
it('turns auto log once the bounds span two decades', () => {
|
||||
const axis = resolveDistributionXAxis([1, 10, 100], DistributionXScale.Auto);
|
||||
|
||||
expect(axis.splits).toStrictEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('gives every bucket the same width on ordinal, ticks on the bounds', () => {
|
||||
const axis = resolveDistributionXAxis(
|
||||
[0, 5, 10, 25, 1000],
|
||||
DistributionXScale.Ordinal,
|
||||
);
|
||||
|
||||
expect(widths(axis.edges)).toStrictEqual([1, 1, 1, 1, 1, 1]);
|
||||
expect(axis.splits).toStrictEqual([1, 2, 3, 4, 5]);
|
||||
expect(axis.overflowSplit).toBe(6);
|
||||
expect(axis.toBucketValue(3.5)).toBe(17.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cropDistributionXAxis', () => {
|
||||
it('stops one bucket past the last occupied one', () => {
|
||||
const axis = resolveDistributionXAxis(
|
||||
[1, 2, 3, 4],
|
||||
DistributionXScale.Ordinal,
|
||||
);
|
||||
|
||||
const cropped = cropDistributionXAxis(axis, [1, 5, 0, 0, 0]);
|
||||
|
||||
expect(cropped.max).toBe(3);
|
||||
expect(cropped.overflowSplit).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sumVisibleCounts', () => {
|
||||
it('adds only the visible series', () => {
|
||||
expect(
|
||||
sumVisibleCounts(
|
||||
[
|
||||
[1, 2],
|
||||
[10, 20],
|
||||
[100, 200],
|
||||
],
|
||||
[true, false, true],
|
||||
2,
|
||||
),
|
||||
).toStrictEqual([101, 202]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDistributionBars', () => {
|
||||
const xAxis = resolveDistributionXAxis([1, 2], DistributionXScale.Ordinal);
|
||||
const counts = [
|
||||
[4, 6, 0],
|
||||
[1, 3, 2],
|
||||
];
|
||||
|
||||
it('stacks visible series in each bucket under normal', () => {
|
||||
const { rects, yMax } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
visible: [true, true],
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[0].map(({ y0, y1 }) => [y0, y1])).toStrictEqual([
|
||||
[0, 4],
|
||||
[0, 6],
|
||||
]);
|
||||
expect(rects[1].map(({ y0, y1 }) => [y0, y1])).toStrictEqual([
|
||||
[4, 5],
|
||||
[6, 9],
|
||||
[0, 2],
|
||||
]);
|
||||
expect(yMax).toBe(9);
|
||||
});
|
||||
|
||||
it('marks the overflow bucket hatched', () => {
|
||||
const { rects } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
visible: [true, true],
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[1].map(({ hatched }) => hatched)).toStrictEqual([
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('stacks shares of each bucket under percent', () => {
|
||||
const { rects, yMax } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
visible: [true, true],
|
||||
stack: StackMode.Percent,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[0][0]).toMatchObject({ y0: 0, y1: 80 });
|
||||
expect(rects[1][0]).toMatchObject({ y0: 80, y1: 100 });
|
||||
expect(yMax).toBe(100);
|
||||
});
|
||||
|
||||
it('splits the bucket width between visible series under none', () => {
|
||||
const { rects, yMax } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
visible: [true, true],
|
||||
stack: StackMode.None,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[0][0]).toMatchObject({ x0: 0, x1: 0.5, y0: 0, y1: 4 });
|
||||
expect(rects[1][0]).toMatchObject({ x0: 0.5, x1: 1, y0: 0, y1: 1 });
|
||||
expect(yMax).toBe(6);
|
||||
});
|
||||
|
||||
it('leaves a hidden series out of the stack and the width split', () => {
|
||||
const { rects } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
visible: [false, true],
|
||||
stack: StackMode.None,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
expect(rects[0]).toStrictEqual([]);
|
||||
expect(rects[1][0]).toMatchObject({ x0: 0, x1: 1, y1: 1 });
|
||||
});
|
||||
|
||||
it('starts bars at the log base and skips zero counts', () => {
|
||||
const { rects } = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
visible: [true, true],
|
||||
stack: StackMode.Normal,
|
||||
isLogY: true,
|
||||
});
|
||||
|
||||
expect(rects[0].map(({ y0 }) => y0)).toStrictEqual([LOG_Y_BASE, LOG_Y_BASE]);
|
||||
expect(rects[0].map(({ bucketIndex }) => bucketIndex)).toStrictEqual([0, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('thinLabelledSplits', () => {
|
||||
it('keeps labels that clear each other by the gap', () => {
|
||||
expect(
|
||||
thinLabelledSplits({
|
||||
positions: [0, 50, 100],
|
||||
widths: [20, 20, 20],
|
||||
gap: 10,
|
||||
}),
|
||||
).toStrictEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('drops a wide label that would touch its neighbour, keeping the rightmost', () => {
|
||||
// 16.67 mins-wide labels 60 px apart: each needs 70 px of centre distance.
|
||||
expect(
|
||||
thinLabelledSplits({
|
||||
positions: [0, 60, 120, 180],
|
||||
widths: [60, 60, 60, 60],
|
||||
gap: 10,
|
||||
}),
|
||||
).toStrictEqual([1, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { resolveDistributionBars, resolveDistributionXAxis } from '../layout';
|
||||
import {
|
||||
computeDistributionPercentiles,
|
||||
formatPercentileMarker,
|
||||
formatPercentileName,
|
||||
layoutMarkerLabels,
|
||||
resolveHoveredSeries,
|
||||
} from '../percentiles';
|
||||
import { DistributionXScale } from '../types';
|
||||
|
||||
// Buckets: ≤ 10, (10, 20], (20, 40], > 40.
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40], DistributionXScale.Linear);
|
||||
|
||||
describe('computeDistributionPercentiles', () => {
|
||||
it('interpolates inside the bucket holding the rank, as histogram_quantile does', () => {
|
||||
const markers = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [10, 20, 10, 0],
|
||||
percentiles: [50, 90],
|
||||
});
|
||||
|
||||
expect(markers.map(({ value }) => value)).toStrictEqual([15, 32]);
|
||||
expect(markers.every(({ isOverflow }) => !isOverflow)).toBe(true);
|
||||
});
|
||||
|
||||
it('places the marker the same fraction across the bucket on the axis', () => {
|
||||
const [p50] = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [10, 20, 10, 0],
|
||||
percentiles: [50],
|
||||
});
|
||||
|
||||
expect(p50.axisValue).toBe(15);
|
||||
});
|
||||
|
||||
it('starts the lowest bucket at zero', () => {
|
||||
const [p10] = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [10, 20, 10, 0],
|
||||
percentiles: [10],
|
||||
});
|
||||
|
||||
expect(p10.value).toBe(4);
|
||||
});
|
||||
|
||||
it('reports a percentile in overflow as above the last bound, at its left edge', () => {
|
||||
const [p99] = computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [0, 0, 10, 10],
|
||||
percentiles: [99],
|
||||
});
|
||||
|
||||
expect(p99).toMatchObject({ value: 40, axisValue: 40, isOverflow: true });
|
||||
});
|
||||
|
||||
it('marks nothing when every count is zero', () => {
|
||||
expect(
|
||||
computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals: [0, 0, 0, 0],
|
||||
percentiles: [50],
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPercentileMarker', () => {
|
||||
const formatBound = (value: number): string => `${value} ms`;
|
||||
|
||||
it('labels an estimate with a tilde and an overflow with its bound', () => {
|
||||
expect(
|
||||
formatPercentileMarker(
|
||||
{ percentile: 99, value: 32, axisValue: 32, isOverflow: false },
|
||||
formatBound,
|
||||
),
|
||||
).toBe('~p99');
|
||||
expect(
|
||||
formatPercentileMarker(
|
||||
{ percentile: 99.9, value: 40, axisValue: 40, isOverflow: true },
|
||||
formatBound,
|
||||
),
|
||||
).toBe('p99.9 > 40 ms');
|
||||
});
|
||||
|
||||
it('keeps fractional percentile names short', () => {
|
||||
expect(formatPercentileName(99.9)).toBe('p99.9');
|
||||
expect(formatPercentileName(50)).toBe('p50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHoveredSeries', () => {
|
||||
const layout = resolveDistributionBars({
|
||||
xAxis,
|
||||
counts: [
|
||||
[0, 4, 0, 0],
|
||||
[0, 6, 0, 0],
|
||||
],
|
||||
visible: [true, true],
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
});
|
||||
|
||||
it('names the series whose bar is under the point', () => {
|
||||
expect(resolveHoveredSeries(layout, 15, 2)).toBe(0);
|
||||
expect(resolveHoveredSeries(layout, 15, 8)).toBe(1);
|
||||
});
|
||||
|
||||
it('is null above the stack or in an empty bucket', () => {
|
||||
expect(resolveHoveredSeries(layout, 15, 11)).toBeNull();
|
||||
expect(resolveHoveredSeries(layout, 30, 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('layoutMarkerLabels', () => {
|
||||
it('keeps labels that fit on the first row', () => {
|
||||
expect(
|
||||
layoutMarkerLabels(
|
||||
[
|
||||
{ x: 0, width: 20 },
|
||||
{ x: 40, width: 20 },
|
||||
],
|
||||
4,
|
||||
),
|
||||
).toStrictEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('drops a label that would overlap to the next free row', () => {
|
||||
expect(
|
||||
layoutMarkerLabels(
|
||||
[
|
||||
{ x: 30, width: 20 },
|
||||
{ x: 0, width: 20 },
|
||||
{ x: 10, width: 20 },
|
||||
],
|
||||
4,
|
||||
),
|
||||
).toStrictEqual([0, 0, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { resolveDistributionXAxis } from '../layout';
|
||||
import {
|
||||
groupIntoOther,
|
||||
resolveClickedGroup,
|
||||
MAX_STACKED_GROUPS,
|
||||
OTHER_GROUP_LABEL,
|
||||
resolveMaxDrawnGroups,
|
||||
} from '../topGroups';
|
||||
import { DistributionXScale } from '../types';
|
||||
|
||||
describe('resolveMaxDrawnGroups', () => {
|
||||
// Four equal buckets plus underflow and overflow: six slots of one unit each.
|
||||
const xAxis = resolveDistributionXAxis(
|
||||
[1, 2, 3, 4, 5],
|
||||
DistributionXScale.Ordinal,
|
||||
);
|
||||
|
||||
it('caps stacked and percent at the fixed limit', () => {
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.Normal, xAxis, plotWidth: 60 }),
|
||||
).toBe(MAX_STACKED_GROUPS);
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.Percent, xAxis, plotWidth: 60 }),
|
||||
).toBe(MAX_STACKED_GROUPS);
|
||||
});
|
||||
|
||||
it('fits side by side so the narrowest bar stays 3 px wide', () => {
|
||||
// 120 px over six slots: 20 px a bucket, six 3 px bars.
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.None, xAxis, plotWidth: 120 }),
|
||||
).toBe(6);
|
||||
expect(
|
||||
resolveMaxDrawnGroups({ stack: StackMode.None, xAxis, plotWidth: 6 }),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupIntoOther', () => {
|
||||
const args = {
|
||||
labels: ['a', 'b', 'c', 'd'],
|
||||
counts: [
|
||||
[1, 1],
|
||||
[5, 5],
|
||||
[2, 0],
|
||||
[3, 3],
|
||||
],
|
||||
colors: ['#a', '#b', '#c', '#d'],
|
||||
visible: [true, true, true, true],
|
||||
otherColor: '#999',
|
||||
};
|
||||
|
||||
it('draws every visible group when they fit', () => {
|
||||
const drawn = groupIntoOther({ ...args, maxGroups: 4 });
|
||||
|
||||
expect(drawn.labels).toStrictEqual(['a', 'b', 'c', 'd']);
|
||||
expect(drawn.otherGroupCount).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the largest, in legend order, and sums the rest exactly', () => {
|
||||
const drawn = groupIntoOther({ ...args, maxGroups: 3 });
|
||||
|
||||
expect(drawn.labels).toStrictEqual(['b', 'd', OTHER_GROUP_LABEL]);
|
||||
expect(drawn.counts[2]).toStrictEqual([3, 1]);
|
||||
expect(drawn.colors[2]).toBe('#999');
|
||||
expect(drawn).toMatchObject({ otherGroupCount: 2, visibleGroupCount: 4 });
|
||||
});
|
||||
|
||||
it('re-ranks without hidden groups, which never reach Other', () => {
|
||||
const drawn = groupIntoOther({
|
||||
...args,
|
||||
visible: [true, false, true, true],
|
||||
maxGroups: 2,
|
||||
});
|
||||
|
||||
expect(drawn.labels).toStrictEqual(['d', OTHER_GROUP_LABEL]);
|
||||
expect(drawn.counts[1]).toStrictEqual([3, 1]);
|
||||
expect(drawn.visibleGroupCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveClickedGroup', () => {
|
||||
const series = ['a', 'b', 'c'].map((label) => ({
|
||||
label,
|
||||
counts: [label === 'a' ? 5 : 1],
|
||||
}));
|
||||
const drawn = groupIntoOther({
|
||||
labels: ['a', 'b', 'c'],
|
||||
counts: series.map(({ counts }) => counts),
|
||||
colors: ['#a', '#b', '#c'],
|
||||
visible: [true, true, true],
|
||||
maxGroups: 2,
|
||||
otherColor: '#999',
|
||||
});
|
||||
|
||||
it('maps a drawn bar back to its source group and colour', () => {
|
||||
expect(resolveClickedGroup(drawn, 0, series)).toStrictEqual({
|
||||
series: series[0],
|
||||
color: '#a',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses Other and empty space', () => {
|
||||
expect(resolveClickedGroup(drawn, 1, series)).toBeNull();
|
||||
expect(resolveClickedGroup(drawn, null, series)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { resolveDistributionBars, resolveDistributionXAxis } from '../layout';
|
||||
import { DistributionXScale } from '../types';
|
||||
import { buildDistributionConfig } from '../utils';
|
||||
|
||||
// Buckets: ≤ 10, (10, 20], (20, 40], > 40. Only (10, 20] is occupied.
|
||||
const xAxis = resolveDistributionXAxis([10, 20, 40], DistributionXScale.Linear);
|
||||
const counts = [
|
||||
[0, 4, 0, 0],
|
||||
[0, 6, 0, 0],
|
||||
];
|
||||
const visible = [true, true];
|
||||
|
||||
function setCursorHook(): (u: uPlot) => void {
|
||||
const config = buildDistributionConfig({
|
||||
id: 'panel-1',
|
||||
xAxis,
|
||||
layout: resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
visible,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
}),
|
||||
labels: ['cart', 'checkout'],
|
||||
colors: ['#ff0000', '#00ff00'],
|
||||
visible,
|
||||
stack: StackMode.Normal,
|
||||
isLogY: false,
|
||||
isDarkMode: true,
|
||||
markers: [],
|
||||
}).getConfig();
|
||||
const [hook] = config.hooks?.setCursor ?? [];
|
||||
return hook as (u: uPlot) => void;
|
||||
}
|
||||
|
||||
/** Pixels are values, so the cursor sits at `(left, top)` in scale space. */
|
||||
function plotAt(
|
||||
left: number,
|
||||
top: number,
|
||||
): { u: uPlot; setSeries: jest.Mock; redraw: jest.Mock } {
|
||||
const setSeries = jest.fn();
|
||||
const redraw = jest.fn();
|
||||
const u = {
|
||||
cursor: { left, top },
|
||||
posToVal: (position: number) => position,
|
||||
setSeries,
|
||||
redraw,
|
||||
} as unknown as uPlot;
|
||||
return { u, setSeries, redraw };
|
||||
}
|
||||
|
||||
describe('buildDistributionConfig setCursor', () => {
|
||||
// TooltipPlugin only shows a tooltip for a focused series.
|
||||
it('focuses the group under the cursor, 1-based as uPlot counts series', () => {
|
||||
const { u, setSeries, redraw } = plotAt(15, 8);
|
||||
|
||||
setCursorHook()(u);
|
||||
|
||||
expect(setSeries).toHaveBeenCalledWith(2, { focus: true });
|
||||
expect(redraw).toHaveBeenCalledWith(true, false);
|
||||
});
|
||||
|
||||
it('still focuses a group above the stack of an occupied bucket', () => {
|
||||
const { u, setSeries } = plotAt(15, 50);
|
||||
|
||||
setCursorHook()(u);
|
||||
|
||||
expect(setSeries).toHaveBeenCalledWith(1, { focus: true });
|
||||
});
|
||||
|
||||
it('focuses nothing over an empty bucket', () => {
|
||||
const hook = setCursorHook();
|
||||
const { u, setSeries } = plotAt(15, 8);
|
||||
hook(u);
|
||||
|
||||
u.cursor.left = 30;
|
||||
u.cursor.top = 1;
|
||||
hook(u);
|
||||
|
||||
expect(setSeries).toHaveBeenLastCalledWith(null, { focus: true });
|
||||
});
|
||||
});
|
||||
211
frontend/src/lib/visualization/charts/Distribution/layout.ts
Normal file
211
frontend/src/lib/visualization/charts/Distribution/layout.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
cropHeatmapYAxis,
|
||||
resolveHeatmapYAxis,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { HeatmapAxisScale } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
import {
|
||||
DistributionBarRect,
|
||||
DistributionXAxis,
|
||||
DistributionXScale,
|
||||
} from './types';
|
||||
|
||||
/** Below this span of positive bounds, auto keeps the axis linear. */
|
||||
const MIN_LOG_DECADES = 2;
|
||||
|
||||
/** Bottom of a log count axis: a bar has to start somewhere above zero. */
|
||||
export const LOG_Y_BASE = 1;
|
||||
|
||||
const SCALE_TO_HEATMAP: Record<
|
||||
Exclude<DistributionXScale, DistributionXScale.Ordinal>,
|
||||
HeatmapAxisScale
|
||||
> = {
|
||||
[DistributionXScale.Auto]: HeatmapAxisScale.Auto,
|
||||
[DistributionXScale.Linear]: HeatmapAxisScale.Linear,
|
||||
[DistributionXScale.Log]: HeatmapAxisScale.Log,
|
||||
[DistributionXScale.Symlog]: HeatmapAxisScale.Symlog,
|
||||
};
|
||||
|
||||
function spansDecades(bounds: number[], decades: number): boolean {
|
||||
const positive = bounds.filter((bound) => bound > 0);
|
||||
if (positive.length < 2) {
|
||||
return false;
|
||||
}
|
||||
const ratio = Math.max(...positive) / Math.min(...positive);
|
||||
return Math.log10(ratio) >= decades;
|
||||
}
|
||||
|
||||
function resolveAutoScale(bounds: number[]): HeatmapAxisScale {
|
||||
const hasNegative = bounds.some((bound) => bound < 0);
|
||||
if (!hasNegative && !spansDecades(bounds, MIN_LOG_DECADES)) {
|
||||
return HeatmapAxisScale.Linear;
|
||||
}
|
||||
return HeatmapAxisScale.Auto;
|
||||
}
|
||||
|
||||
/** Row `i` spans `[i, i + 1]`; within it, value interpolates across the row's bounds. */
|
||||
function toOrdinalAxis(axis: DistributionXAxis): DistributionXAxis {
|
||||
const { rows } = axis;
|
||||
const edges = rows.map((_, index) => index).concat(rows.length);
|
||||
return {
|
||||
rows,
|
||||
edges,
|
||||
splits: rows.slice(0, -1).map((_, index) => index + 1),
|
||||
overflowSplit: axis.overflowSplit === null ? null : rows.length,
|
||||
toBucketValue: (axisValue: number): number => {
|
||||
const index = Math.min(rows.length - 1, Math.max(0, Math.floor(axisValue)));
|
||||
const row = rows[index];
|
||||
return row.lower + (axisValue - index) * (row.upper - row.lower);
|
||||
},
|
||||
min: 0,
|
||||
max: rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** N bounds give N + 1 buckets: `≤ first`, the finite ones, and `> last`. */
|
||||
export function resolveDistributionXAxis(
|
||||
buckets: number[],
|
||||
scale: DistributionXScale,
|
||||
): DistributionXAxis {
|
||||
if (scale === DistributionXScale.Ordinal) {
|
||||
return toOrdinalAxis(resolveHeatmapYAxis(buckets, HeatmapAxisScale.Linear));
|
||||
}
|
||||
const heatmapScale =
|
||||
scale === DistributionXScale.Auto
|
||||
? resolveAutoScale(buckets)
|
||||
: SCALE_TO_HEATMAP[scale];
|
||||
return resolveHeatmapYAxis(buckets, heatmapScale);
|
||||
}
|
||||
|
||||
/** Sum of each bucket across the visible series. */
|
||||
export function sumVisibleCounts(
|
||||
counts: number[][],
|
||||
visible: boolean[],
|
||||
bucketCount: number,
|
||||
): number[] {
|
||||
const totals = new Array<number>(bucketCount).fill(0);
|
||||
counts.forEach((seriesCounts, seriesIndex) => {
|
||||
if (!visible[seriesIndex]) {
|
||||
return;
|
||||
}
|
||||
for (let bucket = 0; bucket < bucketCount; bucket += 1) {
|
||||
totals[bucket] += seriesCounts[bucket] ?? 0;
|
||||
}
|
||||
});
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** Drops empty buckets past the last occupied one, as the heatmap does. */
|
||||
export function cropDistributionXAxis(
|
||||
axis: DistributionXAxis,
|
||||
totals: number[],
|
||||
): DistributionXAxis {
|
||||
return cropHeatmapYAxis(
|
||||
axis,
|
||||
totals.map((total) => [total]),
|
||||
);
|
||||
}
|
||||
|
||||
export interface DistributionBarsLayout {
|
||||
/** One list per series, in series order; a hidden series has none. */
|
||||
rects: DistributionBarRect[][];
|
||||
/** Top of the tallest bar, for the count axis range. */
|
||||
yMax: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every bar, in scale space. `normal` stacks the visible series in each bucket,
|
||||
* `percent` stacks their shares of it, `none` splits the bucket width between them.
|
||||
*/
|
||||
export function resolveDistributionBars({
|
||||
xAxis,
|
||||
counts,
|
||||
visible,
|
||||
stack,
|
||||
isLogY,
|
||||
}: {
|
||||
xAxis: DistributionXAxis;
|
||||
/** `counts[series][bucket]`. */
|
||||
counts: number[][];
|
||||
visible: boolean[];
|
||||
stack: StackMode;
|
||||
isLogY: boolean;
|
||||
}): DistributionBarsLayout {
|
||||
const { rows, edges } = xAxis;
|
||||
const rects: DistributionBarRect[][] = counts.map(() => []);
|
||||
const visibleSeries = counts
|
||||
.map((_, seriesIndex) => seriesIndex)
|
||||
.filter((seriesIndex) => visible[seriesIndex]);
|
||||
const base = isLogY ? LOG_Y_BASE : 0;
|
||||
const totals = sumVisibleCounts(counts, visible, rows.length);
|
||||
let yMax = 0;
|
||||
|
||||
rows.forEach((row, bucketIndex) => {
|
||||
const x0 = edges[bucketIndex];
|
||||
const x1 = edges[bucketIndex + 1];
|
||||
const total = totals[bucketIndex];
|
||||
let cumulative = 0;
|
||||
|
||||
visibleSeries.forEach((seriesIndex, position) => {
|
||||
const count = counts[seriesIndex][bucketIndex] ?? 0;
|
||||
if (count <= 0) {
|
||||
return;
|
||||
}
|
||||
const bar = { bucketIndex, hatched: row.isOverflow };
|
||||
|
||||
if (stack === StackMode.None) {
|
||||
const width = (x1 - x0) / visibleSeries.length;
|
||||
rects[seriesIndex].push({
|
||||
...bar,
|
||||
x0: x0 + position * width,
|
||||
x1: x0 + (position + 1) * width,
|
||||
y0: base,
|
||||
y1: count,
|
||||
});
|
||||
yMax = Math.max(yMax, count);
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = stack === StackMode.Percent ? 100 / total : 1;
|
||||
const y0 = cumulative * scale;
|
||||
cumulative += count;
|
||||
const y1 = cumulative * scale;
|
||||
rects[seriesIndex].push({
|
||||
...bar,
|
||||
x0,
|
||||
x1,
|
||||
y0: Math.max(y0, base),
|
||||
y1,
|
||||
});
|
||||
yMax = Math.max(yMax, y1);
|
||||
});
|
||||
});
|
||||
|
||||
return { rects, yMax };
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexes of the ticks whose centred labels fit without touching, walking from the
|
||||
* right so the `∞` edge survives. Positions and widths share one pixel space.
|
||||
*/
|
||||
export function thinLabelledSplits({
|
||||
positions,
|
||||
widths,
|
||||
gap,
|
||||
}: {
|
||||
positions: number[];
|
||||
widths: number[];
|
||||
gap: number;
|
||||
}): number[] {
|
||||
const kept: number[] = [];
|
||||
let leftEdge = Number.POSITIVE_INFINITY;
|
||||
for (let index = positions.length - 1; index >= 0; index -= 1) {
|
||||
const half = widths[index] / 2;
|
||||
if (positions[index] + half + gap <= leftEdge) {
|
||||
kept.push(index);
|
||||
leftEdge = positions[index] - half;
|
||||
}
|
||||
}
|
||||
return kept.reverse();
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { DistributionBarsLayout } from './layout';
|
||||
import { DistributionXAxis } from './types';
|
||||
|
||||
export interface DistributionPercentile {
|
||||
/** 0–100, as configured. */
|
||||
percentile: number;
|
||||
/** The estimate; in overflow, the last finite bound it is known to exceed. */
|
||||
value: number;
|
||||
/** Where the marker is drawn, in x-scale space. */
|
||||
axisValue: number;
|
||||
isOverflow: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Percentiles of the summed counts, by `histogram_quantile`'s method: find the bucket
|
||||
* holding the target rank and interpolate linearly inside it, with the lowest bucket
|
||||
* starting at zero. So the values match a time-series panel's percentiles.
|
||||
*/
|
||||
export function computeDistributionPercentiles({
|
||||
xAxis,
|
||||
totals,
|
||||
percentiles,
|
||||
}: {
|
||||
xAxis: DistributionXAxis;
|
||||
/** Summed visible counts per bucket. */
|
||||
totals: number[];
|
||||
percentiles: number[];
|
||||
}): DistributionPercentile[] {
|
||||
const { rows, edges } = xAxis;
|
||||
const total = totals.reduce((sum, count) => sum + count, 0);
|
||||
if (total <= 0 || rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return percentiles.flatMap<DistributionPercentile>((percentile) => {
|
||||
const rank = (percentile / 100) * total;
|
||||
let below = 0;
|
||||
for (let index = 0; index < rows.length; index += 1) {
|
||||
const count = totals[index] ?? 0;
|
||||
if (count > 0 && below + count >= rank) {
|
||||
const row = rows[index];
|
||||
if (row.isOverflow) {
|
||||
return [
|
||||
{
|
||||
percentile,
|
||||
value: row.lower,
|
||||
axisValue: edges[index],
|
||||
isOverflow: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
const fraction = (rank - below) / count;
|
||||
const lower = row.isUnderflow ? Math.min(0, row.upper) : row.lower;
|
||||
return [
|
||||
{
|
||||
percentile,
|
||||
value: lower + fraction * (row.upper - lower),
|
||||
axisValue: edges[index] + fraction * (edges[index + 1] - edges[index]),
|
||||
isOverflow: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
below += count;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function formatPercentileName(percentile: number): string {
|
||||
return `p${Number(percentile.toFixed(3))}`;
|
||||
}
|
||||
|
||||
/** `~p99` for an estimate; `p99 > 10 s` where only the overflow bound is known. */
|
||||
export function formatPercentileMarker(
|
||||
marker: DistributionPercentile,
|
||||
formatBound: (value: number) => string,
|
||||
): string {
|
||||
const name = formatPercentileName(marker.percentile);
|
||||
return marker.isOverflow
|
||||
? `${name} > ${formatBound(marker.value)}`
|
||||
: `~${name}`;
|
||||
}
|
||||
|
||||
/** The series whose bar is under `(x, y)` in scale space, or `null`. */
|
||||
export function resolveHoveredSeries(
|
||||
layout: DistributionBarsLayout,
|
||||
x: number,
|
||||
y: number,
|
||||
): number | null {
|
||||
for (let series = 0; series < layout.rects.length; series += 1) {
|
||||
const hit = layout.rects[series].some(
|
||||
(rect) => x >= rect.x0 && x < rect.x1 && y >= rect.y0 && y <= rect.y1,
|
||||
);
|
||||
if (hit) {
|
||||
return series;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The series the tooltip follows at `(x, y)`: the one under the cursor, else the
|
||||
* first with a bar in that bucket, so the tooltip shows anywhere over an occupied
|
||||
* bucket. `null` over an empty one.
|
||||
*/
|
||||
export function resolveFocusedSeries(
|
||||
layout: DistributionBarsLayout,
|
||||
hovered: number | null,
|
||||
bucket: number | null,
|
||||
): number | null {
|
||||
if (hovered !== null) {
|
||||
return hovered;
|
||||
}
|
||||
if (bucket === null) {
|
||||
return null;
|
||||
}
|
||||
const series = layout.rects.findIndex((rects) =>
|
||||
rects.some((rect) => rect.bucketIndex === bucket),
|
||||
);
|
||||
return series === -1 ? null : series;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacks marker labels into rows so none overlap: each goes in the first row whose
|
||||
* last label ends before it starts. Input and output are in the same pixel space.
|
||||
*/
|
||||
export function layoutMarkerLabels(
|
||||
labels: Array<{ x: number; width: number }>,
|
||||
gap: number,
|
||||
): number[] {
|
||||
const rowEnds: number[] = [];
|
||||
const order = labels
|
||||
.map((label, index) => ({ ...label, index }))
|
||||
.sort((a, b) => a.x - b.x);
|
||||
const rows = new Array<number>(labels.length).fill(0);
|
||||
|
||||
order.forEach(({ x, width, index }) => {
|
||||
let row = rowEnds.findIndex((end) => x >= end + gap);
|
||||
if (row === -1) {
|
||||
row = rowEnds.length;
|
||||
rowEnds.push(0);
|
||||
}
|
||||
rowEnds[row] = x + width;
|
||||
rows[index] = row;
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
131
frontend/src/lib/visualization/charts/Distribution/topGroups.ts
Normal file
131
frontend/src/lib/visualization/charts/Distribution/topGroups.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { DistributionSeries, DistributionXAxis } from './types';
|
||||
|
||||
/** Colours stop being told apart past this many stacked groups. */
|
||||
export const MAX_STACKED_GROUPS = 10;
|
||||
/** Narrowest a side-by-side bar may get, in CSS pixels. */
|
||||
const MIN_SIDE_BY_SIDE_BAR_PX = 3;
|
||||
export const OTHER_GROUP_LABEL = 'Other';
|
||||
|
||||
/**
|
||||
* How many series fit: a fixed cap when stacked, and side by side as many as keep
|
||||
* every bar in the narrowest bucket at least `MIN_SIDE_BY_SIDE_BAR_PX` wide.
|
||||
*/
|
||||
export function resolveMaxDrawnGroups({
|
||||
stack,
|
||||
xAxis,
|
||||
plotWidth,
|
||||
}: {
|
||||
stack: StackMode;
|
||||
xAxis: DistributionXAxis;
|
||||
/** Approximate width of the plotting area, in CSS pixels. */
|
||||
plotWidth: number;
|
||||
}): number {
|
||||
if (stack !== StackMode.None) {
|
||||
return MAX_STACKED_GROUPS;
|
||||
}
|
||||
const span = xAxis.max - xAxis.min;
|
||||
let narrowest = Number.POSITIVE_INFINITY;
|
||||
for (let index = 0; index + 1 < xAxis.edges.length; index += 1) {
|
||||
if (xAxis.edges[index] < xAxis.max) {
|
||||
narrowest = Math.min(narrowest, xAxis.edges[index + 1] - xAxis.edges[index]);
|
||||
}
|
||||
}
|
||||
if (!(span > 0) || !Number.isFinite(narrowest)) {
|
||||
return MAX_STACKED_GROUPS;
|
||||
}
|
||||
const narrowestPx = (plotWidth * narrowest) / span;
|
||||
return Math.min(
|
||||
MAX_STACKED_GROUPS,
|
||||
Math.max(1, Math.floor(narrowestPx / MIN_SIDE_BY_SIDE_BAR_PX)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface DrawnGroups {
|
||||
labels: string[];
|
||||
counts: number[][];
|
||||
colors: string[];
|
||||
/** Visible groups summed into the Other series; 0 when none were. */
|
||||
otherGroupCount: number;
|
||||
/** Visible groups in all, drawn or folded. */
|
||||
visibleGroupCount: number;
|
||||
}
|
||||
|
||||
function sum(counts: number[]): number {
|
||||
return counts.reduce((total, count) => total + count, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The visible groups to draw: all of them when they fit, else the largest by total
|
||||
* and one Other series summing the rest. Exact, because bucket counts add. Drawn
|
||||
* groups keep legend order.
|
||||
*/
|
||||
export function groupIntoOther({
|
||||
labels,
|
||||
counts,
|
||||
colors,
|
||||
visible,
|
||||
maxGroups,
|
||||
otherColor,
|
||||
}: {
|
||||
labels: string[];
|
||||
counts: number[][];
|
||||
colors: string[];
|
||||
visible: boolean[];
|
||||
maxGroups: number;
|
||||
otherColor: string;
|
||||
}): DrawnGroups {
|
||||
const visibleIndexes = labels
|
||||
.map((_, index) => index)
|
||||
.filter((index) => visible[index]);
|
||||
|
||||
if (visibleIndexes.length <= maxGroups) {
|
||||
return {
|
||||
labels: visibleIndexes.map((index) => labels[index]),
|
||||
counts: visibleIndexes.map((index) => counts[index]),
|
||||
colors: visibleIndexes.map((index) => colors[index]),
|
||||
otherGroupCount: 0,
|
||||
visibleGroupCount: visibleIndexes.length,
|
||||
};
|
||||
}
|
||||
|
||||
const keep = new Set(
|
||||
[...visibleIndexes]
|
||||
.sort((a, b) => sum(counts[b]) - sum(counts[a]) || a - b)
|
||||
.slice(0, Math.max(0, maxGroups - 1)),
|
||||
);
|
||||
const kept = visibleIndexes.filter((index) => keep.has(index));
|
||||
const folded = visibleIndexes.filter((index) => !keep.has(index));
|
||||
const bucketCount = Math.max(...folded.map((index) => counts[index].length));
|
||||
const other = Array.from({ length: bucketCount }, (_, bucket) =>
|
||||
folded.reduce((total, index) => total + (counts[index][bucket] ?? 0), 0),
|
||||
);
|
||||
|
||||
return {
|
||||
labels: [...kept.map((index) => labels[index]), OTHER_GROUP_LABEL],
|
||||
counts: [...kept.map((index) => counts[index]), other],
|
||||
colors: [...kept.map((index) => colors[index]), otherColor],
|
||||
otherGroupCount: folded.length,
|
||||
visibleGroupCount: visibleIndexes.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawn group at `drawnIndex` as a source series and its colour; `null` for Other,
|
||||
* which sums several groups no single filter can name.
|
||||
*/
|
||||
export function resolveClickedGroup(
|
||||
drawn: DrawnGroups,
|
||||
drawnIndex: number | null,
|
||||
series: DistributionSeries[],
|
||||
): { series: DistributionSeries; color: string } | null {
|
||||
const isOther =
|
||||
drawn.otherGroupCount > 0 && drawnIndex === drawn.labels.length - 1;
|
||||
if (drawnIndex === null || isOther) {
|
||||
return null;
|
||||
}
|
||||
const label = drawn.labels[drawnIndex];
|
||||
const clicked = series.find((entry) => entry.label === label);
|
||||
return clicked ? { series: clicked, color: drawn.colors[drawnIndex] } : null;
|
||||
}
|
||||
42
frontend/src/lib/visualization/charts/Distribution/types.ts
Normal file
42
frontend/src/lib/visualization/charts/Distribution/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { VariableWidthBar } from 'lib/uPlotV2/paths/variableWidthBars';
|
||||
import type { HeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
export interface DistributionSeries {
|
||||
label: string;
|
||||
/** Group-by key → value, for drilling into this group. Empty when ungrouped. */
|
||||
groupLabels?: Record<string, string>;
|
||||
/** One count per bucket bound plus the trailing overflow, summed over the range. */
|
||||
counts: number[];
|
||||
}
|
||||
|
||||
/** A click on a group's bar; never on Other, which no filter can name. */
|
||||
export interface DistributionBarClick {
|
||||
series: DistributionSeries;
|
||||
color: string;
|
||||
/** Viewport coordinates of the click. */
|
||||
coordinates: { x: number; y: number };
|
||||
}
|
||||
|
||||
export enum DistributionXScale {
|
||||
/** Linear for bounds spanning under two decades or linear bucketing, else the
|
||||
* heatmap's auto: log, floor-log for a zero bound, symlog for negatives. */
|
||||
Auto = 'auto',
|
||||
Linear = 'linear',
|
||||
Log = 'log',
|
||||
Symlog = 'symlog',
|
||||
/** Every bucket equally wide; ticks still name the bounds. */
|
||||
Ordinal = 'ordinal',
|
||||
}
|
||||
|
||||
export enum DistributionYScale {
|
||||
Linear = 'linear',
|
||||
Log = 'log',
|
||||
}
|
||||
|
||||
/** The bucket axis in x-scale space. The heatmap's row axis, laid sideways. */
|
||||
export type DistributionXAxis = HeatmapYAxis;
|
||||
|
||||
/** `x` in bucket-axis units, `y` in counts (or percent); the overflow bucket is hatched. */
|
||||
export interface DistributionBarRect extends VariableWidthBar {
|
||||
bucketIndex: number;
|
||||
}
|
||||
390
frontend/src/lib/visualization/charts/Distribution/utils.ts
Normal file
390
frontend/src/lib/visualization/charts/Distribution/utils.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import {
|
||||
DistributionType,
|
||||
DrawStyle,
|
||||
StackMode,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { createVariableWidthBarsPathBuilder } from 'lib/uPlotV2/paths/variableWidthBars';
|
||||
import { resolveRowIndex } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { createHatchPattern } from 'lib/uPlotV2/plugins/HeatmapPlugin/paint';
|
||||
import { toAlphaHex } from 'lib/uPlotV2/utils/fillOpacity';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
DistributionBarsLayout,
|
||||
LOG_Y_BASE,
|
||||
thinLabelledSplits,
|
||||
} from './layout';
|
||||
import {
|
||||
DistributionPercentile,
|
||||
formatPercentileMarker,
|
||||
layoutMarkerLabels,
|
||||
resolveFocusedSeries,
|
||||
resolveHoveredSeries,
|
||||
} from './percentiles';
|
||||
import { DistributionXAxis } from './types';
|
||||
|
||||
/** Minimum space between two x tick labels, in CSS pixels. */
|
||||
const X_LABEL_GAP_PX = 12;
|
||||
const BAR_GAP_PX = 1;
|
||||
const BAR_OPACITY = 0.85;
|
||||
const OVERFLOW_BAR_OPACITY = 0.4;
|
||||
/** Every group but the hovered one fades to this. */
|
||||
const DIMMED_BAR_OPACITY = 0.25;
|
||||
const DIMMED_OVERFLOW_BAR_OPACITY = 0.12;
|
||||
const OVERFLOW_DASH: [number, number] = [4, 3];
|
||||
const OVERFLOW_AXIS_LABEL = '∞';
|
||||
/** Headroom above the tallest bar: a fraction on linear, a factor on log. Marker
|
||||
* labels sit at the top of the plot, so they take more. */
|
||||
const LINEAR_HEADROOM = 1.1;
|
||||
const LINEAR_HEADROOM_WITH_MARKERS = 1.3;
|
||||
const LOG_HEADROOM = 2;
|
||||
const LOG_HEADROOM_WITH_MARKERS = 6;
|
||||
const MARKER_DASH: [number, number] = [3, 3];
|
||||
const MARKER_FONT_SIZE_PX = 10;
|
||||
const MARKER_LABEL_ROW_PX = 13;
|
||||
const MARKER_LABEL_GAP_PX = 6;
|
||||
const MARKER_LABEL_OFFSET_PX = 3;
|
||||
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
function withOpacity(color: string, opacity: number): string {
|
||||
return HEX_COLOR.test(color) ? `${color}${toAlphaHex(opacity)}` : color;
|
||||
}
|
||||
|
||||
export function createBoundFormatter(
|
||||
unit?: string,
|
||||
decimalPrecision?: PrecisionOption,
|
||||
): (value: number) => string {
|
||||
return (value: number): string =>
|
||||
getToolTipValue(String(value), unit, decimalPrecision);
|
||||
}
|
||||
|
||||
/** Neutral, so the summed remainder never reads as one more group. */
|
||||
export function resolveOtherColor(isDarkMode: boolean): string {
|
||||
return isDarkMode ? Color.BG_VANILLA_400 : Color.BG_SLATE_50;
|
||||
}
|
||||
|
||||
export function resolveSeriesColor(
|
||||
label: string,
|
||||
isDarkMode: boolean,
|
||||
customColors?: Record<string, string>,
|
||||
): string {
|
||||
return (
|
||||
customColors?.[label] ??
|
||||
generateColor(
|
||||
label,
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `[bucket centres, ...counts per series]`. The series draw from the layout, so the
|
||||
* data only feeds uPlot's cursor and tooltip.
|
||||
*/
|
||||
export function prepareDistributionChartData(
|
||||
xAxis: DistributionXAxis,
|
||||
counts: number[][],
|
||||
): uPlot.AlignedData {
|
||||
const centres = xAxis.rows.map(
|
||||
(_, index) => (xAxis.edges[index] + xAxis.edges[index + 1]) / 2,
|
||||
);
|
||||
return [
|
||||
centres,
|
||||
...counts.map((seriesCounts) =>
|
||||
centres.map((_, index) => seriesCounts[index] ?? 0),
|
||||
),
|
||||
] as unknown as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
function resolveYRange({
|
||||
yMax,
|
||||
isLogY,
|
||||
stack,
|
||||
hasMarkers,
|
||||
}: {
|
||||
yMax: number;
|
||||
isLogY: boolean;
|
||||
stack: StackMode;
|
||||
hasMarkers: boolean;
|
||||
}): [number, number] {
|
||||
if (stack === StackMode.Percent) {
|
||||
return [0, hasMarkers ? 100 * LINEAR_HEADROOM_WITH_MARKERS : 100];
|
||||
}
|
||||
if (isLogY) {
|
||||
const headroom = hasMarkers ? LOG_HEADROOM_WITH_MARKERS : LOG_HEADROOM;
|
||||
return [LOG_Y_BASE, Math.max(yMax, LOG_Y_BASE) * headroom];
|
||||
}
|
||||
const headroom = hasMarkers ? LINEAR_HEADROOM_WITH_MARKERS : LINEAR_HEADROOM;
|
||||
return [0, yMax > 0 ? yMax * headroom : 1];
|
||||
}
|
||||
|
||||
/** Dashed lines across the plot, labels stacked in rows at the top so none overlap. */
|
||||
function drawPercentileMarkers(
|
||||
u: uPlot,
|
||||
markers: DistributionPercentile[],
|
||||
formatBound: (value: number) => string,
|
||||
isDarkMode: boolean,
|
||||
): void {
|
||||
if (markers.length === 0) {
|
||||
return;
|
||||
}
|
||||
const { ctx, bbox } = u;
|
||||
const { pxRatio } = uPlot;
|
||||
const color = isDarkMode ? themeColors.white : themeColors.black;
|
||||
|
||||
ctx.save();
|
||||
ctx.font = `500 ${MARKER_FONT_SIZE_PX * pxRatio}px Inter, sans-serif`;
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
const labels = markers.map((marker) => {
|
||||
const text = formatPercentileMarker(marker, formatBound);
|
||||
const x = u.valToPos(marker.axisValue, 'x', true);
|
||||
return { text, x, width: ctx.measureText(text).width };
|
||||
});
|
||||
const rows = layoutMarkerLabels(
|
||||
labels.map(({ x, width }) => ({
|
||||
x: x + MARKER_LABEL_OFFSET_PX * pxRatio,
|
||||
width,
|
||||
})),
|
||||
MARKER_LABEL_GAP_PX * pxRatio,
|
||||
);
|
||||
|
||||
labels.forEach(({ text, x }, index) => {
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = pxRatio;
|
||||
ctx.setLineDash(MARKER_DASH.map((dash) => dash * pxRatio));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, bbox.top);
|
||||
ctx.lineTo(x, bbox.top + bbox.height);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(
|
||||
text,
|
||||
x + MARKER_LABEL_OFFSET_PX * pxRatio,
|
||||
bbox.top + rows[index] * MARKER_LABEL_ROW_PX * pxRatio,
|
||||
);
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawOverflowBoundary(
|
||||
u: uPlot,
|
||||
xAxis: DistributionXAxis,
|
||||
isDarkMode: boolean,
|
||||
): void {
|
||||
if (xAxis.overflowSplit === null || xAxis.edges.length < 3) {
|
||||
return;
|
||||
}
|
||||
const x = u.valToPos(xAxis.edges[xAxis.edges.length - 2], 'x', true);
|
||||
const { ctx, bbox } = u;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = isDarkMode ? themeColors.white : themeColors.black;
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.lineWidth = uPlot.pxRatio;
|
||||
ctx.setLineDash(OVERFLOW_DASH.map((dash) => dash * uPlot.pxRatio));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, bbox.top);
|
||||
ctx.lineTo(x, bbox.top + bbox.height);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
export interface BuildDistributionConfigArgs {
|
||||
id: string;
|
||||
xAxis: DistributionXAxis;
|
||||
layout: DistributionBarsLayout;
|
||||
labels: string[];
|
||||
colors: string[];
|
||||
visible: boolean[];
|
||||
stack: StackMode;
|
||||
isLogY: boolean;
|
||||
isDarkMode: boolean;
|
||||
/** Drawn across the plot, labelled at the top. */
|
||||
markers: DistributionPercentile[];
|
||||
/** Unit of the bucket bounds; counts take none. */
|
||||
xAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}
|
||||
|
||||
export function buildDistributionConfig({
|
||||
id,
|
||||
xAxis,
|
||||
layout,
|
||||
labels,
|
||||
colors,
|
||||
visible,
|
||||
stack,
|
||||
isLogY,
|
||||
isDarkMode,
|
||||
markers,
|
||||
xAxisUnit,
|
||||
decimalPrecision,
|
||||
}: BuildDistributionConfigArgs): UPlotConfigBuilder {
|
||||
const builder = new UPlotConfigBuilder({ id });
|
||||
// uPlot's focus picks the series nearest in value, meaningless for stacked bars; the
|
||||
// setCursor hook focuses the one under the cursor itself. Alpha 1 leaves the
|
||||
// dimming to the bar fills.
|
||||
builder.setFocus({ alpha: 1 });
|
||||
builder.setCursor({ focus: { prox: -1 }, points: { show: false } });
|
||||
|
||||
const formatBound = createBoundFormatter(xAxisUnit, decimalPrecision);
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: false,
|
||||
auto: false,
|
||||
range: (): [number, number] => [xAxis.min, xAxis.max],
|
||||
});
|
||||
const yRange = resolveYRange({
|
||||
yMax: layout.yMax,
|
||||
isLogY,
|
||||
stack,
|
||||
hasMarkers: markers.length > 0,
|
||||
});
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
auto: false,
|
||||
distribution: isLogY ? DistributionType.Logarithmic : DistributionType.Linear,
|
||||
range: (): [number, number] => yRange,
|
||||
});
|
||||
|
||||
const axisSplits =
|
||||
xAxis.overflowSplit === null
|
||||
? xAxis.splits
|
||||
: [...xAxis.splits, xAxis.overflowSplit];
|
||||
// From the bounds themselves: inverting a log transform rounds 128 to 127.99….
|
||||
const splitLabels = new Map<number, string>();
|
||||
xAxis.splits.forEach((split, index) => {
|
||||
splitLabels.set(split, formatBound(xAxis.rows[index].upper));
|
||||
});
|
||||
if (xAxis.overflowSplit !== null) {
|
||||
splitLabels.set(xAxis.overflowSplit, OVERFLOW_AXIS_LABEL);
|
||||
}
|
||||
|
||||
const labelOf = (split: number): string =>
|
||||
splitLabels.get(split) ?? formatBound(xAxis.toBucketValue(split));
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
// Thinned by each label's own width: bound labels run from `5 s` to
|
||||
// `16.67 mins`, so no fixed spacing keeps them apart.
|
||||
splits: (self, axisIdx): number[] => {
|
||||
const visible = axisSplits.filter((split) => split <= xAxis.max);
|
||||
const font = self.axes[axisIdx]?.font?.[0];
|
||||
if (font) {
|
||||
self.ctx.font = font;
|
||||
}
|
||||
const kept = thinLabelledSplits({
|
||||
positions: visible.map((split) => self.valToPos(split, 'x')),
|
||||
widths: visible.map(
|
||||
(split) => self.ctx.measureText(labelOf(split)).width / uPlot.pxRatio,
|
||||
),
|
||||
gap: X_LABEL_GAP_PX,
|
||||
});
|
||||
return kept.map((index) => visible[index]);
|
||||
},
|
||||
values: (_, splits): string[] => splits.map(labelOf),
|
||||
});
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit: stack === StackMode.Percent ? 'percent' : 'short',
|
||||
// The marker headroom runs a share axis past 100%, which labels nothing real.
|
||||
...(stack === StackMode.Percent && {
|
||||
filter: (_self: uPlot, splits: number[]): Array<number | null> =>
|
||||
splits.map((split) => (split <= 100 ? split : null)),
|
||||
}),
|
||||
});
|
||||
|
||||
let hatchPattern: CanvasPattern | null | undefined;
|
||||
const getHatchPattern = (
|
||||
ctx: CanvasRenderingContext2D,
|
||||
): CanvasPattern | null => {
|
||||
hatchPattern ??= createHatchPattern(ctx, isDarkMode);
|
||||
return hatchPattern;
|
||||
};
|
||||
|
||||
// Read by every series' fill on each draw; a change redraws the paths.
|
||||
const hover: { series: number | null; focused: number | null } = {
|
||||
series: null,
|
||||
focused: null,
|
||||
};
|
||||
const isDimmed = (seriesIndex: number): boolean =>
|
||||
hover.series !== null && hover.series !== seriesIndex;
|
||||
|
||||
labels.forEach((label, seriesIndex) => {
|
||||
const color = colors[seriesIndex];
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
label,
|
||||
drawStyle: DrawStyle.Bar,
|
||||
pathBuilder: createVariableWidthBarsPathBuilder({
|
||||
getBars: () => layout.rects[seriesIndex] ?? [],
|
||||
fill: () =>
|
||||
withOpacity(
|
||||
color,
|
||||
isDimmed(seriesIndex) ? DIMMED_BAR_OPACITY : BAR_OPACITY,
|
||||
),
|
||||
hatchedFill: () =>
|
||||
withOpacity(
|
||||
color,
|
||||
isDimmed(seriesIndex)
|
||||
? DIMMED_OVERFLOW_BAR_OPACITY
|
||||
: OVERFLOW_BAR_OPACITY,
|
||||
),
|
||||
getHatchPattern,
|
||||
gapPx: BAR_GAP_PX,
|
||||
}),
|
||||
lineColor: color,
|
||||
lineWidth: 0,
|
||||
colorMapping: {},
|
||||
isDarkMode,
|
||||
show: visible[seriesIndex],
|
||||
showPoints: false,
|
||||
spanGaps: false,
|
||||
});
|
||||
});
|
||||
|
||||
builder.addHook('draw', (u: uPlot): void => {
|
||||
drawOverflowBoundary(u, xAxis, isDarkMode);
|
||||
drawPercentileMarkers(u, markers, formatBound, isDarkMode);
|
||||
});
|
||||
|
||||
// Registered as a hook, not a plugin, so it runs before TooltipPlugin's: the tooltip
|
||||
// only shows for a focused series, and must find it focused when it positions.
|
||||
builder.addHook('setCursor', (u: uPlot): void => {
|
||||
const { left = -1, top = -1 } = u.cursor;
|
||||
const isOnPlot = left >= 0 && top >= 0;
|
||||
const x = isOnPlot ? u.posToVal(left, 'x') : Number.NaN;
|
||||
const hovered = isOnPlot
|
||||
? resolveHoveredSeries(layout, x, u.posToVal(top, 'y'))
|
||||
: null;
|
||||
const focused = isOnPlot
|
||||
? resolveFocusedSeries(layout, hovered, resolveRowIndex(xAxis.edges, x))
|
||||
: null;
|
||||
|
||||
if (focused !== hover.focused) {
|
||||
hover.focused = focused;
|
||||
u.setSeries(focused === null ? null : focused + 1, { focus: true });
|
||||
}
|
||||
if (hovered !== hover.series) {
|
||||
hover.series = hovered;
|
||||
u.redraw(true, false);
|
||||
}
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -27,6 +27,12 @@ import {
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import type {
|
||||
DistributionBarClick,
|
||||
DistributionSeries,
|
||||
DistributionXScale,
|
||||
DistributionYScale,
|
||||
} from 'lib/visualization/charts/Distribution/types';
|
||||
|
||||
interface BaseChartProps {
|
||||
width: number;
|
||||
@@ -168,6 +174,46 @@ export interface HeatmapChartProps {
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-range bucket counts, one series per group, drawn as bars whose widths follow
|
||||
* the bucket bounds. Builds its own config, since `buckets` fully determines the x
|
||||
* axis. `buckets` and `series` must be referentially stable: a new identity rebuilds
|
||||
* the plot.
|
||||
*/
|
||||
export interface DistributionChartProps {
|
||||
/** Widget id; the group selection persists under it. */
|
||||
id: string;
|
||||
/** Ascending. N boundaries describe N+1 buckets, the last one overflow. */
|
||||
buckets: number[];
|
||||
series: DistributionSeries[];
|
||||
width: number;
|
||||
height: number;
|
||||
isDarkMode: boolean;
|
||||
/** Default auto. */
|
||||
xScale?: DistributionXScale;
|
||||
/** Default linear. Ignored under `percent`, whose axis is a share. */
|
||||
yScale?: DistributionYScale;
|
||||
/** Default normal. `none` draws the groups side by side. */
|
||||
stack?: StackMode;
|
||||
/** Unit of the bucket bounds; counts are always plain numbers. */
|
||||
xAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
/** Hex colours keyed by group label, overriding the automatic palette. */
|
||||
customColors?: Record<string, string>;
|
||||
/** 0–100. Marked across the plot, over the visible groups combined. */
|
||||
percentiles?: number[];
|
||||
/** Fires for a click on a group's bar; not on Other or empty space. */
|
||||
onBarClick?: (click: DistributionBarClick) => void;
|
||||
showLegend?: boolean;
|
||||
legendPosition?: LegendPosition;
|
||||
showTooltip?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
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.
|
||||
|
||||
@@ -45,11 +45,12 @@ function PanelTypeSwitcher({
|
||||
(kind: PanelKind): string | undefined =>
|
||||
getPanelTypeDisabledReason({
|
||||
kind,
|
||||
fromKind: panelKind,
|
||||
queryType,
|
||||
signal,
|
||||
label: getPanelDefinition(kind).displayName,
|
||||
}),
|
||||
[queryType, signal],
|
||||
[panelKind, queryType, signal],
|
||||
);
|
||||
|
||||
const canRevert = !!originalPanelKind && originalPanelKind !== panelKind;
|
||||
|
||||
@@ -25,6 +25,39 @@ describe('getPanelTypeDisabledReason', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('blocks Histogram and Distribution switching into each other, both ways', () => {
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/DistributionPanel',
|
||||
fromKind: 'signoz/HistogramPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
signal: metrics,
|
||||
label: 'Distribution',
|
||||
}),
|
||||
).toMatch(/can't switch between them/);
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/HistogramPanel',
|
||||
fromKind: 'signoz/DistributionPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
signal: metrics,
|
||||
label: 'Histogram',
|
||||
}),
|
||||
).toMatch(/can't switch between them/);
|
||||
});
|
||||
|
||||
it('lets Distribution switch to other kinds', () => {
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/HeatmapPanel',
|
||||
fromKind: 'signoz/DistributionPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
signal: metrics,
|
||||
label: 'Heatmap',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('explains an unsupported query type', () => {
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
|
||||
interface UsePanelTypeSelectItemsArgs {
|
||||
/** The panel's current kind, which some kinds can't be switched to from. */
|
||||
fromKind?: PanelKind;
|
||||
/** Active query type — a kind that can't be authored in it is disabled. */
|
||||
queryType: EQueryType;
|
||||
/** Current datasource — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
@@ -21,6 +23,7 @@ interface UsePanelTypeSelectItemsArgs {
|
||||
* the capabilities guard, the same rule the editor's `PanelTypeSwitcher` applies.
|
||||
*/
|
||||
export function usePanelTypeSelectItems({
|
||||
fromKind,
|
||||
queryType,
|
||||
signal,
|
||||
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
|
||||
@@ -30,6 +33,7 @@ export function usePanelTypeSelectItems({
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind,
|
||||
fromKind,
|
||||
queryType,
|
||||
signal,
|
||||
label: displayName,
|
||||
@@ -42,6 +46,6 @@ export function usePanelTypeSelectItems({
|
||||
tooltip: disabledReason,
|
||||
};
|
||||
}),
|
||||
[queryType, signal],
|
||||
[fromKind, queryType, signal],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,16 @@ import {
|
||||
} from '../../../Panels/capabilities';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
|
||||
/** Kinds whose queries differ in shape, so neither converts into the other. */
|
||||
const UNSWITCHABLE_KIND_PAIRS: ReadonlyArray<readonly [PanelKind, PanelKind]> =
|
||||
[['signoz/HistogramPanel', 'signoz/DistributionPanel']];
|
||||
|
||||
function isUnswitchablePair(from: PanelKind, to: PanelKind): boolean {
|
||||
return UNSWITCHABLE_KIND_PAIRS.some(
|
||||
([a, b]) => (a === from && b === to) || (a === to && b === from),
|
||||
);
|
||||
}
|
||||
|
||||
const QUERY_TYPE_LABEL: Record<EQueryType, string> = {
|
||||
[EQueryType.QUERY_BUILDER]: 'Query Builder',
|
||||
[EQueryType.CLICKHOUSE]: 'ClickHouse',
|
||||
@@ -29,15 +39,21 @@ const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
|
||||
*/
|
||||
export function getPanelTypeDisabledReason({
|
||||
kind,
|
||||
fromKind,
|
||||
queryType,
|
||||
signal,
|
||||
label,
|
||||
}: {
|
||||
kind: PanelKind;
|
||||
/** The panel's current kind; some pairs can't switch into each other. */
|
||||
fromKind?: PanelKind;
|
||||
queryType: EQueryType;
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
label: string;
|
||||
}): string | undefined {
|
||||
if (fromKind && isUnswitchablePair(fromKind, kind)) {
|
||||
return "Histogram and Distribution query differently, so a panel can't switch between them";
|
||||
}
|
||||
// A kind that renders without a query pairs with anything — it declares no
|
||||
// query types or signals, and the checks below would read that as "supports
|
||||
// nothing" and disable it everywhere.
|
||||
|
||||
@@ -26,6 +26,13 @@ export const SWITCH_SKETCHES = {
|
||||
))}
|
||||
</SketchSvg>
|
||||
),
|
||||
percentiles: (
|
||||
<SketchSvg>
|
||||
<path d="M3 19 L9 13 L15 7 L21 9 L27 14 L33 17 L37 19" />
|
||||
<path d="M15 3 V20" strokeDasharray="2 2" />
|
||||
<path d="M29 3 V20" strokeDasharray="2 2" />
|
||||
</SketchSvg>
|
||||
),
|
||||
combine: (
|
||||
<SketchSvg>
|
||||
<rect x={4} y={10} width={6} height={10} />
|
||||
|
||||
@@ -38,6 +38,23 @@ function bucketRowsDrawing(ys: number[], dashed = false): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
/** Bucket columns along an x axis; `xs` sets the column spacing a scale gives. */
|
||||
function bucketColumnsDrawing(xs: number[], dashed = false): JSX.Element {
|
||||
return (
|
||||
<TileSvg>
|
||||
<path d="M4 22 H60" strokeWidth={1.4} opacity={0.35} />
|
||||
{xs.map((x) => (
|
||||
<path
|
||||
key={x}
|
||||
d={`M${x} 4 V20`}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={dashed ? '3 3' : undefined}
|
||||
/>
|
||||
))}
|
||||
</TileSvg>
|
||||
);
|
||||
}
|
||||
|
||||
export const TILE_DRAWINGS = {
|
||||
lineSolid: (
|
||||
<TileSvg>
|
||||
@@ -159,6 +176,25 @@ export const TILE_DRAWINGS = {
|
||||
bucketsLinear: bucketRowsDrawing([4, 9, 14, 19]),
|
||||
bucketsLog: bucketRowsDrawing([3, 5, 9, 17]),
|
||||
bucketsSymlog: bucketRowsDrawing([3, 9, 11, 13, 19]),
|
||||
bucketColumnsAuto: bucketColumnsDrawing([10, 20, 30, 42, 56], true),
|
||||
bucketColumnsLinear: bucketColumnsDrawing([8, 20, 32, 44, 56]),
|
||||
bucketColumnsLog: bucketColumnsDrawing([8, 12, 18, 30, 56]),
|
||||
bucketColumnsSymlog: bucketColumnsDrawing([8, 26, 32, 38, 56]),
|
||||
bucketColumnsOrdinal: (
|
||||
<TileSvg>
|
||||
<path d="M4 22 H60" strokeWidth={1.4} opacity={0.35} />
|
||||
{[8, 20, 32, 44].map((x, i) => (
|
||||
<rect
|
||||
key={x}
|
||||
x={x}
|
||||
y={[14, 6, 10, 16][i]}
|
||||
width={10}
|
||||
height={[6, 14, 10, 4][i]}
|
||||
{...FILLED}
|
||||
/>
|
||||
))}
|
||||
</TileSvg>
|
||||
),
|
||||
scaleSymlog: (
|
||||
<TileSvg>
|
||||
<path d="M4 20 C 20 20, 22 12, 32 12 S 44 4, 60 4" />
|
||||
@@ -180,6 +216,22 @@ export const TILE_DRAWINGS = {
|
||||
<rect x={38} y={8} width={11} height={6} {...FILLED} />
|
||||
</TileSvg>
|
||||
),
|
||||
barsSideBySide: (
|
||||
<TileSvg>
|
||||
<rect x={10} y={8} width={7} height={14} {...FILLED} opacity={0.5} />
|
||||
<rect x={18} y={12} width={7} height={10} {...FILLED} />
|
||||
<rect x={36} y={11} width={7} height={11} {...FILLED} opacity={0.5} />
|
||||
<rect x={44} y={6} width={7} height={16} {...FILLED} />
|
||||
</TileSvg>
|
||||
),
|
||||
barsPercent: (
|
||||
<TileSvg>
|
||||
<rect x={14} y={12} width={11} height={10} {...FILLED} opacity={0.5} />
|
||||
<rect x={14} y={2} width={11} height={9} {...FILLED} />
|
||||
<rect x={38} y={8} width={11} height={14} {...FILLED} opacity={0.5} />
|
||||
<rect x={38} y={2} width={11} height={5} {...FILLED} />
|
||||
</TileSvg>
|
||||
),
|
||||
areaOverlap: (
|
||||
<TileSvg>
|
||||
<path d="M4 15 L18 9 L32 13 L46 6 L60 9" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type {
|
||||
DashboardtypesDistributionAxesDTO,
|
||||
DashboardtypesLinkDTO,
|
||||
DashboardtypesHistogramBucketsDTO,
|
||||
DashboardtypesLegendDTO,
|
||||
@@ -25,6 +26,7 @@ import BucketsSection from './sections/BucketsSection/BucketsSection';
|
||||
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
|
||||
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
|
||||
import DimensionsSection from './sections/DimensionsSection/DimensionsSection';
|
||||
import DistributionAxesSection from './sections/DistributionAxesSection/DistributionAxesSection';
|
||||
import FormattingSection from './sections/FormattingSection/FormattingSection';
|
||||
import LegendSection from './sections/LegendSection/LegendSection';
|
||||
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
|
||||
@@ -97,6 +99,13 @@ export const SECTION_REGISTRY: {
|
||||
getPluginSlice<DashboardtypesScatterPlotAxesDTO>(spec, 'axes'),
|
||||
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
|
||||
},
|
||||
// Same `axes` key again, in Distribution's bucket/count-scale shape.
|
||||
[SectionKind.DistributionAxes]: {
|
||||
Component: DistributionAxesSection,
|
||||
get: (spec): DashboardtypesDistributionAxesDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesDistributionAxesDTO>(spec, 'axes'),
|
||||
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
|
||||
},
|
||||
[SectionKind.Dimensions]: {
|
||||
Component: DimensionsSection,
|
||||
get: (spec): DashboardtypesScatterPlotDimensionsDTO | undefined =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import cx from 'classnames';
|
||||
import { DashboardtypesStackModeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { resolveFillOpacity } from 'lib/uPlotV2/utils/fillOpacity';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
@@ -13,9 +14,12 @@ import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
import DisconnectValuesField from './DisconnectValuesField';
|
||||
import DistributionPercentilesField from './DistributionPercentilesField';
|
||||
import HeatmapColorsField from './HeatmapColorsField';
|
||||
import PointsFields from './PointsFields';
|
||||
import {
|
||||
BUCKET_STACK_HELP,
|
||||
BUCKET_STACK_OPTIONS,
|
||||
FILL_MODE_OPTIONS,
|
||||
FILLED_FILL_MODE_OPTIONS,
|
||||
LINE_INTERPOLATION_HELP,
|
||||
@@ -45,9 +49,34 @@ function ChartAppearanceSection({
|
||||
Pick<SectionEditorContext, 'stepInterval' | 'sizeColumnLabel'>): JSX.Element {
|
||||
const reset = createFieldResetter(value, savedValue, onChange);
|
||||
const interpolation = value?.lineInterpolation;
|
||||
const stack = value?.stack ?? DashboardtypesStackModeDTO.normal;
|
||||
|
||||
return (
|
||||
<>
|
||||
{controls.stack && (
|
||||
<ConfigField
|
||||
label="Groups in a bucket"
|
||||
help={BUCKET_STACK_HELP[stack]}
|
||||
{...reset('stack')}
|
||||
>
|
||||
<ConfigTiles
|
||||
testId="panel-editor-v2-bucket-stack"
|
||||
aria-label="Groups in a bucket"
|
||||
value={stack}
|
||||
items={BUCKET_STACK_OPTIONS}
|
||||
onChange={(next): void => onChange({ ...value, stack: next })}
|
||||
/>
|
||||
</ConfigField>
|
||||
)}
|
||||
|
||||
{controls.percentiles && (
|
||||
<DistributionPercentilesField
|
||||
value={value?.percentiles}
|
||||
savedValue={savedValue?.percentiles}
|
||||
onChange={(percentiles): void => onChange({ ...value, percentiles })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.lineStyle && (
|
||||
<ConfigField label="Line" {...reset('lineStyle')}>
|
||||
<ConfigTiles
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { DashboardtypesDistributionPercentilesDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DEFAULT_DISTRIBUTION_PERCENTILES } from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/resolvers';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigMultiSelect from '../../controls/ConfigMultiSelect/ConfigMultiSelect';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
import {
|
||||
MAX_PERCENTILE_MARKERS,
|
||||
resolvePercentileItems,
|
||||
} from './percentileOptions';
|
||||
|
||||
interface DistributionPercentilesFieldProps {
|
||||
value: DashboardtypesDistributionPercentilesDTO | undefined;
|
||||
savedValue?: DashboardtypesDistributionPercentilesDTO;
|
||||
onChange: (next: DashboardtypesDistributionPercentilesDTO) => void;
|
||||
}
|
||||
|
||||
/** Edits `chartAppearance.percentiles`; unset reads as on, at p50/p90/p99. */
|
||||
function DistributionPercentilesField({
|
||||
value,
|
||||
savedValue,
|
||||
onChange,
|
||||
}: DistributionPercentilesFieldProps): JSX.Element {
|
||||
const reset = createFieldResetter(value, savedValue, onChange);
|
||||
const enabled = value?.enabled ?? true;
|
||||
const values = value?.values ?? DEFAULT_DISTRIBUTION_PERCENTILES;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-percentile-markers"
|
||||
title="Percentile markers"
|
||||
description="Dashed lines at percentiles estimated from the bucket counts."
|
||||
sketch={SWITCH_SKETCHES.percentiles}
|
||||
changed={reset('enabled').changed}
|
||||
value={enabled}
|
||||
onChange={(next): void => onChange({ enabled: next, values })}
|
||||
/>
|
||||
|
||||
{enabled && (
|
||||
<ConfigField
|
||||
label="Percentiles"
|
||||
help={`Up to ${MAX_PERCENTILE_MARKERS}.`}
|
||||
{...reset('values')}
|
||||
>
|
||||
<ConfigMultiSelect
|
||||
testId="panel-editor-v2-percentile-values"
|
||||
value={values.map(String)}
|
||||
items={resolvePercentileItems(values)}
|
||||
onChange={(picked): void => {
|
||||
if (picked.length <= MAX_PERCENTILE_MARKERS) {
|
||||
onChange({ enabled, values: picked.map(Number) });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ConfigField>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DistributionPercentilesField;
|
||||
@@ -125,6 +125,41 @@ describe('ChartAppearanceSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the bucket stack on stacked, where a distribution starts', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ stack: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-bucket-stack'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Groups sit on top of each other, so the height shows the bucket total.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes the chosen bucket stack', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ stack: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Side by side'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ stack: 'none' });
|
||||
});
|
||||
|
||||
it('shows the fill opacity at the chart default when the spec omits it', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import DistributionPercentilesField from '../DistributionPercentilesField';
|
||||
import { resolvePercentileItems } from '../percentileOptions';
|
||||
|
||||
describe('DistributionPercentilesField', () => {
|
||||
it('opens on, with the values picker shown', () => {
|
||||
render(
|
||||
<DistributionPercentilesField value={undefined} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-percentile-values'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('turns markers off, keeping the values for when they come back', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<DistributionPercentilesField
|
||||
value={{ enabled: true, values: [75] }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('switch'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ enabled: false, values: [75] });
|
||||
});
|
||||
|
||||
it('hides the values picker while markers are off', () => {
|
||||
render(
|
||||
<DistributionPercentilesField
|
||||
value={{ enabled: false, values: [75] }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-percentile-values'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePercentileItems', () => {
|
||||
it('adds a saved value missing from the presets, in order', () => {
|
||||
expect(resolvePercentileItems([97]).map(({ label }) => label)).toStrictEqual([
|
||||
'p50',
|
||||
'p75',
|
||||
'p90',
|
||||
'p95',
|
||||
'p97',
|
||||
'p99',
|
||||
'p99.9',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
DashboardtypesStackModeDTO,
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
@@ -99,3 +100,32 @@ export const DISCONNECT_MODE_OPTIONS: ConfigTileItem<DisconnectValuesMode>[] = [
|
||||
drawing: TILE_DRAWINGS.gapsBreak,
|
||||
},
|
||||
];
|
||||
|
||||
/** Distribution's layout of groups within a bucket. */
|
||||
export const BUCKET_STACK_OPTIONS: ConfigTileItem<DashboardtypesStackModeDTO>[] =
|
||||
[
|
||||
{
|
||||
value: DashboardtypesStackModeDTO.normal,
|
||||
label: 'Stacked',
|
||||
drawing: TILE_DRAWINGS.barsStacked,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesStackModeDTO.none,
|
||||
label: 'Side by side',
|
||||
drawing: TILE_DRAWINGS.barsSideBySide,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesStackModeDTO.percent,
|
||||
label: '100%',
|
||||
drawing: TILE_DRAWINGS.barsPercent,
|
||||
},
|
||||
];
|
||||
|
||||
export const BUCKET_STACK_HELP: Record<DashboardtypesStackModeDTO, string> = {
|
||||
[DashboardtypesStackModeDTO.normal]:
|
||||
'Groups sit on top of each other, so the height shows the bucket total.',
|
||||
[DashboardtypesStackModeDTO.none]:
|
||||
'Groups share each bucket side by side; hard to read past a few groups.',
|
||||
[DashboardtypesStackModeDTO.percent]:
|
||||
"Each group's share of the bucket; the y axis becomes a percentage.",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ConfigMultiSelectItem } from '../../controls/ConfigMultiSelect/ConfigMultiSelect';
|
||||
|
||||
/** The save API rejects more. */
|
||||
export const MAX_PERCENTILE_MARKERS = 5;
|
||||
|
||||
const PRESET_PERCENTILES = [50, 75, 90, 95, 99, 99.9];
|
||||
|
||||
/** The presets plus any saved value outside them, ascending, so nothing saved is hidden. */
|
||||
export function resolvePercentileItems(
|
||||
values: number[],
|
||||
): ConfigMultiSelectItem[] {
|
||||
return [...new Set([...PRESET_PERCENTILES, ...values])]
|
||||
.sort((a, b) => a - b)
|
||||
.map((percentile) => ({
|
||||
value: String(percentile),
|
||||
label: `p${percentile}`,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.axes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.axis + .axis {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
DashboardtypesDistributionXScaleDTO,
|
||||
DashboardtypesDistributionYScaleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
import {
|
||||
X_SCALE_HELP,
|
||||
X_SCALE_OPTIONS,
|
||||
Y_SCALE_HELP,
|
||||
Y_SCALE_OPTIONS,
|
||||
} from './options';
|
||||
|
||||
import styles from './DistributionAxesSection.module.scss';
|
||||
|
||||
function DistributionAxesSection({
|
||||
value,
|
||||
savedValue,
|
||||
onChange,
|
||||
}: SectionEditorProps<SectionKind.DistributionAxes>): JSX.Element {
|
||||
const reset = createFieldResetter(value, savedValue, onChange);
|
||||
const xScale = value?.x?.scale ?? DashboardtypesDistributionXScaleDTO.auto;
|
||||
const yScale = value?.y?.scale ?? DashboardtypesDistributionYScaleDTO.linear;
|
||||
|
||||
return (
|
||||
<div className={styles.axes}>
|
||||
<div className={styles.axis}>
|
||||
<ConfigField
|
||||
label="X-axis scale"
|
||||
help={X_SCALE_HELP[xScale]}
|
||||
{...reset('x')}
|
||||
>
|
||||
<ConfigTiles
|
||||
testId="panel-editor-v2-distribution-x-scale"
|
||||
aria-label="X-axis scale"
|
||||
value={xScale}
|
||||
items={X_SCALE_OPTIONS}
|
||||
onChange={(scale): void => onChange({ ...value, x: { scale } })}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
<div className={styles.axis}>
|
||||
<ConfigField
|
||||
label="Y-axis scale"
|
||||
help={Y_SCALE_HELP[yScale]}
|
||||
{...reset('y')}
|
||||
>
|
||||
<ConfigTiles
|
||||
testId="panel-editor-v2-distribution-y-scale"
|
||||
aria-label="Y-axis scale"
|
||||
value={yScale}
|
||||
items={Y_SCALE_OPTIONS}
|
||||
onChange={(scale): void => onChange({ ...value, y: { scale } })}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DistributionAxesSection;
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
DashboardtypesDistributionXScaleDTO,
|
||||
DashboardtypesDistributionYScaleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import DistributionAxesSection from '../DistributionAxesSection';
|
||||
|
||||
describe('DistributionAxesSection', () => {
|
||||
it('opens on the defaults the chart draws: auto buckets, linear counts', () => {
|
||||
render(<DistributionAxesSection value={undefined} onChange={jest.fn()} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Log for buckets spanning two or more orders of magnitude, otherwise linear.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Bar heights proportional to counts.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes the x scale without dropping the y scale', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<DistributionAxesSection
|
||||
value={{ y: { scale: DashboardtypesDistributionYScaleDTO.log } }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText('Equal'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
x: { scale: DashboardtypesDistributionXScaleDTO.ordinal },
|
||||
y: { scale: DashboardtypesDistributionYScaleDTO.log },
|
||||
});
|
||||
});
|
||||
|
||||
it('writes the y scale', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(<DistributionAxesSection value={undefined} onChange={onChange} />);
|
||||
|
||||
await userEvent.click(screen.getByText('Logarithmic'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
y: { scale: DashboardtypesDistributionYScaleDTO.log },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
DashboardtypesDistributionXScaleDTO,
|
||||
DashboardtypesDistributionYScaleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
|
||||
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
|
||||
import { pickScaleOptions } from '../AxesSection/options';
|
||||
|
||||
export const X_SCALE_OPTIONS: ConfigTileItem<DashboardtypesDistributionXScaleDTO>[] =
|
||||
[
|
||||
{
|
||||
value: DashboardtypesDistributionXScaleDTO.auto,
|
||||
label: 'Auto',
|
||||
drawing: TILE_DRAWINGS.bucketColumnsAuto,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesDistributionXScaleDTO.linear,
|
||||
label: 'Linear',
|
||||
drawing: TILE_DRAWINGS.bucketColumnsLinear,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesDistributionXScaleDTO.log,
|
||||
label: 'Log',
|
||||
drawing: TILE_DRAWINGS.bucketColumnsLog,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesDistributionXScaleDTO.symlog,
|
||||
label: 'Symlog',
|
||||
drawing: TILE_DRAWINGS.bucketColumnsSymlog,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesDistributionXScaleDTO.ordinal,
|
||||
label: 'Equal',
|
||||
drawing: TILE_DRAWINGS.bucketColumnsOrdinal,
|
||||
},
|
||||
];
|
||||
|
||||
export const X_SCALE_HELP: Record<DashboardtypesDistributionXScaleDTO, string> =
|
||||
{
|
||||
auto:
|
||||
'Log for buckets spanning two or more orders of magnitude, otherwise linear.',
|
||||
linear: 'Bars as wide as their buckets.',
|
||||
log: 'More room for small buckets.',
|
||||
symlog: 'Log on both sides of zero, for buckets that cross it.',
|
||||
ordinal: 'Every bucket equally wide, whatever its range.',
|
||||
};
|
||||
|
||||
export const Y_SCALE_OPTIONS = pickScaleOptions([
|
||||
DashboardtypesDistributionYScaleDTO.linear,
|
||||
DashboardtypesDistributionYScaleDTO.log,
|
||||
] as const);
|
||||
|
||||
export const Y_SCALE_HELP: Record<DashboardtypesDistributionYScaleDTO, string> =
|
||||
{
|
||||
linear: 'Bar heights proportional to counts.',
|
||||
log: 'Keeps small tail buckets visible next to the bulk.',
|
||||
};
|
||||
@@ -45,6 +45,8 @@ interface PanelEditorQueryBuilderProps {
|
||||
onStageRunQuery: () => void;
|
||||
/** Abort the in-flight preview fetch (the button's cancel action). */
|
||||
onCancelQuery: () => void;
|
||||
/** Optional content above the tabs (e.g. Histogram's pointer to Distribution). */
|
||||
banner?: ReactNode;
|
||||
/** Optional content pinned below the builder (e.g. the List columns editor). */
|
||||
footer?: ReactNode;
|
||||
/** Pin the tabs + Run Query row to the top of the scroll area. Off in the View modal, which shares a scroll area with its own header. */
|
||||
@@ -62,6 +64,7 @@ function PanelEditorQueryBuilder({
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
banner,
|
||||
footer,
|
||||
stickyHeader = true,
|
||||
}: PanelEditorQueryBuilderProps): JSX.Element {
|
||||
@@ -188,6 +191,7 @@ function PanelEditorQueryBuilder({
|
||||
role="presentation"
|
||||
>
|
||||
<div className={styles.scrollArea}>
|
||||
{banner}
|
||||
<Tabs
|
||||
type="card"
|
||||
className={cx(styles.tabsContainer, {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { isBucketCountingPanelType } from 'lib/query/bucketCountingPanelTypes';
|
||||
import { handleQueryChange } from 'lib/query/panelQuery';
|
||||
import type { PartialPanelTypes } from 'lib/query/panelTypeDataSourceFormValuesMap';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
@@ -183,7 +184,7 @@ export function usePanelKindAndQueryModeSwitch({
|
||||
newKind === 'signoz/ListPanel'
|
||||
? withDefaultListOrder(transformed)
|
||||
: transformed;
|
||||
return newKind === 'signoz/HeatmapPanel'
|
||||
return isBucketCountingPanelType(newPanelType)
|
||||
? withSingleHeatmapQuery(ordered)
|
||||
: withPercentileHistogramAggregation(ordered);
|
||||
};
|
||||
|
||||
@@ -24,7 +24,8 @@ import type { PanelKind } from '../types/panelKind';
|
||||
|
||||
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
|
||||
const { logs, traces, metrics } = TelemetrytypesSignalDTO;
|
||||
const { time_series, scalar, raw, heatmap } = Querybuildertypesv5RequestTypeDTO;
|
||||
const { time_series, scalar, raw, heatmap, distribution } =
|
||||
Querybuildertypesv5RequestTypeDTO;
|
||||
|
||||
const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
|
||||
'signoz/TimeSeriesPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
@@ -33,6 +34,7 @@ const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
|
||||
'signoz/NumberPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/HistogramPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/HeatmapPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/DistributionPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
|
||||
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE],
|
||||
'signoz/ListPanel': [QUERY_BUILDER],
|
||||
@@ -73,6 +75,7 @@ const EXPECTED_QUERY_MODES: Record<PanelKind, QueryMode[]> = {
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
],
|
||||
'signoz/HeatmapPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/DistributionPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/PieChartPanel': [
|
||||
QUERY_BUILDER,
|
||||
CLICKHOUSE,
|
||||
@@ -92,6 +95,7 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
|
||||
'signoz/HistogramPanel': [metrics, logs, traces],
|
||||
// A heatmap needs a bucket axis, which only a metric carries.
|
||||
'signoz/HeatmapPanel': [metrics],
|
||||
'signoz/DistributionPanel': [metrics],
|
||||
'signoz/PieChartPanel': [metrics, logs, traces],
|
||||
'signoz/TablePanel': [metrics, logs, traces],
|
||||
// List renders raw rows; metrics produce no row data.
|
||||
@@ -145,6 +149,13 @@ const EXPECTED_QUERY_CAPABILITIES: Partial<
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
'signoz/DistributionPanel': {
|
||||
requestType: distribution,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
'signoz/NumberPanel': {
|
||||
requestType: scalar,
|
||||
formatTableResultForUI: false,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { DashboardtypesDistributionPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import Distribution from 'lib/visualization/charts/Distribution/Distribution';
|
||||
import type { DistributionBarClick } from 'lib/visualization/charts/Distribution/types';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
import TooltipFooter from 'lib/visualization/panels/components/TooltipFooter';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { getDistributionResults } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
import NoData from '../../components/NoData/NoData';
|
||||
import PanelStyles from '../../panel.module.scss';
|
||||
import { PanelRendererProps } from '../../types/rendererProps';
|
||||
import {
|
||||
resolveDecimalPrecision,
|
||||
resolveDistributionPercentiles,
|
||||
resolveDistributionStackMode,
|
||||
resolveDistributionXScale,
|
||||
resolveDistributionYScale,
|
||||
resolveLegendPosition,
|
||||
} from '../../utils/chartAppearance/resolvers';
|
||||
import { enrichDistributionClick } from '../../utils/drilldown/enrichDistributionClick';
|
||||
import { getBuilderQueries } from '../../utils/getBuilderQueries';
|
||||
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
|
||||
import { resolveRequestedBucketKind } from '../HeatmapPanel/prepareData';
|
||||
|
||||
import { prepareDistributionData } from './prepareData';
|
||||
|
||||
function DistributionPanelRenderer({
|
||||
panelId,
|
||||
panel,
|
||||
data,
|
||||
isFetching,
|
||||
refetch,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/DistributionPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const spec = useMemo<DashboardtypesDistributionPanelSpecDTO>(
|
||||
() => panel.spec.plugin.spec,
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const { buckets, series, queryName } = useMemo(
|
||||
() =>
|
||||
prepareDistributionData({
|
||||
results: getDistributionResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
}),
|
||||
[data.response, data.legendMap],
|
||||
);
|
||||
|
||||
const xScale = useMemo(
|
||||
() =>
|
||||
resolveDistributionXScale(
|
||||
spec.axes?.x?.scale,
|
||||
resolveRequestedBucketKind(data.requestPayload),
|
||||
),
|
||||
[spec.axes?.x?.scale, data.requestPayload],
|
||||
);
|
||||
|
||||
const percentiles = useMemo(
|
||||
() => resolveDistributionPercentiles(spec.chartAppearance?.percentiles),
|
||||
[spec.chartAppearance?.percentiles],
|
||||
);
|
||||
|
||||
const decimalPrecision = useMemo(
|
||||
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
|
||||
[spec.formatting?.decimalPrecision],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
const handleBarClick = useCallback(
|
||||
(click: DistributionBarClick): void => {
|
||||
const payload = enrichDistributionClick({
|
||||
click,
|
||||
queryName,
|
||||
builderQueries,
|
||||
timeRange: getPanelTimeRange(data.requestPayload),
|
||||
});
|
||||
if (payload) {
|
||||
onClick?.(payload);
|
||||
}
|
||||
},
|
||||
[onClick, queryName, builderQueries, data.requestPayload],
|
||||
);
|
||||
|
||||
const renderTooltipFooter = useCallback(
|
||||
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
|
||||
<TooltipFooter
|
||||
id={panelId}
|
||||
isPinned={isPinned}
|
||||
canDrilldown={!!enableDrillDown}
|
||||
dismiss={dismiss}
|
||||
/>
|
||||
),
|
||||
[panelId, enableDrillDown],
|
||||
);
|
||||
|
||||
const hasBars = buckets.length > 0 && series.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={graphRef}
|
||||
data-testid="distribution-panel-renderer"
|
||||
className={PanelStyles.panelContainer}
|
||||
>
|
||||
{!hasBars && (
|
||||
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
|
||||
)}
|
||||
{hasBars &&
|
||||
containerDimensions.width > 0 &&
|
||||
containerDimensions.height > 0 && (
|
||||
<Distribution
|
||||
key={panelId}
|
||||
id={panelId}
|
||||
buckets={buckets}
|
||||
series={series}
|
||||
xScale={xScale}
|
||||
yScale={resolveDistributionYScale(spec.axes?.y?.scale)}
|
||||
stack={resolveDistributionStackMode(spec.chartAppearance?.stack)}
|
||||
xAxisUnit={spec.formatting?.unit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
customColors={spec.legend?.customColors ?? undefined}
|
||||
percentiles={percentiles}
|
||||
canPinTooltip
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onBarClick={enableDrillDown && onClick ? handleBarClick : undefined}
|
||||
legendPosition={resolveLegendPosition(spec.legend?.position)}
|
||||
isDarkMode={isDarkMode}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DistributionPanelRenderer;
|
||||
@@ -0,0 +1,140 @@
|
||||
import type {
|
||||
Querybuildertypesv5DistributionDataDTO,
|
||||
Querybuildertypesv5DistributionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { prepareDistributionData } from '../prepareData';
|
||||
|
||||
function result({
|
||||
queryName = 'A',
|
||||
buckets = [1, 2, 4],
|
||||
distributions = [],
|
||||
}: {
|
||||
queryName?: string;
|
||||
buckets?: number[];
|
||||
distributions?: Querybuildertypesv5DistributionDTO[];
|
||||
} = {}): Querybuildertypesv5DistributionDataDTO {
|
||||
return {
|
||||
queryName,
|
||||
aggregations: [{ index: 0, meta: { buckets }, distributions }],
|
||||
};
|
||||
}
|
||||
|
||||
describe('prepareDistributionData', () => {
|
||||
it('reads the bucket bounds and one count array per group', () => {
|
||||
const { buckets, series, queryName } = prepareDistributionData({
|
||||
results: [
|
||||
result({
|
||||
distributions: [
|
||||
{
|
||||
labels: [{ key: { name: 'service' }, value: 'redis' }],
|
||||
counts: [5, 12, 3, 1],
|
||||
},
|
||||
{
|
||||
labels: [{ key: { name: 'service' }, value: 'cart' }],
|
||||
counts: [0, 4, 8, 0],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
legendMap: {},
|
||||
});
|
||||
|
||||
expect(buckets).toStrictEqual([1, 2, 4]);
|
||||
expect(queryName).toBe('A');
|
||||
expect(series).toStrictEqual([
|
||||
{
|
||||
label: '{service="redis"}',
|
||||
groupLabels: { service: 'redis' },
|
||||
counts: [5, 12, 3, 1],
|
||||
},
|
||||
{
|
||||
label: '{service="cart"}',
|
||||
groupLabels: { service: 'cart' },
|
||||
counts: [0, 4, 8, 0],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('names an ungrouped distribution after its query', () => {
|
||||
const { series } = prepareDistributionData({
|
||||
results: [result({ distributions: [{ counts: [1, 0, 0, 0] }] })],
|
||||
legendMap: { A: '' },
|
||||
});
|
||||
|
||||
expect(series).toHaveLength(1);
|
||||
expect(series[0].label).toBe('A');
|
||||
});
|
||||
|
||||
it('applies the query legend as the group label template', () => {
|
||||
const { series } = prepareDistributionData({
|
||||
results: [
|
||||
result({
|
||||
distributions: [
|
||||
{
|
||||
labels: [{ key: { name: 'service' }, value: 'redis' }],
|
||||
counts: [1, 0, 0, 0],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
legendMap: { A: '{{service}} latency' },
|
||||
});
|
||||
|
||||
expect(series[0].label).toBe('redis latency');
|
||||
});
|
||||
|
||||
it('pads missing counts with zero and trims extras, keeping the overflow slot', () => {
|
||||
const { series } = prepareDistributionData({
|
||||
results: [
|
||||
result({
|
||||
distributions: [
|
||||
{ counts: [1, 2] },
|
||||
{ counts: [1, 2, 3, 4, 5] },
|
||||
{ counts: null },
|
||||
],
|
||||
}),
|
||||
],
|
||||
legendMap: {},
|
||||
});
|
||||
|
||||
expect(series.map(({ counts }) => counts)).toStrictEqual([
|
||||
[1, 2, 0, 0],
|
||||
[1, 2, 3, 4],
|
||||
[0, 0, 0, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips aggregations without bounds and reads the first that has them', () => {
|
||||
const { buckets } = prepareDistributionData({
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
aggregations: [
|
||||
{ index: 0, meta: {}, distributions: [{ counts: [1] }] },
|
||||
{ index: 1, meta: { buckets: [10, 20] }, distributions: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
legendMap: {},
|
||||
});
|
||||
|
||||
expect(buckets).toStrictEqual([10, 20]);
|
||||
});
|
||||
|
||||
it('returns no buckets when nothing carries bounds', () => {
|
||||
expect(
|
||||
prepareDistributionData({
|
||||
results: [result({ buckets: [] })],
|
||||
legendMap: {},
|
||||
}),
|
||||
).toStrictEqual({ buckets: [], series: [], queryName: '' });
|
||||
expect(prepareDistributionData({ results: [], legendMap: {} })).toStrictEqual(
|
||||
{
|
||||
buckets: [],
|
||||
series: [],
|
||||
queryName: '',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DashboardtypesDistributionXScaleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { QueryRangeV5200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import type { PanelOfKind } from '../../../types/rendererProps';
|
||||
import { getDistributionDataWarning } from '../warning';
|
||||
|
||||
function dataWith(buckets: number[], counts: number[][]): PanelQueryData {
|
||||
return {
|
||||
response: {
|
||||
data: {
|
||||
type: 'distribution',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
aggregations: [
|
||||
{
|
||||
index: 0,
|
||||
meta: { buckets },
|
||||
distributions: counts.map((entry) => ({ counts: entry })),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as QueryRangeV5200,
|
||||
legendMap: {},
|
||||
} as unknown as PanelQueryData;
|
||||
}
|
||||
|
||||
function panelWith(
|
||||
spec: Record<string, unknown> = {},
|
||||
): PanelOfKind<'signoz/DistributionPanel'> {
|
||||
return {
|
||||
spec: { plugin: { kind: 'signoz/DistributionPanel', spec } },
|
||||
} as unknown as PanelOfKind<'signoz/DistributionPanel'>;
|
||||
}
|
||||
|
||||
describe('getDistributionDataWarning', () => {
|
||||
it('warns when more than 10% of observations overflow', () => {
|
||||
const warning = getDistributionDataWarning(
|
||||
panelWith({ formatting: { unit: 's' } }),
|
||||
dataWith([1, 10], [[10, 50, 40]]),
|
||||
);
|
||||
|
||||
expect(warning?.message).toMatch(/^40% of observations are above 10/);
|
||||
});
|
||||
|
||||
it('says nothing at or under 10%', () => {
|
||||
expect(
|
||||
getDistributionDataWarning(panelWith(), dataWith([1, 10], [[40, 50, 10]])),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('warns when an explicit log axis meets negative bounds', () => {
|
||||
const warning = getDistributionDataWarning(
|
||||
panelWith({
|
||||
axes: { x: { scale: DashboardtypesDistributionXScaleDTO.log } },
|
||||
}),
|
||||
dataWith([-5, 5], [[1, 1, 0]]),
|
||||
);
|
||||
|
||||
expect(warning?.message).toMatch(/log axis can't place negative/);
|
||||
});
|
||||
|
||||
it('lists both under one summary when both apply', () => {
|
||||
const warning = getDistributionDataWarning(
|
||||
panelWith({
|
||||
axes: { x: { scale: DashboardtypesDistributionXScaleDTO.log } },
|
||||
}),
|
||||
dataWith([-5, 5], [[1, 1, 8]]),
|
||||
);
|
||||
|
||||
expect(warning?.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('says nothing without bounds', () => {
|
||||
expect(getDistributionDataWarning(panelWith(), dataWith([], []))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ChartColumnBig } from '@signozhq/icons';
|
||||
|
||||
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import { QueryModeKind } from '../../types/panelCapabilities';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import { getDistributionDataWarning } from './warning';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/DistributionPanel'> = {
|
||||
kind: 'signoz/DistributionPanel',
|
||||
displayName: 'Distribution',
|
||||
mode: 'query',
|
||||
icon: ChartColumnBig,
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedQueryModesKindMap: {
|
||||
// Only metrics carry a bucket axis; the request rejects the other signals.
|
||||
[QueryMode.QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [TelemetrytypesSignalDTO.metrics],
|
||||
},
|
||||
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
},
|
||||
// The request rejects both, as for heatmap: a point is a count per bucket.
|
||||
queryBuilderFields: {
|
||||
[QueryBuilderField.Functions]: { state: 'hidden' },
|
||||
[QueryBuilderField.Having]: { state: 'hidden' },
|
||||
},
|
||||
// No width-derived step: there is no time axis, and a histogram's counts do not
|
||||
// depend on the step.
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.distribution,
|
||||
formatTableResultForUI: false,
|
||||
bucketedStepInterval: false,
|
||||
orderTiebreaker: false,
|
||||
serverPaginated: false,
|
||||
},
|
||||
getDataWarning: getDistributionDataWarning,
|
||||
actions: {
|
||||
view: true,
|
||||
edit: true,
|
||||
clone: true,
|
||||
download: { csv: false, png: true, svg: true },
|
||||
createAlert: false,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
Querybuildertypesv5DistributionDataDTO,
|
||||
Querybuildertypesv5DistributionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import type { DistributionSeries } from 'lib/visualization/charts/Distribution/types';
|
||||
|
||||
export interface DistributionPanelData {
|
||||
/** Ascending bucket upper bounds shared by every group; empty means no chart. */
|
||||
buckets: number[];
|
||||
series: DistributionSeries[];
|
||||
/** The query the series came from, for drilldown. */
|
||||
queryName: string;
|
||||
}
|
||||
|
||||
const EMPTY_DATA: DistributionPanelData = {
|
||||
buckets: [],
|
||||
series: [],
|
||||
queryName: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* V5 distribution results → the chart's `buckets` + `series`. Only the first
|
||||
* aggregation carrying bounds is read: each has its own bucket axis, and the chart
|
||||
* has one.
|
||||
*/
|
||||
export function prepareDistributionData({
|
||||
results,
|
||||
legendMap,
|
||||
}: {
|
||||
results: Querybuildertypesv5DistributionDataDTO[];
|
||||
legendMap: Record<string, string>;
|
||||
}): DistributionPanelData {
|
||||
for (const result of results) {
|
||||
const queryName = result.queryName ?? '';
|
||||
for (const aggregation of result.aggregations ?? []) {
|
||||
const buckets = aggregation.meta?.buckets;
|
||||
if (!buckets?.length) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
buckets,
|
||||
queryName,
|
||||
series: (aggregation.distributions ?? []).map((distribution) =>
|
||||
toDistributionSeries(
|
||||
distribution,
|
||||
buckets.length + 1,
|
||||
queryName,
|
||||
legendMap[queryName] ?? '',
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return EMPTY_DATA;
|
||||
}
|
||||
|
||||
/** Pads or trims to `length` so every series lines up with the bucket axis. */
|
||||
function toDistributionSeries(
|
||||
distribution: Querybuildertypesv5DistributionDTO,
|
||||
length: number,
|
||||
queryName: string,
|
||||
legend: string,
|
||||
): DistributionSeries {
|
||||
const record: Record<string, string> = {};
|
||||
(distribution.labels ?? []).forEach((label) => {
|
||||
if (label.key?.name) {
|
||||
record[label.key.name] = String(label.value);
|
||||
}
|
||||
});
|
||||
|
||||
const counts = distribution.counts ?? [];
|
||||
return {
|
||||
label: getLabelName(record, queryName, legend),
|
||||
groupLabels: record,
|
||||
counts: Array.from({ length }, (_, index) => {
|
||||
const count = counts[index];
|
||||
return Number.isFinite(count) ? count : 0;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SectionKind, type SectionConfig } from '../../types/sections';
|
||||
import { resolveDistributionLegendSeries } from '../../utils/legendSeries';
|
||||
|
||||
export const sections: SectionConfig[] = [
|
||||
{
|
||||
kind: SectionKind.Visualization,
|
||||
controls: { switchPanelKind: true, timePreference: true },
|
||||
},
|
||||
{
|
||||
kind: SectionKind.ChartAppearance,
|
||||
controls: { stack: true, percentiles: true },
|
||||
},
|
||||
// Formats the bucket bounds; counts are plain integers and take no unit.
|
||||
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
|
||||
{ kind: SectionKind.DistributionAxes },
|
||||
{
|
||||
kind: SectionKind.Legend,
|
||||
controls: { position: true, colors: resolveDistributionLegendSeries },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,67 @@
|
||||
import { DashboardtypesDistributionXScaleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import type { PanelStatusDetail } from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelStatus/types';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { getDistributionResults } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
import type { PanelOfKind } from '../../types/rendererProps';
|
||||
import { prepareDistributionData } from './prepareData';
|
||||
|
||||
/** Above this share in overflow, the bucket bounds probably stop short. */
|
||||
export const OVERFLOW_WARNING_SHARE = 0.1;
|
||||
|
||||
/**
|
||||
* Problems with the bounds themselves, over every group regardless of the legend:
|
||||
* too much landing in overflow, or a log axis asked to place negative bounds.
|
||||
*/
|
||||
export function getDistributionDataWarning(
|
||||
panel: PanelOfKind<'signoz/DistributionPanel'>,
|
||||
data: PanelQueryData,
|
||||
): PanelStatusDetail | null {
|
||||
const { buckets, series } = prepareDistributionData({
|
||||
results: getDistributionResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
});
|
||||
if (buckets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const spec = panel.spec.plugin.spec;
|
||||
const lastBound = buckets[buckets.length - 1];
|
||||
let total = 0;
|
||||
let overflow = 0;
|
||||
series.forEach(({ counts }) => {
|
||||
counts.forEach((count) => {
|
||||
total += count;
|
||||
});
|
||||
overflow += counts[buckets.length] ?? 0;
|
||||
});
|
||||
|
||||
const messages: string[] = [];
|
||||
const overflowShare = total > 0 ? overflow / total : 0;
|
||||
if (overflowShare > OVERFLOW_WARNING_SHARE) {
|
||||
const bound = getToolTipValue(String(lastBound), spec.formatting?.unit);
|
||||
messages.push(
|
||||
`${Math.round(overflowShare * 100)}% of observations are above ${bound}. The bucket bounds may be too low.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
spec.axes?.x?.scale === DashboardtypesDistributionXScaleDTO.log &&
|
||||
buckets.some((bound) => bound < 0)
|
||||
) {
|
||||
messages.push(
|
||||
"A log axis can't place negative bucket bounds. Use symlog or auto instead.",
|
||||
);
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
message:
|
||||
messages.length === 1
|
||||
? messages[0]
|
||||
: 'The bucket bounds may not fit the data.',
|
||||
messages: messages.length === 1 ? [] : messages,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.banner {
|
||||
margin: var(--spacing-4) var(--spacing-6) 0;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
import type { QueryEditorPaneProps } from '../../types/panelDefinition';
|
||||
import PanelEditorQueryBuilder from '../../../PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import { isMetricQuery } from './isMetricQuery';
|
||||
|
||||
import styles from './HistogramEditorPane.module.scss';
|
||||
|
||||
/**
|
||||
* Histogram bins the query's values in the browser, which for a histogram metric
|
||||
* means percentiles or rates, not its bucket counts. Distribution reads those, but
|
||||
* not logs or traces yet, so the pointer shows for metric queries only.
|
||||
*/
|
||||
function HistogramEditorPane({
|
||||
panelDefinition,
|
||||
isLoadingQueries,
|
||||
onChangeQueryMode,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
stickyHeader,
|
||||
}: QueryEditorPaneProps): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={panelDefinition}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
onChangeQueryMode={onChangeQueryMode}
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
onCancelQuery={onCancelQuery}
|
||||
stickyHeader={stickyHeader}
|
||||
banner={
|
||||
isMetricQuery(currentQuery) && (
|
||||
<Callout
|
||||
className={styles.banner}
|
||||
type="info"
|
||||
size="small"
|
||||
showIcon
|
||||
title="For bucket counts over the time range, use the Distribution panel."
|
||||
testId="histogram-distribution-banner"
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistogramEditorPane;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { isMetricQuery } from '../isMetricQuery';
|
||||
|
||||
function builderOver(dataSource: DataSource): Query {
|
||||
return initialQueriesMap[dataSource];
|
||||
}
|
||||
|
||||
describe('isMetricQuery', () => {
|
||||
it('is true for a metrics builder query', () => {
|
||||
expect(isMetricQuery(builderOver(DataSource.METRICS))).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for logs and traces, which Distribution does not read yet', () => {
|
||||
expect(isMetricQuery(builderOver(DataSource.LOGS))).toBe(false);
|
||||
expect(isMetricQuery(builderOver(DataSource.TRACES))).toBe(false);
|
||||
});
|
||||
|
||||
it('is true for PromQL and ClickHouse', () => {
|
||||
const base = builderOver(DataSource.LOGS);
|
||||
expect(isMetricQuery({ ...base, queryType: EQueryType.PROM })).toBe(true);
|
||||
expect(isMetricQuery({ ...base, queryType: EQueryType.CLICKHOUSE })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { BarChart } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import { QueryModeKind } from '../../types/panelCapabilities';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import HistogramEditorPane from './HistogramEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
@@ -17,7 +17,7 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
mode: 'query',
|
||||
icon: BarChart,
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
EditorPane: HistogramEditorPane,
|
||||
sections,
|
||||
supportedQueryModesKindMap: {
|
||||
[QueryMode.QUERY_BUILDER]: {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
/** PromQL and ClickHouse read metrics here; a builder query does when any of it does. */
|
||||
export function isMetricQuery(query: Query): boolean {
|
||||
if (query.queryType !== EQueryType.QUERY_BUILDER) {
|
||||
return true;
|
||||
}
|
||||
return query.builder.queryData.some(
|
||||
(queryData) => queryData.dataSource === DataSource.METRICS,
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { definition as AreaChart } from './kinds/AreaChartPanel/definition';
|
||||
import { definition as BarChart } from './kinds/BarChartPanel/definition';
|
||||
import { definition as Distribution } from './kinds/DistributionPanel/definition';
|
||||
import { definition as Heatmap } from './kinds/HeatmapPanel/definition';
|
||||
import { definition as Histogram } from './kinds/HistogramPanel/definition';
|
||||
import { definition as NumberValue } from './kinds/NumberPanel/definition';
|
||||
@@ -29,6 +30,7 @@ export const PANELS: PanelRegistry = {
|
||||
[ScatterPlot.kind]: ScatterPlot,
|
||||
[Histogram.kind]: Histogram,
|
||||
[Heatmap.kind]: Heatmap,
|
||||
[Distribution.kind]: Distribution,
|
||||
[List.kind]: List,
|
||||
[Text.kind]: Text,
|
||||
};
|
||||
|
||||
@@ -37,6 +37,9 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
|
||||
'signoz/HeatmapPanel': { onDragSelect?: DragSelect };
|
||||
'signoz/TablePanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/PieChartPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/DistributionPanel': {
|
||||
onClick?: (event: DrilldownClickPayload) => void;
|
||||
};
|
||||
'signoz/NumberPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/ScatterPlotPanel': {
|
||||
onClick?: (event: DrilldownClickPayload) => void;
|
||||
|
||||
@@ -27,6 +27,7 @@ export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
|
||||
'signoz/HeatmapPanel': PANEL_TYPES.HEATMAP,
|
||||
'signoz/TextPanel': PANEL_TYPES.TEXT,
|
||||
'signoz/ScatterPlotPanel': PANEL_TYPES.SCATTER,
|
||||
'signoz/DistributionPanel': PANEL_TYPES.DISTRIBUTION,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@ import type {
|
||||
DashboardtypesLinkDTO,
|
||||
DashboardtypesAxesDTO,
|
||||
DashboardtypesComparisonThresholdDTO,
|
||||
DashboardtypesDistributionAxesDTO,
|
||||
DashboardtypesDistributionPercentilesDTO,
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesFillOpacityDTO,
|
||||
DashboardtypesHeatmapAxesDTO,
|
||||
@@ -72,6 +74,8 @@ export enum SectionKind {
|
||||
Dimensions = 'dimensions',
|
||||
/** Scatter Plot's per-axis `axes` shape; `Axes` is the single y-axis shape. */
|
||||
ScatterAxes = 'scatterAxes',
|
||||
/** Distribution's `axes` shape: the bucket-axis and count-axis scales. */
|
||||
DistributionAxes = 'distributionAxes',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +120,10 @@ export interface PanelChartAppearanceSlice {
|
||||
colors?: DashboardtypesHeatmapColorsDTO;
|
||||
/** Scatter Plot's dot size and opacity. */
|
||||
points?: DashboardtypesScatterPlotPointsDTO;
|
||||
/** Distribution stacking, which lives here rather than under `visualization`. */
|
||||
stack?: DashboardtypesStackModeDTO;
|
||||
/** Distribution's percentile markers. */
|
||||
percentiles?: DashboardtypesDistributionPercentilesDTO;
|
||||
}
|
||||
|
||||
/** Superset spanning every kind's visualization DTO. */
|
||||
@@ -148,6 +156,7 @@ export interface SectionSpecMap {
|
||||
[SectionKind.PanelHeader]: DashboardtypesHeaderOptionsDTO; // spec.plugin.spec.headerOptions (Text)
|
||||
[SectionKind.Dimensions]: DashboardtypesScatterPlotDimensionsDTO; // spec.plugin.spec.dimensions (Scatter Plot)
|
||||
[SectionKind.ScatterAxes]: DashboardtypesScatterPlotAxesDTO; // spec.plugin.spec.axes (Scatter Plot)
|
||||
[SectionKind.DistributionAxes]: DashboardtypesDistributionAxesDTO; // spec.plugin.spec.axes (Distribution)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,6 +197,10 @@ export interface SectionControls {
|
||||
colors?: boolean;
|
||||
/** Scatter Plot's dots: one size, or a min/max range when a size column is bound. */
|
||||
points?: boolean;
|
||||
/** Distribution's group layout in a bucket: stacked, side by side or 100%. */
|
||||
stack?: boolean;
|
||||
/** Distribution's percentile markers: on/off and which ones. */
|
||||
percentiles?: boolean;
|
||||
};
|
||||
[SectionKind.Buckets]: {
|
||||
count?: boolean;
|
||||
@@ -218,7 +231,8 @@ export type AtomicSectionKind =
|
||||
| SectionKind.TextLayout
|
||||
| SectionKind.PanelHeader
|
||||
| SectionKind.Dimensions
|
||||
| SectionKind.ScatterAxes;
|
||||
| SectionKind.ScatterAxes
|
||||
| SectionKind.DistributionAxes;
|
||||
|
||||
/** Predicate to hide a section from the current spec; returning true removes it. */
|
||||
export type SectionVisibilityPredicate = (
|
||||
@@ -255,6 +269,7 @@ export const SECTION_METADATA = {
|
||||
[SectionKind.PanelHeader]: { title: 'Panel header', icon: PanelTop },
|
||||
[SectionKind.Dimensions]: { title: 'Dimensions', icon: Crosshair },
|
||||
[SectionKind.ScatterAxes]: { title: 'Axes', icon: Scale3D },
|
||||
[SectionKind.DistributionAxes]: { title: 'Axes', icon: Scale3D },
|
||||
} as const satisfies Record<SectionKind, SectionMetadata>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
QueryRangeV5200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { resolveSeriesColor } from 'lib/visualization/charts/Distribution/utils';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import { resolveDistributionLegendSeries } from '../legendSeries';
|
||||
|
||||
describe('resolveDistributionLegendSeries', () => {
|
||||
it('keys each group by the label and default colour the chart draws it with', () => {
|
||||
const data = {
|
||||
response: {
|
||||
data: {
|
||||
type: 'distribution',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
aggregations: [
|
||||
{
|
||||
index: 0,
|
||||
meta: { buckets: [1] },
|
||||
distributions: [
|
||||
{
|
||||
labels: [{ key: { name: 'service' }, value: 'cart' }],
|
||||
counts: [1, 2],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as QueryRangeV5200,
|
||||
legendMap: { A: '{{service}}' },
|
||||
} as unknown as PanelQueryData;
|
||||
|
||||
expect(
|
||||
resolveDistributionLegendSeries({
|
||||
spec: {} as DashboardtypesPanelSpecDTO,
|
||||
queries: [],
|
||||
data,
|
||||
isDarkMode: true,
|
||||
}),
|
||||
).toStrictEqual([
|
||||
{ label: 'cart', defaultColor: resolveSeriesColor('cart', true) },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
DashboardtypesAreaFillModeDTO,
|
||||
DashboardtypesDistributionXScaleDTO,
|
||||
DashboardtypesDistributionYScaleDTO,
|
||||
DashboardtypesHeatmapColorModeDTO,
|
||||
DashboardtypesHeatmapColorScaleDTO,
|
||||
DashboardtypesHeatmapPaletteDTO,
|
||||
@@ -15,9 +17,17 @@ import {
|
||||
HeatmapColorPalette,
|
||||
HeatmapColorScale,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import {
|
||||
DistributionXScale,
|
||||
DistributionYScale,
|
||||
} from 'lib/visualization/charts/Distribution/types';
|
||||
|
||||
import {
|
||||
resolveAreaFillMode,
|
||||
resolveDistributionPercentiles,
|
||||
resolveDistributionStackMode,
|
||||
resolveDistributionXScale,
|
||||
resolveDistributionYScale,
|
||||
resolveHeatmapAxisScale,
|
||||
resolveHeatmapColors,
|
||||
resolveSpanGaps,
|
||||
@@ -190,3 +200,79 @@ describe('resolveHeatmapColors', () => {
|
||||
expect(resolveHeatmapColors({ steps: 0 })).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDistributionStackMode', () => {
|
||||
it('defaults to normal, unlike the shared stack default', () => {
|
||||
expect(resolveDistributionStackMode(undefined)).toBe(StackMode.Normal);
|
||||
expect(resolveStackMode(undefined)).toBe(StackMode.None);
|
||||
});
|
||||
|
||||
it('keeps an explicit mode', () => {
|
||||
expect(resolveDistributionStackMode(DashboardtypesStackModeDTO.none)).toBe(
|
||||
StackMode.None,
|
||||
);
|
||||
expect(resolveDistributionStackMode(DashboardtypesStackModeDTO.percent)).toBe(
|
||||
StackMode.Percent,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDistributionXScale', () => {
|
||||
it('maps each scale, ordinal included', () => {
|
||||
expect(
|
||||
resolveDistributionXScale(DashboardtypesDistributionXScaleDTO.ordinal),
|
||||
).toBe(DistributionXScale.Ordinal);
|
||||
expect(
|
||||
resolveDistributionXScale(DashboardtypesDistributionXScaleDTO.symlog),
|
||||
).toBe(DistributionXScale.Symlog);
|
||||
expect(resolveDistributionXScale(undefined)).toBe(DistributionXScale.Auto);
|
||||
});
|
||||
|
||||
it('keeps auto linear over linear bucketing, and only under auto', () => {
|
||||
expect(
|
||||
resolveDistributionXScale(
|
||||
DashboardtypesDistributionXScaleDTO.auto,
|
||||
Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
|
||||
),
|
||||
).toBe(DistributionXScale.Linear);
|
||||
expect(
|
||||
resolveDistributionXScale(
|
||||
DashboardtypesDistributionXScaleDTO.log,
|
||||
Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
|
||||
),
|
||||
).toBe(DistributionXScale.Log);
|
||||
expect(
|
||||
resolveDistributionXScale(
|
||||
undefined,
|
||||
Querybuildertypesv5BucketOptionsLogDTOKind.log,
|
||||
),
|
||||
).toBe(DistributionXScale.Auto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDistributionYScale', () => {
|
||||
it('defaults to linear', () => {
|
||||
expect(resolveDistributionYScale(undefined)).toBe(DistributionYScale.Linear);
|
||||
expect(
|
||||
resolveDistributionYScale(DashboardtypesDistributionYScaleDTO.log),
|
||||
).toBe(DistributionYScale.Log);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDistributionPercentiles', () => {
|
||||
it('marks p50/p90/p99 unless the spec says otherwise', () => {
|
||||
expect(resolveDistributionPercentiles(undefined)).toStrictEqual([50, 90, 99]);
|
||||
expect(
|
||||
resolveDistributionPercentiles({ enabled: true, values: null }),
|
||||
).toStrictEqual([50, 90, 99]);
|
||||
});
|
||||
|
||||
it('marks the configured percentiles, or none when turned off', () => {
|
||||
expect(
|
||||
resolveDistributionPercentiles({ enabled: true, values: [75, 99.9] }),
|
||||
).toStrictEqual([75, 99.9]);
|
||||
expect(
|
||||
resolveDistributionPercentiles({ enabled: false, values: [75] }),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
DashboardtypesAreaFillModeDTO,
|
||||
DashboardtypesDistributionXScaleDTO,
|
||||
DashboardtypesDistributionYScaleDTO,
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesHeatmapColorModeDTO,
|
||||
DashboardtypesHeatmapColorScaleDTO,
|
||||
@@ -24,6 +26,10 @@ import {
|
||||
HeatmapColorPalette,
|
||||
HeatmapColorScale,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import {
|
||||
DistributionXScale,
|
||||
DistributionYScale,
|
||||
} from 'lib/visualization/charts/Distribution/types';
|
||||
|
||||
/**
|
||||
* Bridges the V2 wire-format enums to the uPlotV2 chart enums. String values
|
||||
@@ -114,3 +120,22 @@ export const HEATMAP_Y_SCALE_MAP: Record<
|
||||
[DashboardtypesHeatmapYScaleDTO.log]: HeatmapAxisScale.Log,
|
||||
[DashboardtypesHeatmapYScaleDTO.symlog]: HeatmapAxisScale.Symlog,
|
||||
};
|
||||
|
||||
export const DISTRIBUTION_X_SCALE_MAP: Record<
|
||||
DashboardtypesDistributionXScaleDTO,
|
||||
DistributionXScale
|
||||
> = {
|
||||
[DashboardtypesDistributionXScaleDTO.auto]: DistributionXScale.Auto,
|
||||
[DashboardtypesDistributionXScaleDTO.linear]: DistributionXScale.Linear,
|
||||
[DashboardtypesDistributionXScaleDTO.log]: DistributionXScale.Log,
|
||||
[DashboardtypesDistributionXScaleDTO.symlog]: DistributionXScale.Symlog,
|
||||
[DashboardtypesDistributionXScaleDTO.ordinal]: DistributionXScale.Ordinal,
|
||||
};
|
||||
|
||||
export const DISTRIBUTION_Y_SCALE_MAP: Record<
|
||||
DashboardtypesDistributionYScaleDTO,
|
||||
DistributionYScale
|
||||
> = {
|
||||
[DashboardtypesDistributionYScaleDTO.linear]: DistributionYScale.Linear,
|
||||
[DashboardtypesDistributionYScaleDTO.log]: DistributionYScale.Log,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { rangeUtil } from '@grafana/data';
|
||||
import {
|
||||
type DashboardtypesAreaFillModeDTO,
|
||||
type DashboardtypesDistributionPercentilesDTO,
|
||||
type DashboardtypesDistributionXScaleDTO,
|
||||
type DashboardtypesDistributionYScaleDTO,
|
||||
DashboardtypesHeatmapYScaleDTO,
|
||||
DashboardtypesLegendPositionDTO,
|
||||
DashboardtypesPrecisionOptionDTO,
|
||||
@@ -18,9 +21,15 @@ import {
|
||||
HeatmapAxisScale,
|
||||
type HeatmapColorOptions,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import {
|
||||
DistributionXScale,
|
||||
DistributionYScale,
|
||||
} from 'lib/visualization/charts/Distribution/types';
|
||||
|
||||
import {
|
||||
AREA_FILL_MODE_MAP,
|
||||
DISTRIBUTION_X_SCALE_MAP,
|
||||
DISTRIBUTION_Y_SCALE_MAP,
|
||||
HEATMAP_COLOR_MODE_MAP,
|
||||
HEATMAP_COLOR_SCALE_MAP,
|
||||
HEATMAP_PALETTE_MAP,
|
||||
@@ -106,6 +115,54 @@ export function resolveStackMode(
|
||||
return StackMode.None;
|
||||
}
|
||||
|
||||
/** Missing/unknown falls back to `Normal`, where Distribution's stacking starts. */
|
||||
export function resolveDistributionStackMode(
|
||||
stack: DashboardtypesStackModeDTO | undefined,
|
||||
): StackMode {
|
||||
return stack ? resolveStackMode(stack) : StackMode.Normal;
|
||||
}
|
||||
|
||||
export const DEFAULT_DISTRIBUTION_PERCENTILES = [50, 90, 99];
|
||||
|
||||
/** The percentiles to mark; on with p50/p90/p99 unless the spec turns them off. */
|
||||
export function resolveDistributionPercentiles(
|
||||
percentiles: DashboardtypesDistributionPercentilesDTO | undefined,
|
||||
): number[] {
|
||||
if (percentiles?.enabled === false) {
|
||||
return [];
|
||||
}
|
||||
return percentiles?.values ?? DEFAULT_DISTRIBUTION_PERCENTILES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket-axis spacing of a distribution; missing/unknown is `auto`. As on the
|
||||
* heatmap, `auto` over linear bucketing keeps evenly spaced bounds evenly spaced.
|
||||
*/
|
||||
export function resolveDistributionXScale(
|
||||
scale: DashboardtypesDistributionXScaleDTO | undefined,
|
||||
bucketKind?: Querybuildertypesv5BucketOptionsDTO['kind'],
|
||||
): DistributionXScale {
|
||||
const configured =
|
||||
scale && scale in DISTRIBUTION_X_SCALE_MAP
|
||||
? DISTRIBUTION_X_SCALE_MAP[scale]
|
||||
: DistributionXScale.Auto;
|
||||
|
||||
if (configured !== DistributionXScale.Auto) {
|
||||
return configured;
|
||||
}
|
||||
return bucketKind === Querybuildertypesv5BucketOptionsLinearDTOKind.linear
|
||||
? DistributionXScale.Linear
|
||||
: DistributionXScale.Auto;
|
||||
}
|
||||
|
||||
export function resolveDistributionYScale(
|
||||
scale: DashboardtypesDistributionYScaleDTO | undefined,
|
||||
): DistributionYScale {
|
||||
return scale && scale in DISTRIBUTION_Y_SCALE_MAP
|
||||
? DISTRIBUTION_Y_SCALE_MAP[scale]
|
||||
: DistributionYScale.Linear;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row-height distribution of a heatmap's bucket axis. Missing/unknown resolves to
|
||||
* `auto`, the one option that reads the bucket bounds rather than overriding them.
|
||||
|
||||
@@ -11,6 +11,7 @@ import { buildAggregateData } from '../buildAggregateData';
|
||||
import { stepClickTimeRange } from '../chartClickTimeRange';
|
||||
import { enrichChartClick } from '../enrichChartClick';
|
||||
import { enrichNumberClick } from '../enrichNumberClick';
|
||||
import { enrichDistributionClick } from '../enrichDistributionClick';
|
||||
import { enrichPieClick } from '../enrichPieClick';
|
||||
import { enrichTableClick } from '../enrichTableClick';
|
||||
import { getDataLinks } from '../getDataLinks';
|
||||
@@ -390,6 +391,53 @@ describe('enrichPieClick', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichDistributionClick', () => {
|
||||
const click = {
|
||||
series: {
|
||||
label: 'cart',
|
||||
groupLabels: { 'service.name': 'cart' },
|
||||
counts: [1, 2],
|
||||
},
|
||||
color: '#abc',
|
||||
coordinates: { x: 7, y: 8 },
|
||||
};
|
||||
|
||||
it("filters to the clicked group over the panel's whole window", () => {
|
||||
const payload = enrichDistributionClick({
|
||||
click,
|
||||
queryName: 'A',
|
||||
builderQueries: [
|
||||
builderQuery({
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
groupBy: [{ name: 'service.name' }],
|
||||
}),
|
||||
],
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
});
|
||||
|
||||
expect(payload).toStrictEqual({
|
||||
coordinates: { x: 7, y: 8 },
|
||||
context: {
|
||||
queryName: 'A',
|
||||
signal: 'metrics',
|
||||
filters: [
|
||||
{ filterKey: 'service.name', filterValue: 'cart', operator: '=' },
|
||||
],
|
||||
timeRange: { startTime: 1, endTime: 2 },
|
||||
label: 'cart',
|
||||
seriesColor: '#abc',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null without a drillable query', () => {
|
||||
expect(
|
||||
enrichDistributionClick({ click, queryName: '', builderQueries: [] }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePanelContextLinks', () => {
|
||||
it('substitutes the clicked field value (_-prefixed) into the label and URL', () => {
|
||||
const resolved = resolvePanelContextLinks(
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { isValidQueryName } from 'container/QueryTable/Drilldown/drilldownUtils';
|
||||
import type { DistributionBarClick } from 'lib/visualization/charts/Distribution/types';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
import type { DrilldownClickPayload } from '../../types/drilldown';
|
||||
|
||||
import { getGroupByFilters } from './getGroupByFilters';
|
||||
import { resolveDrilldownSignal } from './signal';
|
||||
|
||||
interface EnrichDistributionClickArgs {
|
||||
click: DistributionBarClick;
|
||||
queryName: string;
|
||||
builderQueries: BuilderQuery[];
|
||||
/** The fetched window: a bar sums the whole range, not one step. */
|
||||
timeRange?: { startTime: number; endTime: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a bar click into a drilldown payload, filtered to the clicked group. `null`
|
||||
* when the bar has no drillable query.
|
||||
*/
|
||||
export function enrichDistributionClick({
|
||||
click,
|
||||
queryName,
|
||||
builderQueries,
|
||||
timeRange,
|
||||
}: EnrichDistributionClickArgs): DrilldownClickPayload | null {
|
||||
if (!isValidQueryName(queryName)) {
|
||||
return null;
|
||||
}
|
||||
const builderQuery = builderQueries.find((query) => query.name === queryName);
|
||||
|
||||
return {
|
||||
coordinates: click.coordinates,
|
||||
context: {
|
||||
queryName,
|
||||
signal: resolveDrilldownSignal(builderQuery),
|
||||
filters: builderQuery
|
||||
? getGroupByFilters(click.series.groupLabels ?? {}, builderQuery)
|
||||
: [],
|
||||
timeRange,
|
||||
label: click.series.label,
|
||||
seriesColor: click.color,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
import { themeColors } from 'constants/theme';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { resolveSeriesColor } from 'lib/visualization/charts/Distribution/utils';
|
||||
import { prepareDistributionData } from '../kinds/DistributionPanel/prepareData';
|
||||
import { preparePieData } from '../kinds/PieChartPanel/prepareData';
|
||||
import { getColorGroupLabels } from '../kinds/ScatterPlotPanel/utils/prepareData';
|
||||
import { getJoinedScalarTable } from './getJoinedScalarTable';
|
||||
@@ -14,6 +16,7 @@ import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/quer
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getDistributionResults,
|
||||
getScalarResults,
|
||||
getTimeSeriesResults,
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
|
||||
@@ -59,6 +62,21 @@ function buildLegendSeries(
|
||||
}));
|
||||
}
|
||||
|
||||
/** One entry per group, labelled and coloured as the Distribution chart draws it. */
|
||||
export function resolveDistributionLegendSeries({
|
||||
data,
|
||||
isDarkMode,
|
||||
}: LegendSeriesArgs): LegendSeries[] {
|
||||
const { series } = prepareDistributionData({
|
||||
results: getDistributionResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
});
|
||||
return buildLegendSeries(
|
||||
series.map((entry) => entry.label),
|
||||
(label) => resolveSeriesColor(label, isDarkMode),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pie is fed by scalar results, not time series. Reuse the exact slices the renderer
|
||||
* draws (without overrides, so their colors are the defaults) so the color control keys
|
||||
|
||||
@@ -50,13 +50,18 @@ const PANEL_TYPE_META: Record<PanelKind, PanelTypeMeta> = {
|
||||
},
|
||||
'signoz/HistogramPanel': {
|
||||
group: 'distributions',
|
||||
description: 'Distribution of values into buckets',
|
||||
description: 'Bins query results in the browser',
|
||||
},
|
||||
'signoz/HeatmapPanel': {
|
||||
group: 'distributions',
|
||||
description: 'Bucket density over time',
|
||||
isNew: true,
|
||||
},
|
||||
'signoz/DistributionPanel': {
|
||||
group: 'distributions',
|
||||
description: 'Bucket counts over the time range',
|
||||
isNew: true,
|
||||
},
|
||||
'signoz/NumberPanel': {
|
||||
group: 'single',
|
||||
description: 'One aggregate value, large',
|
||||
|
||||
@@ -55,6 +55,23 @@ const HEATMAP_CELLS = [
|
||||
[0.4, 0.85, 0.7, 0.2],
|
||||
[0.3, 0.65, 0.9, 0.45],
|
||||
];
|
||||
// `[lower, upper]` heights (0–44) of two stacked groups per bucket, skewed right.
|
||||
const DISTRIBUTION_STACKS = [
|
||||
[6, 3],
|
||||
[14, 6],
|
||||
[26, 10],
|
||||
[20, 12],
|
||||
[12, 9],
|
||||
[7, 6],
|
||||
[4, 3],
|
||||
[2, 2],
|
||||
];
|
||||
const DISTRIBUTION_UPPER = { fill: 'var(--bg-robin-400)', fillOpacity: 0.55 };
|
||||
const PERCENTILE_MARKER = {
|
||||
stroke: 'var(--l1-foreground)',
|
||||
strokeOpacity: 0.6,
|
||||
};
|
||||
|
||||
const TEXT_ROWS = [45, 100, 92, 64].map((width, i) => [
|
||||
{ ...(i === 0 ? HEAD : CELL), width },
|
||||
]);
|
||||
@@ -113,6 +130,39 @@ export const PANEL_TYPE_PREVIEWS: Record<PanelKind, JSX.Element> = {
|
||||
)}
|
||||
</svg>
|
||||
),
|
||||
'signoz/DistributionPanel': (
|
||||
<svg viewBox="0 0 120 44" preserveAspectRatio="none" className={styles.svg}>
|
||||
{DISTRIBUTION_STACKS.map(([lower, upper], i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<g key={i}>
|
||||
<rect
|
||||
x={i * 15 + 1}
|
||||
y={44 - lower}
|
||||
width={13}
|
||||
height={lower}
|
||||
style={AREA_FILL}
|
||||
/>
|
||||
<rect
|
||||
x={i * 15 + 1}
|
||||
y={44 - lower - upper}
|
||||
width={13}
|
||||
height={upper}
|
||||
style={DISTRIBUTION_UPPER}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
<line
|
||||
x1={88}
|
||||
x2={88}
|
||||
y1={2}
|
||||
y2={44}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={PERCENTILE_MARKER}
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
'signoz/ScatterPlotPanel': (
|
||||
<svg viewBox="0 0 120 44" className={styles.svg}>
|
||||
{SCATTER_DOTS.map(([cx, cy, r]) => (
|
||||
|
||||
@@ -73,6 +73,7 @@ function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
|
||||
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
|
||||
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
|
||||
const panelTypeItems = usePanelTypeSelectItems({
|
||||
fromKind: panelKind,
|
||||
queryType: query?.queryType ?? EQueryType.QUERY_BUILDER,
|
||||
signal: query?.signal,
|
||||
});
|
||||
|
||||
@@ -112,6 +112,8 @@ export function panelTypeToRequestType(
|
||||
return Querybuildertypesv5RequestTypeDTO.raw;
|
||||
case PANEL_TYPES.HEATMAP:
|
||||
return Querybuildertypesv5RequestTypeDTO.heatmap;
|
||||
case PANEL_TYPES.DISTRIBUTION:
|
||||
return Querybuildertypesv5RequestTypeDTO.distribution;
|
||||
case PANEL_TYPES.TRACE:
|
||||
return Querybuildertypesv5RequestTypeDTO.trace;
|
||||
default:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Querybuildertypesv5AggregationBucketDTO,
|
||||
Querybuildertypesv5DistributionDataDTO,
|
||||
Querybuildertypesv5ExecStatsDTO,
|
||||
Querybuildertypesv5RawDataDTO,
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
@@ -36,6 +37,17 @@ export function getHeatmapResults(
|
||||
[]) as Querybuildertypesv5TimeSeriesDataDTO[];
|
||||
}
|
||||
|
||||
/** One summed count array per group, positional against each aggregation's `meta.buckets`. */
|
||||
export function getDistributionResults(
|
||||
response: QueryRangeV5200 | undefined,
|
||||
): Querybuildertypesv5DistributionDataDTO[] {
|
||||
if (response?.data?.type !== 'distribution') {
|
||||
return [];
|
||||
}
|
||||
return (response.data.data?.results ??
|
||||
[]) as Querybuildertypesv5DistributionDataDTO[];
|
||||
}
|
||||
|
||||
export function getScalarResults(
|
||||
response: QueryRangeV5200 | undefined,
|
||||
): Querybuildertypesv5ScalarDataDTO[] {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { rest } from 'msw';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { buildRoutePath } from 'lib/router/buildRoutePath';
|
||||
import {
|
||||
DashboardtypesDistributionXScaleDTO as DistributionXScale,
|
||||
DashboardtypesHeatmapPaletteDTO as HeatmapPalette,
|
||||
DashboardtypesHeatmapYScaleDTO as HeatmapYScale,
|
||||
DashboardtypesLegendPositionDTO as LegendPosition,
|
||||
@@ -27,6 +28,8 @@ import { publicDashboardMeta } from '../__story_mockdata__/publicDashboard';
|
||||
|
||||
import {
|
||||
BUCKET_WIDTHS,
|
||||
DISTRIBUTION_OVERFLOWS,
|
||||
DISTRIBUTION_X_SCALES,
|
||||
GAPS,
|
||||
HEATMAP_COUNT_RANGES,
|
||||
HEATMAP_FIXED_COUNTS,
|
||||
@@ -34,7 +37,7 @@ import {
|
||||
HEATMAP_Y_SCALES,
|
||||
LEGEND_POSITIONS,
|
||||
LINE_STYLES,
|
||||
MAX_SERIES,
|
||||
MAX_SERIES_CONTROL,
|
||||
PANEL_KINDS,
|
||||
PANEL_RESULTS,
|
||||
PANELS_DASHBOARD_ID,
|
||||
@@ -57,6 +60,7 @@ import {
|
||||
} from './__story_mockdata__/panelResponses';
|
||||
import type {
|
||||
BucketWidth,
|
||||
DistributionOverflow,
|
||||
Gaps,
|
||||
HeatmapCountRange,
|
||||
PanelArgs,
|
||||
@@ -109,7 +113,7 @@ export const panelsMocks = defineStoryMocks({
|
||||
stack: choiceControl<StackMode>('Stack', {
|
||||
group: PANEL,
|
||||
description:
|
||||
'Area stacks `normal` or to `percent`; Bar stacks on anything but `none`. No effect on other kinds.',
|
||||
'Area and Distribution stack `normal` or to `percent`, and Distribution draws `none` side by side; Bar stacks on anything but `none`. No effect on other kinds.',
|
||||
options: STACK_MODES,
|
||||
value: StackMode.none,
|
||||
}),
|
||||
@@ -131,6 +135,22 @@ export const panelsMocks = defineStoryMocks({
|
||||
options: HEATMAP_COUNT_RANGES,
|
||||
value: 'auto',
|
||||
}),
|
||||
distributionXScale: choiceControl<DistributionXScale>(
|
||||
'Distribution x scale',
|
||||
{
|
||||
group: PANEL,
|
||||
description:
|
||||
'Spacing of the bucket axis. The bounds are exponential, so `auto` lays them out on log; `ordinal` makes every bucket equally wide.',
|
||||
options: DISTRIBUTION_X_SCALES,
|
||||
value: DistributionXScale.auto,
|
||||
},
|
||||
),
|
||||
percentiles: toggleControl('Distribution percentiles', {
|
||||
group: PANEL,
|
||||
description:
|
||||
'Dashed p50, p90 and p99 markers across the Distribution panel.',
|
||||
value: true,
|
||||
}),
|
||||
bucketWidth: choiceControl<BucketWidth>('Histogram bucket width', {
|
||||
group: PANEL,
|
||||
description:
|
||||
@@ -158,7 +178,8 @@ export const panelsMocks = defineStoryMocks({
|
||||
}),
|
||||
yScale: choiceControl<YScale>('Y scale', {
|
||||
group: PANEL,
|
||||
description: 'The y axis of Time Series, Bar and Area.',
|
||||
description:
|
||||
'The y axis of Time Series, Bar and Area, and the count axis of Distribution.',
|
||||
options: Y_SCALES,
|
||||
value: 'linear',
|
||||
}),
|
||||
@@ -195,9 +216,9 @@ export const panelsMocks = defineStoryMocks({
|
||||
series: countControl('Series', {
|
||||
group: DATA,
|
||||
description:
|
||||
'Series on the charts, groups on the heatmap, slices on the pie, rows on the table.',
|
||||
'Series on the charts, groups on the heatmap and distribution, slices on the pie, rows on the table. A stacked distribution draws 10 and sums the rest into Other.',
|
||||
value: 4,
|
||||
max: MAX_SERIES,
|
||||
max: MAX_SERIES_CONTROL,
|
||||
}),
|
||||
gaps: choiceControl<Gaps>('Gaps', {
|
||||
group: DATA,
|
||||
@@ -206,6 +227,13 @@ export const panelsMocks = defineStoryMocks({
|
||||
options: GAPS,
|
||||
value: 'none',
|
||||
}),
|
||||
overflow: choiceControl<DistributionOverflow>('Distribution overflow', {
|
||||
group: DATA,
|
||||
description:
|
||||
'Observations past the last bucket bound: `low` 2%, `high` 25%, which the panel header flags.',
|
||||
options: DISTRIBUTION_OVERFLOWS,
|
||||
value: 'low',
|
||||
}),
|
||||
listRows: countControl('List rows', {
|
||||
group: DATA,
|
||||
description:
|
||||
@@ -260,6 +288,7 @@ export const panelsMocks = defineStoryMocks({
|
||||
series: values.series,
|
||||
listRows: values.listRows,
|
||||
gaps: values.gaps,
|
||||
overflow: values.overflow,
|
||||
});
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import {
|
||||
DashboardtypesDistributionXScaleDTO as DistributionXScale,
|
||||
DashboardtypesHeatmapYScaleDTO as HeatmapYScale,
|
||||
DashboardtypesLegendPositionDTO as LegendPosition,
|
||||
DashboardtypesLineStyleDTO as LineStyleDTO,
|
||||
@@ -118,6 +119,62 @@ export const HeatmapCountRange: Story = {
|
||||
play,
|
||||
};
|
||||
|
||||
/**
|
||||
* Latency bucket counts over the whole range, one stacked bar per bucket on a log
|
||||
* bucket axis, with p50, p90 and p99 marked and the overflow bucket hatched.
|
||||
*/
|
||||
export const Distribution: Story = {
|
||||
args: { kind: 'Distribution', stack: StackMode.normal },
|
||||
play,
|
||||
};
|
||||
|
||||
/** Each bucket split between the services, their bars side by side. */
|
||||
export const DistributionSideBySide: Story = {
|
||||
args: { kind: 'Distribution', stack: StackMode.none },
|
||||
play,
|
||||
};
|
||||
|
||||
/** Each bucket stacked to 100%, every band its service's share of that bucket. */
|
||||
export const DistributionPercent: Story = {
|
||||
args: { kind: 'Distribution', stack: StackMode.percent },
|
||||
play,
|
||||
};
|
||||
|
||||
/** Every bucket equally wide, whatever its range, counts on a log axis. */
|
||||
export const DistributionOrdinalLog: Story = {
|
||||
args: {
|
||||
kind: 'Distribution',
|
||||
stack: StackMode.normal,
|
||||
distributionXScale: DistributionXScale.ordinal,
|
||||
yScale: 'log',
|
||||
},
|
||||
play,
|
||||
};
|
||||
|
||||
/** Fourteen services: the top nine drawn, the other five summed into Other. */
|
||||
export const DistributionOther: Story = {
|
||||
args: { kind: 'Distribution', stack: StackMode.normal, series: 14 },
|
||||
play,
|
||||
};
|
||||
|
||||
/**
|
||||
* Fourteen services side by side: as many as keep every bar 3 px wide in the
|
||||
* narrowest bucket are drawn, and the rest summed into Other.
|
||||
*/
|
||||
export const DistributionSideBySideOther: Story = {
|
||||
args: { kind: 'Distribution', stack: StackMode.none, series: 14 },
|
||||
play,
|
||||
};
|
||||
|
||||
/**
|
||||
* A quarter of observations past the last bound: the overflow bar dominates, p99
|
||||
* reads `> 5 s`, and the header warns that the bounds may be too low.
|
||||
*/
|
||||
export const DistributionOverflow: Story = {
|
||||
args: { kind: 'Distribution', stack: StackMode.normal, overflow: 'high' },
|
||||
play,
|
||||
};
|
||||
|
||||
/** Each service's share of traffic as a donut, with the total in the middle. */
|
||||
export const Pie: Story = {
|
||||
args: { kind: 'Pie' },
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
DashboardtypesDistributionXScaleDTO as DistributionXScale,
|
||||
DashboardtypesHeatmapPaletteDTO as HeatmapPalette,
|
||||
DashboardtypesHeatmapYScaleDTO as HeatmapYScale,
|
||||
DashboardtypesLegendPositionDTO as LegendPosition,
|
||||
@@ -24,6 +25,7 @@ export const PANEL_KINDS = [
|
||||
'Area',
|
||||
'Histogram',
|
||||
'Heatmap',
|
||||
'Distribution',
|
||||
'Pie',
|
||||
'Number',
|
||||
'Table',
|
||||
@@ -78,6 +80,7 @@ export const PANEL_NAME: Record<PanelKind, string> = {
|
||||
Area: 'Request rate by service',
|
||||
Histogram: 'Latency distribution',
|
||||
Heatmap: 'Latency heatmap',
|
||||
Distribution: 'Latency buckets',
|
||||
Pie: 'Traffic share',
|
||||
Number: 'p99 latency',
|
||||
Table: 'Calls by service',
|
||||
@@ -117,6 +120,19 @@ export const HEATMAP_BUCKETS = [
|
||||
|
||||
export const HEATMAP_COLUMNS = 30;
|
||||
|
||||
/** Exponential, as a latency histogram's `le` bounds are, so `auto` picks log. */
|
||||
export const DISTRIBUTION_BUCKETS = [
|
||||
5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000,
|
||||
];
|
||||
|
||||
export const DISTRIBUTION_X_SCALES = Object.values(DistributionXScale);
|
||||
|
||||
/** `high` puts a quarter of each group past the last bound, over the warning's 10%. */
|
||||
export const DISTRIBUTION_OVERFLOWS = ['low', 'high'] as const;
|
||||
|
||||
/** Groups the series control allows: past the 10 a stacked distribution draws. */
|
||||
export const MAX_SERIES_CONTROL = 14;
|
||||
|
||||
export const SERVICES = [
|
||||
'checkout',
|
||||
'payments',
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import {
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpecDTOKind as AreaChartKind,
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTOKind as BarChartKind,
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind as DistributionKind,
|
||||
DashboardtypesDistributionYScaleDTO as DistributionYScale,
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind as HeatmapKind,
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTOKind as HistogramKind,
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTOKind as ListKind,
|
||||
@@ -131,6 +133,35 @@ export const panelShape = (args: PanelArgs): PanelShape => {
|
||||
metricQuery(RequestType.heatmap, { grouped: true, heatmap: true }),
|
||||
],
|
||||
};
|
||||
case 'Distribution':
|
||||
return {
|
||||
plugin: {
|
||||
kind: DistributionKind['signoz/DistributionPanel'],
|
||||
spec: {
|
||||
formatting,
|
||||
legend,
|
||||
axes: {
|
||||
x: { scale: args.distributionXScale },
|
||||
y: {
|
||||
scale:
|
||||
args.yScale === 'log'
|
||||
? DistributionYScale.log
|
||||
: DistributionYScale.linear,
|
||||
},
|
||||
},
|
||||
chartAppearance: {
|
||||
stack: args.stack,
|
||||
percentiles: { enabled: args.percentiles, values: [50, 90, 99] },
|
||||
},
|
||||
},
|
||||
},
|
||||
queries: [
|
||||
metricQuery(RequestType.distribution, {
|
||||
grouped: true,
|
||||
histogram: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
case 'Pie':
|
||||
return {
|
||||
plugin: {
|
||||
|
||||
@@ -89,6 +89,8 @@ export const TEXT_BODIES: Record<TextSample, string> = {
|
||||
interface MetricQueryOptions {
|
||||
grouped: boolean;
|
||||
heatmap?: boolean;
|
||||
/** A histogram metric, whose `le` bounds are the bucket axis: no bucket options. */
|
||||
histogram?: boolean;
|
||||
name?: string;
|
||||
metricName?: string;
|
||||
}
|
||||
@@ -98,6 +100,7 @@ export const metricQuery = (
|
||||
{
|
||||
grouped,
|
||||
heatmap,
|
||||
histogram,
|
||||
name = QUERY_NAME,
|
||||
metricName = 'signoz_latency',
|
||||
}: MetricQueryOptions,
|
||||
@@ -107,9 +110,10 @@ export const metricQuery = (
|
||||
signal: MetricSignal.metrics,
|
||||
aggregations: [
|
||||
{
|
||||
metricName: heatmap ? `${metricName}_bucket` : metricName,
|
||||
spaceAggregation: heatmap ? undefined : SpaceAggregation.p99,
|
||||
timeAggregation: heatmap ? TimeAggregation.increase : TimeAggregation.avg,
|
||||
metricName: heatmap || histogram ? `${metricName}_bucket` : metricName,
|
||||
spaceAggregation: heatmap || histogram ? undefined : SpaceAggregation.p99,
|
||||
timeAggregation:
|
||||
heatmap || histogram ? TimeAggregation.increase : TimeAggregation.avg,
|
||||
},
|
||||
],
|
||||
groupBy: grouped ? [{ name: 'service.name' }] : undefined,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Warning } from 'types/api';
|
||||
import type { MetricRangePayloadV5, RawRow } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
queryRangeV5DistributionResponse,
|
||||
queryRangeV5EmptyResponse,
|
||||
queryRangeV5HeatmapResponse,
|
||||
queryRangeV5RawResponse,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
} from '../../__story_mockdata__/panelData';
|
||||
|
||||
import {
|
||||
DISTRIBUTION_BUCKETS,
|
||||
HEATMAP_BUCKETS,
|
||||
HEATMAP_COLUMNS,
|
||||
HOLE,
|
||||
@@ -32,7 +34,12 @@ import {
|
||||
SERVICES,
|
||||
TABLE_SECOND_COLUMN,
|
||||
} from './constants';
|
||||
import type { Gaps, PanelRequest, PanelResponseArgs } from './types';
|
||||
import type {
|
||||
DistributionOverflow,
|
||||
Gaps,
|
||||
PanelRequest,
|
||||
PanelResponseArgs,
|
||||
} from './types';
|
||||
|
||||
export const serviceAt = (index: number): string =>
|
||||
index < SERVICES.length
|
||||
@@ -144,6 +151,38 @@ const heatmapResponse = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* A latency-shaped hump per group, peaking a bucket further right every third
|
||||
* group, plus the overflow count `overflow` asks for.
|
||||
*/
|
||||
export const distributionCounts = (
|
||||
group: number,
|
||||
overflow: DistributionOverflow,
|
||||
): number[] => {
|
||||
const peak = 3 + (group % 3);
|
||||
const scale = 1 + (group % 4) * 0.4;
|
||||
const finite = DISTRIBUTION_BUCKETS.map((_bound, index) =>
|
||||
Math.round(scale * 120 * Math.exp(-((index - peak) ** 2) / 3)),
|
||||
);
|
||||
const total = finite.reduce((sum, count) => sum + count, 0);
|
||||
const share = overflow === 'high' ? 0.25 : 0.02;
|
||||
return [...finite, Math.round((total * share) / (1 - share))];
|
||||
};
|
||||
|
||||
const distributionResponse = (
|
||||
count: number,
|
||||
overflow: DistributionOverflow,
|
||||
warning: Warning | undefined,
|
||||
): QueryRangeV5200 =>
|
||||
queryRangeV5DistributionResponse({
|
||||
buckets: DISTRIBUTION_BUCKETS,
|
||||
warning,
|
||||
distributions: Array.from({ length: count }, (_unused, group) => ({
|
||||
labels: [{ key: { name: 'service.name' }, value: serviceAt(group) }],
|
||||
counts: distributionCounts(group, overflow),
|
||||
})),
|
||||
});
|
||||
|
||||
/** Spaced by the window, newest first, as the `timestamp desc` order asks for. */
|
||||
const logRows = (
|
||||
{ start, end }: PanelWindow,
|
||||
@@ -180,6 +219,7 @@ export const panelQueryResponse = ({
|
||||
series,
|
||||
listRows,
|
||||
gaps,
|
||||
overflow,
|
||||
window,
|
||||
offset,
|
||||
limit,
|
||||
@@ -193,6 +233,8 @@ export const panelQueryResponse = ({
|
||||
switch (kind) {
|
||||
case 'Heatmap':
|
||||
return heatmapResponse(window, series, warning);
|
||||
case 'Distribution':
|
||||
return distributionResponse(series, overflow, warning);
|
||||
case 'Pie':
|
||||
return queryRangeV5ScalarTableResponse({
|
||||
groupBy: ['service.name'],
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
DashboardtypesDistributionXScaleDTO as DistributionXScale,
|
||||
DashboardtypesHeatmapPaletteDTO as HeatmapPalette,
|
||||
DashboardtypesHeatmapYScaleDTO as HeatmapYScale,
|
||||
DashboardtypesLegendPositionDTO as LegendPosition,
|
||||
@@ -16,6 +17,7 @@ import type { PanelWindow } from '../../__story_mockdata__/panelData';
|
||||
|
||||
import type {
|
||||
BUCKET_WIDTHS,
|
||||
DISTRIBUTION_OVERFLOWS,
|
||||
GAPS,
|
||||
HEATMAP_COUNT_RANGES,
|
||||
PANEL_KINDS,
|
||||
@@ -44,6 +46,8 @@ export type Gaps = (typeof GAPS)[number];
|
||||
|
||||
export type SpanGaps = (typeof SPAN_GAPS)[number];
|
||||
|
||||
export type DistributionOverflow = (typeof DISTRIBUTION_OVERFLOWS)[number];
|
||||
|
||||
/** The controls that shape the dashboard document. */
|
||||
export interface PanelArgs {
|
||||
kind: PanelKind;
|
||||
@@ -63,6 +67,8 @@ export interface PanelArgs {
|
||||
showPoints: boolean;
|
||||
spanGaps: SpanGaps;
|
||||
columnUnits: boolean;
|
||||
distributionXScale: DistributionXScale;
|
||||
percentiles: boolean;
|
||||
}
|
||||
|
||||
/** What one `query_range` call asked for, as the handler reads it. */
|
||||
@@ -78,4 +84,5 @@ export interface PanelResponseArgs extends PanelRequest {
|
||||
series: number;
|
||||
listRows: number;
|
||||
gaps: Gaps;
|
||||
overflow: DistributionOverflow;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { DashboardtypesHeatmapYScaleDTO as HeatmapYScale } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesDistributionXScaleDTO as DistributionXScale,
|
||||
DashboardtypesHeatmapYScaleDTO as HeatmapYScale,
|
||||
DashboardtypesStackModeDTO as StackMode,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { MAX_STACKED_GROUPS } from 'lib/visualization/charts/Distribution/topGroups';
|
||||
import { getPaletteStops } from 'lib/uPlotV2/plugins/HeatmapPlugin/palettes';
|
||||
import { HEATMAP_PALETTE_MAP } from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/enumMaps';
|
||||
import { expect, waitFor, within } from 'storybook/test';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
DISTRIBUTION_BUCKETS,
|
||||
HEATMAP_BUCKETS,
|
||||
HEATMAP_FIXED_COUNTS,
|
||||
} from '../__story_mockdata__/constants';
|
||||
@@ -89,3 +95,57 @@ export const expectHeatmap = async (
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const expectDistribution = async (
|
||||
root: HTMLElement,
|
||||
args: PanelPlayArgs,
|
||||
): Promise<void> => {
|
||||
const isStacked = args.stack !== StackMode.none;
|
||||
let plot: uPlot | undefined;
|
||||
await waitFor(async () => {
|
||||
plot = findPlot(root);
|
||||
await expect(plot).toBeDefined();
|
||||
}, PANEL_LOAD);
|
||||
const drawn = plot as uPlot;
|
||||
|
||||
// Side by side fits as many groups as the panel's width allows, never more than a
|
||||
// stack, so only a stack has an exact count to hold it to.
|
||||
const notice = within(root).queryByTestId('distribution-other-notice');
|
||||
if (args.series > MAX_STACKED_GROUPS) {
|
||||
await expect(notice).toHaveTextContent(
|
||||
isStacked
|
||||
? `Showing top ${MAX_STACKED_GROUPS - 1} of ${args.series} groups`
|
||||
: `of ${args.series} groups; the rest are summed into Other.`,
|
||||
);
|
||||
} else if (isStacked) {
|
||||
await expect(notice).not.toBeInTheDocument();
|
||||
}
|
||||
if (isStacked) {
|
||||
await expect(drawn.series.length - 1).toBe(
|
||||
Math.min(args.series, MAX_STACKED_GROUPS),
|
||||
);
|
||||
} else {
|
||||
await expect(drawn.series.length - 1).toBeLessThanOrEqual(MAX_STACKED_GROUPS);
|
||||
}
|
||||
|
||||
const xMax = drawn.scales.x.max ?? 0;
|
||||
const lastBound = DISTRIBUTION_BUCKETS[DISTRIBUTION_BUCKETS.length - 1];
|
||||
switch (args.distributionXScale) {
|
||||
case DistributionXScale.ordinal:
|
||||
// One slot a bucket: the bounds plus underflow and overflow.
|
||||
await expect(xMax).toBe(DISTRIBUTION_BUCKETS.length + 1);
|
||||
break;
|
||||
case DistributionXScale.linear:
|
||||
await expect(xMax).toBeGreaterThan(lastBound);
|
||||
break;
|
||||
default:
|
||||
// The exponential bounds put auto on log10 of them, as log and symlog are.
|
||||
await expect(xMax).toBeLessThan(10);
|
||||
}
|
||||
|
||||
if (args.stack === StackMode.percent) {
|
||||
await expect(drawn.scales.y.max ?? 0).toBeGreaterThanOrEqual(100);
|
||||
} else {
|
||||
await expect(drawn.scales.y.distr).toBe(args.yScale === 'log' ? 3 : 1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,7 +3,11 @@ import { expect, within } from 'storybook/test';
|
||||
import { PANEL_NAME } from '../__story_mockdata__/constants';
|
||||
|
||||
import { hasData, PANEL_LOAD, type PanelPlayArgs } from './args';
|
||||
import { expectHeatmap, expectHistogram } from './distributions';
|
||||
import {
|
||||
expectDistribution,
|
||||
expectHeatmap,
|
||||
expectHistogram,
|
||||
} from './distributions';
|
||||
import { expectLineChart } from './lineChart';
|
||||
import { expectListPage } from './list';
|
||||
import { findPlot } from './plot';
|
||||
@@ -31,6 +35,7 @@ const KIND_EXPECTATIONS: Record<
|
||||
Area: expectLineChart,
|
||||
Histogram: expectHistogram,
|
||||
Heatmap: expectHeatmap,
|
||||
Distribution: expectDistribution,
|
||||
Pie: expectPie,
|
||||
Number: expectNumber,
|
||||
Table: expectTable,
|
||||
@@ -73,4 +78,12 @@ export const expectPanel = async (
|
||||
within(root).queryByTestId('panel-status-warning'),
|
||||
).not.toBeInTheDocument();
|
||||
}
|
||||
|
||||
// The panel's own read of the data, apart from the response's warning.
|
||||
const dataWarning = within(root).queryByTestId('panel-status-data-warning');
|
||||
if (args.kind === 'Distribution' && args.overflow === 'high') {
|
||||
await expect(dataWarning).toBeInTheDocument();
|
||||
} else if (args.kind === 'Distribution') {
|
||||
await expect(dataWarning).not.toBeInTheDocument();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
type QueryRangeV5200,
|
||||
type Querybuildertypesv5DistributionDTO,
|
||||
type Querybuildertypesv5TimeSeriesDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { Warning } from 'types/api';
|
||||
@@ -517,3 +518,34 @@ export const queryRangeV5HeatmapResponse = ({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
interface DistributionResponseSpec {
|
||||
/** Ascending bucket upper bounds; each distribution carries one count per bound plus the overflow. */
|
||||
buckets: number[];
|
||||
distributions: Querybuildertypesv5DistributionDTO[];
|
||||
queryName?: string;
|
||||
warning?: Warning;
|
||||
}
|
||||
|
||||
/** A distribution panel's answer: per-group counts summed over the range. */
|
||||
export const queryRangeV5DistributionResponse = ({
|
||||
buckets,
|
||||
distributions,
|
||||
queryName = 'A',
|
||||
warning,
|
||||
}: DistributionResponseSpec): QueryRangeV5200 => ({
|
||||
status: 'success',
|
||||
data: {
|
||||
warning,
|
||||
type: Querybuildertypesv5RequestTypeDTO.distribution,
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName,
|
||||
aggregations: [{ index: 0, meta: { buckets }, distributions }],
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -77,6 +77,7 @@ export const panelTypeToExplorerView: Record<PANEL_TYPES, ExplorerViews> = {
|
||||
[PANEL_TYPES.PIE]: ExplorerViews.TIMESERIES,
|
||||
[PANEL_TYPES.HISTOGRAM]: ExplorerViews.TIMESERIES,
|
||||
[PANEL_TYPES.HEATMAP]: ExplorerViews.TIMESERIES,
|
||||
[PANEL_TYPES.DISTRIBUTION]: ExplorerViews.TIMESERIES,
|
||||
// Dashboard-only visualisation; explorers never offer it.
|
||||
[PANEL_TYPES.TEXT]: ExplorerViews.LIST,
|
||||
[PANEL_TYPES.SCATTER]: ExplorerViews.TABLE,
|
||||
|
||||
@@ -481,8 +481,7 @@ func (bc *bucketCache) mergeBuckets(ctx context.Context, buckets []*qbtypes.Cach
|
||||
|
||||
// Merge values based on type
|
||||
var mergedValue any
|
||||
switch resultType {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
if resultType.IsBuiltFromTimeSeries() {
|
||||
mergedValue = bc.mergeTimeSeriesValues(ctx, buckets)
|
||||
// Raw and Scalar types are not cached, so no merge needed
|
||||
}
|
||||
@@ -629,8 +628,8 @@ func (bc *bucketCache) isEmptyResult(result *qbtypes.Result) (isEmpty bool, isFi
|
||||
return true, false
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
switch {
|
||||
case result.Type.IsBuiltFromTimeSeries():
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
// No aggregations at all means truly empty
|
||||
if len(tsData.Aggregations) == 0 {
|
||||
@@ -665,7 +664,7 @@ func (bc *bucketCache) isEmptyResult(result *qbtypes.Result) (isEmpty bool, isFi
|
||||
return !hasValues, !hasValues && totalSeries > 0
|
||||
}
|
||||
|
||||
case qbtypes.RequestTypeRaw, qbtypes.RequestTypeScalar, qbtypes.RequestTypeTrace:
|
||||
default:
|
||||
// Raw and scalar data are not cached
|
||||
return true, false
|
||||
}
|
||||
@@ -768,8 +767,8 @@ func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, fluxBoun
|
||||
Warnings: result.Warnings,
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
switch {
|
||||
case result.Type.IsBuiltFromTimeSeries():
|
||||
// Trim time series data
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok && tsData != nil {
|
||||
trimmedData := &qbtypes.TimeSeriesData{}
|
||||
@@ -812,7 +811,7 @@ func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, fluxBoun
|
||||
trimmedResult.Value = trimmedData
|
||||
}
|
||||
|
||||
case qbtypes.RequestTypeRaw, qbtypes.RequestTypeScalar, qbtypes.RequestTypeTrace:
|
||||
default:
|
||||
// Don't cache raw or scalar data
|
||||
return nil
|
||||
}
|
||||
@@ -854,8 +853,8 @@ func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, q qbtypes
|
||||
}
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
switch {
|
||||
case result.Type.IsBuiltFromTimeSeries():
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
filteredData := &qbtypes.TimeSeriesData{
|
||||
Aggregations: make([]*qbtypes.AggregationBucket, 0, len(tsData.Aggregations)),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user