Compare commits

..

26 Commits

Author SHA1 Message Date
Abhi Kumar
95a32e356f Merge branch 'feat/distribution-chart' into feat/distribution-panel-kind 2026-11-12 11:51:02 +05:30
Abhi Kumar
a1b97a1404 test(dashboard): find the distribution tooltip heading by test id 2026-11-12 11:50:49 +05:30
Abhi Kumar
1b37430401 refactor(dashboard): move the distribution bar click into a hook 2026-11-12 11:50:44 +05:30
Abhi Kumar
0d69faf6ae fix(uplot): hatch only overflow bars when both fills share a colour 2026-11-12 11:49:54 +05:30
Abhi Kumar
ce18da3cab test(dashboard): keep the distribution tooltip play passing for every control 2026-11-12 11:48:44 +05:30
Abhi Kumar
88050d6a7a test(dashboard): type distribution panel fixtures and use the test harness 2026-11-12 11:48:37 +05:30
Abhi Kumar
2fefdb57c7 Merge branch 'feat/distribution-chart' into feat/distribution-panel-kind 2026-11-12 11:44:03 +05:30
Abhi Kumar
4f802cd077 test(dashboard): type distribution chart fakes and drive the legend by test id 2026-11-12 11:43:49 +05:30
Abhi Kumar
5a491bd258 fix(dashboard): size the distribution count axis by the buckets in view 2026-11-12 11:42:14 +05:30
Abhi Kumar
51b6570221 Merge branch 'feat/distribution-panel-spec' into feat/distribution-chart 2026-11-12 11:41:45 +05:30
Abhi Kumar
fd6dca4658 refactor(dashboard): name distribution spec receivers by role 2026-11-12 11:41:33 +05:30
Abhi Kumar
3db01e3c9a test(dashboard): add distribution panel stories 2026-11-12 11:34:56 +05:30
Abhi Kumar
c2da09da4f feat(dashboard): point metric histograms at the distribution panel 2026-11-12 11:34:56 +05:30
Abhi Kumar
68945c82e5 feat(dashboard): keep histogram and distribution panels from switching into each other
Histogram bins raw values; Distribution reads histogram buckets. Neither query converts.
2026-11-12 11:34:56 +05:30
Abhi Kumar
59c57f5d08 feat(dashboard): add distribution editor controls 2026-11-12 11:34:56 +05:30
Abhi Kumar
2995e99439 feat(dashboard): add the distribution panel kind
Regenerates the API types here: the new kind widens the panel kind union, which the
exhaustive panel registry only satisfies once the kind is registered.
2026-11-12 11:34:56 +05:30
Abhi Kumar
f1612294d6 feat(dashboard): read distribution query results 2026-11-12 11:34:56 +05:30
Abhi Kumar
dd4de5560e feat(query-builder): treat distribution like heatmap for bucket options 2026-11-12 11:34:56 +05:30
Abhi Kumar
d8d64c5c1f fix(dashboard): turn off drag-to-zoom on the distribution chart 2026-11-12 11:34:42 +05:30
Abhi Kumar
02ed7d6a9c feat(dashboard): add the distribution chart 2026-11-12 11:21:21 +05:30
Abhi Kumar
f5b1d71675 feat(dashboard): add the distribution tooltip 2026-11-12 11:21:17 +05:30
Abhi Kumar
eb92d8dee0 feat(dashboard): lay out distribution buckets, groups and percentiles 2026-11-12 11:21:07 +05:30
Abhi Kumar
083500731e feat(uplot): add a variable-width bars path builder 2026-11-12 11:21:01 +05:30
Abhi Kumar
6cc664d954 chore(jest): stop ignoring every test path containing "dist" 2026-11-12 11:20:53 +05:30
Abhi Kumar
dfb906fe0a chore: regenerate openapi specs 2026-11-12 11:20:45 +05:30
Abhi Kumar
4b6f040e8a feat(dashboard): add distribution panel spec 2026-11-12 11:19:40 +05:30
164 changed files with 6114 additions and 3101 deletions

9
.github/CODEOWNERS vendored
View File

@@ -209,10 +209,6 @@ go.mod @therealpandey
/frontend/src/types/api/alerts/ @SigNoz/pulse-frontend
/frontend/src/providers/Alert.tsx @SigNoz/pulse-frontend
/frontend/src/constants/alerts.ts @SigNoz/pulse-frontend
/frontend/src/container/PlannedDowntime/ @SigNoz/pulse-frontend
/frontend/src/hooks/alerts/ @SigNoz/pulse-frontend
/frontend/src/container/Home/AlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/Home/__tests__/AlertRules.test.tsx @SigNoz/pulse-frontend
## Notification Channels
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
@@ -221,11 +217,6 @@ go.mod @therealpandey
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
## List filter + views (dashboards and alerts lists)
/frontend/src/components/ListFilter/ @SigNoz/pulse-frontend
/frontend/src/components/ListViews/ @SigNoz/pulse-frontend
/frontend/src/hooks/listViews/ @SigNoz/pulse-frontend
## OpenAPI Schema - Generated
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv

View File

@@ -63,7 +63,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
GO_NAME: signoz-community
GO_INPUT_ARTIFACT_CACHE_KEY: community-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build

View File

@@ -89,7 +89,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
GO_INPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./cmd/enterprise

View File

@@ -95,7 +95,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
GO_INPUT_ARTIFACT_CACHE_KEY: staging-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./cmd/enterprise

View File

@@ -22,7 +22,7 @@ jobs:
with:
PRIMUS_REF: main
GO_TEST_CONTEXT: ./...
GO_VERSION: 1.25
GO_VERSION: 1.24
fmt:
if: |
github.event_name == 'merge_group' ||
@@ -32,7 +32,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
lint:
if: |
github.event_name == 'merge_group' ||
@@ -42,7 +42,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
deps:
if: |
github.event_name == 'merge_group' ||
@@ -52,7 +52,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
semconv-generated:
if: |
github.event_name == 'merge_group' ||
@@ -65,7 +65,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: check-semconv-generated-files
run: make semconv-check
build:
@@ -80,7 +80,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: qemu-install
uses: docker/setup-qemu-action@v3
- name: aarch64-install
@@ -116,7 +116,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: generate-openapi
run: |
go run cmd/enterprise/*.go generate openapi
@@ -133,7 +133,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: generate-authz
run: |
go run cmd/enterprise/*.go generate authz
@@ -150,7 +150,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: generate-web-settings
run: |
go run cmd/enterprise/*.go generate config web-settings
@@ -167,7 +167,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: generate-transaction-groups
run: |
go run cmd/enterprise/*.go generate config transaction-groups

View File

@@ -66,7 +66,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: cross-compilation-tools
if: matrix.os == 'ubuntu-latest'
run: |
@@ -130,7 +130,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
# copy the caches from build
- name: get-sha

View File

@@ -75,7 +75,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: cross-compilation-tools
if: matrix.os == 'ubuntu-latest'
run: |
@@ -138,7 +138,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
# copy the caches from build
- name: get-sha

View File

@@ -4526,6 +4526,99 @@ 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) axis range and Y (count) axis scale.
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:
max:
description: Buckets wholly above this value are hidden. Null shows them
all.
nullable: true
type: number
min:
description: Buckets wholly below this value are hidden. Null shows them
all.
nullable: true
type: number
type: object
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
@@ -5296,6 +5389,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'
@@ -5318,6 +5412,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -5332,6 +5427,7 @@ components:
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
- signoz/DistributionPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -5363,6 +5459,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:
@@ -5516,9 +5627,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,
@@ -5654,8 +5765,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'
@@ -5812,9 +5923,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
@@ -10231,10 +10342,8 @@ components:
having:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5Having'
description: A filter on the formula values, referencing the formula by
its name or as __result, for example F1 > 0. A time series keeps only
the points that match and drops a series with none left; a scalar result
keeps only the rows that match.
description: Accepted but currently ignored; the computed values are returned
unfiltered.
legend:
description: The display name template for each series; {{field}} placeholders
are replaced by label values.
@@ -32731,305 +32840,6 @@ paths:
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_anomaly_request_rate:
description: 'anomaly is the one function with named arguments. seasonality
says which past periods predict the current value: hourly, daily
or weekly, daily when absent. z_score_threshold is how many standard
deviations from the prediction count as an anomaly. It has no default:
a request without it gets bounds of zero width, so send it. 3 is
the value the UI sends. The response carries, next to the values,
a predicted series, an upper and a lower bound series and an anomaly
score series. The scoring runs in the enterprise build; the community
build returns the series unchanged.'
summary: 'anomaly: request rate scored against its daily pattern'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: http.server.duration.count
spaceAggregation: sum
timeAggregation: rate
functions:
- args:
- name: seasonality
value: daily
- name: z_score_threshold
value: 3
name: anomaly
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: metrics
stepInterval: 300s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_cumulativesum_errors_so_far:
description: cumulativeSum replaces each point with the sum of every
point up to and including it, so a per-step count becomes a running
total whose first point is the first count. It takes no arguments.
The total resets with the window, so a longer time range gives a
larger final value; it is a view of the window, not a counter.
summary: 'cumulativeSum: 5xx responses since the start of the window'
value:
compositeQuery:
queries:
- spec:
aggregations:
- alias: server_errors
expression: count()
filter:
expression: http.response.status_code >= 500
functions:
- name: cumulativeSum
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: traces
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_cutoffmin_hide_idle_hosts:
description: The four threshold functions take one number. cutOffMin
and cutOffMax remove the points below or above the threshold, so
an idle host disappears from the chart instead of drawing a line
along the bottom. clampMin and clampMax keep the points and replace
the value with the threshold, which caps spikes without hiding them.
system.cpu.utilization is a ratio, so 0.05 is 5%.
summary: 'cutOffMin: drop hosts below 5% CPU from the chart'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: system.cpu.utilization
spaceAggregation: avg
timeAggregation: avg
functions:
- args:
- value: 0.05
name: cutOffMin
groupBy:
- fieldContext: resource
name: host.name
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_ewma_smoothed_latency:
description: 'ewma3, ewma5 and ewma7 replace each point with an exponentially
weighted average of the point and the points before it. The number
in the name is the window the default weight is derived from: alpha
= 2 / (n + 1), so 0.5, 0.333 and 0.25. One optional argument overrides
alpha; a value close to 1 follows the raw series, a value close
to 0 smooths harder. 0.3 here smooths a little more than ewma5''s
default.'
summary: 'ewma5: smoothed p95 latency'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: http.server.duration.bucket
spaceAggregation: p95
functions:
- args:
- value: 0.3
name: ewma5
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_fillzero_sparse_errors:
description: A count over sparse events returns a point only for steps
that had a match, so a chart of failed spans has gaps where nothing
failed. fillZero adds a zero at every missing step. With no arguments
the server fills the request window at the query's step, which is
the common case. The three optional arguments are start, end and
step in milliseconds, for callers that want to fill a different
grid.
summary: 'fillZero: a zero at every step with no failed spans'
value:
compositeQuery:
queries:
- spec:
aggregations:
- alias: failed_spans
expression: count()
filter:
expression: has_error = true
functions:
- name: fillZero
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: traces
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_log10_network_bytes:
description: log2 and log10 replace each value with its logarithm,
so a loopback device moving kilobytes and an uplink moving gigabytes
fit on one chart. They take no arguments. Zero and negative values
have no logarithm and are not drawn. absolute, also without arguments,
is the usual companion for a signed series before a logarithm.
summary: 'log10: network throughput across devices that differ by
orders of magnitude'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: system.network.io
spaceAggregation: sum
timeAggregation: rate
filter:
expression: direction = 'transmit'
functions:
- name: log10
groupBy:
- fieldContext: attribute
name: device
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_median_despiked_request_rate:
description: median3, median5 and median7 replace each point with
the median of the surrounding 3, 5 or 7 points. A spike that lasts
one step vanishes, a shift that lasts longer than half the window
stays. The first and last half window, 2 points each for median5,
have no full window and keep their values, so the newest spike stays
visible; a series shorter than the window is returned unchanged.
They take no arguments. Use them when the raw series is noisy and
the chart is for a trend, not for catching the spike.
summary: 'median5: request rate without one-step spikes'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: http.server.duration.count
spaceAggregation: sum
timeAggregation: rate
functions:
- name: median5
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_runningdiff_disk_growth:
description: 'runningDiff turns a gauge into the change between consecutive
points, so a filesystem usage gauge becomes bytes written per step.
It takes no arguments and drops the first point of the window, since
that point has nothing to difference against; the server reads one
step before the window to keep the first interval. Functions apply
in order: clampMin with 0 runs on the differences and replaces the
negative value a cleanup or a volume reset produces with zero.'
summary: 'runningDiff then clampMin: bytes written to disk per step'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: system.filesystem.usage
spaceAggregation: sum
timeAggregation: latest
filter:
expression: state = 'used'
functions:
- name: runningDiff
- args:
- value: 0
name: clampMin
groupBy:
- fieldContext: resource
name: host.name
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_timeshift_week_over_week:
description: timeShift moves every point of its query forward by the
argument, in seconds, so a series read from the past lands on the
current timestamps. B here reads the hour exactly seven days (604800
s) before the requested window and comes back on this week's timestamps,
so it overlays A on a chart and the formula can subtract it point
by point. The argument is positive; it says how far back to read.
summary: 'timeShift: this week''s error logs next to last week''s'
value:
compositeQuery:
queries:
- spec:
aggregations:
- alias: errors
expression: count()
filter:
expression: severity_text = 'ERROR'
name: A
signal: logs
stepInterval: 60s
type: builder_query
- spec:
aggregations:
- alias: errors
expression: count()
filter:
expression: severity_text = 'ERROR'
functions:
- args:
- value: 604800
name: timeShift
name: B
signal: logs
stepInterval: 60s
type: builder_query
- spec:
expression: A - B
name: F1
type: builder_formula
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
logs_raw:
summary: 'Raw: fetch raw log records'
value:

View File

@@ -11,7 +11,7 @@ const config: Config.InitialOptions = {
coverageReporters: ['text', 'cobertura', 'html', 'json-summary'],
collectCoverageFrom: ['src/**/*.{ts,tsx}'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
modulePathIgnorePatterns: ['dist'],
modulePathIgnorePatterns: ['<rootDir>/dist/'],
moduleNameMapper: {
'\\.(png|jpg|jpeg|gif|svg|webp|avif|ico|bmp|tiff)$':
'<rootDir>/__mocks__/fileMock.ts',

View File

@@ -6949,6 +6949,107 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
/**
* The panel kind.
*/
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind {
'signoz/DistributionPanel' = 'signoz/DistributionPanel',
}
export interface DashboardtypesDistributionXAxisDTO {
/**
* @type number,null
* @description Buckets wholly above this value are hidden. Null shows them all.
*/
max?: number | null;
/**
* @type number,null
* @description Buckets wholly below this value are hidden. Null shows them all.
*/
min?: number | null;
}
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) axis range and Y (count) axis scale.
*/
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
@@ -6960,7 +7061,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -7079,7 +7181,7 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
*/
functions?: Querybuildertypesv5FunctionDTO[] | null;
/**
* @description A filter on the formula values, referencing the formula by its name or as __result, for example F1 > 0. A time series keeps only the points that match and drops a series with none left; a scalar result keeps only the rows that match.
* @description Accepted but currently ignored; the computed values are returned unfiltered.
*/
having?: Querybuildertypesv5HavingDTO;
/**
@@ -7449,14 +7551,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;
/**
@@ -7476,7 +7578,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;
/**
@@ -8247,6 +8349,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.

View File

@@ -11,8 +11,6 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
// generated API Instance
const generatedAPIAxiosInstance = axios.create({
baseURL: ENVIRONMENT.baseURL,
// The backend binds repeated keys (`states=a&states=b`); axios' default sends `states[]=`.
paramsSerializer: { indexes: null },
});
let generatedAPIQueryKeyHeaderContext: Record<string, unknown> | undefined;

View File

@@ -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';
@@ -99,7 +100,7 @@ function QueryAddOns({
}
if (
panelType === PANEL_TYPES.HEATMAP &&
isBucketCountingPanelType(panelType) &&
query.dataSource === DataSource.METRICS &&
!HISTOGRAM_ATTRIBUTE_TYPES.has(query.aggregateAttribute?.type ?? '')
) {

View File

@@ -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,

View File

@@ -438,6 +438,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

View File

@@ -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,

View File

@@ -340,6 +340,7 @@ export enum PANEL_TYPES {
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
DISTRIBUTION = 'distribution',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

View File

@@ -21,15 +21,14 @@ import {
useListDashboardsForUserV2,
} from 'api/generated/services/dashboard';
import {
getListRulesV3QueryKey,
useListRulesV3,
getListRulesQueryKey,
useListRules,
} from 'api/generated/services/rules';
import type {
DashboardtypesListedDashboardForUserV2DTO,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
ListRulesV3200,
ListRulesV3Params,
ListRules200,
} from 'api/generated/services/sigNoz.schemas';
import logEvent from 'api/common/logEvent';
import { useQueryService } from 'hooks/useQueryService';
@@ -180,9 +179,6 @@ const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
*/
const DASHBOARD_LIST_PARAMS: ListDashboardsForUserV2Params = { limit: 1000 };
/** Same as dashboards. 200 is the most the rules list returns per call. */
const ALERT_RULE_LIST_PARAMS: ListRulesV3Params = { limit: 200 };
function dashboardTitle(
dashboard: DashboardtypesListedDashboardForUserV2DTO,
): string {
@@ -759,7 +755,7 @@ export default function ChatInput({
data: alertsResponse,
isLoading: isAlertsLoading,
isError: isAlertsError,
} = useListRulesV3(ALERT_RULE_LIST_PARAMS, {
} = useListRules({
query: {
enabled: isContextPickerOpen && activeContextCategory === 'Alerts',
staleTime: Infinity,
@@ -808,10 +804,10 @@ export default function ChatInput({
}
}
if (ctx.type === 'alert' && ctx.resourceId) {
const cached = queryClient.getQueryData<ListRulesV3200>(
getListRulesV3QueryKey(ALERT_RULE_LIST_PARAMS),
const cached = queryClient.getQueryData<ListRules200>(
getListRulesQueryKey(),
);
const rule = cached?.data.rules.find((r) => r.id === ctx.resourceId);
const rule = cached?.data?.find((r) => r.id === ctx.resourceId);
if (rule?.alert) {
return rule.alert;
}
@@ -839,8 +835,8 @@ export default function ChatInput({
value: dashboardTitle(dashboard),
})) ?? [],
Alerts:
alertsResponse?.data.rules
.filter((alertRule) => Boolean(alertRule.alert))
alertsResponse?.data
?.filter((alertRule) => Boolean(alertRule.alert))
.map((alertRule) => ({
id: alertRule.id,
value: alertRule.alert,
@@ -1063,7 +1059,6 @@ export default function ChatInput({
color="secondary"
size="sm"
id={`ai-context-tab-${category}`}
testId={`ai-context-tab-${category}`}
// Single stable panel id shared by every tab: only the
// active category's tabpanel is rendered, so per-category
// `aria-controls` ids would point at nonexistent nodes

View File

@@ -1,70 +0,0 @@
import {
alertRulesFixture,
listRulesV3Response,
} from 'mocks-server/__mockdata__/alert_rules';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
jest.mock('api/generated/services/dashboard', () => ({
useListDashboardsForUserV2: (): unknown => ({
data: undefined,
isLoading: false,
isError: false,
}),
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
}));
jest.mock('hooks/useQueryService', () => ({
useQueryService: (): unknown => ({
data: [],
isLoading: false,
isFetching: false,
isError: false,
}),
}));
jest.mock('../../../hooks/useSpeechRecognition', () => ({
useSpeechRecognition: (): unknown => ({
isListening: false,
isSupported: false,
permission: 'prompt',
start: jest.fn(),
discard: jest.fn(),
}),
}));
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
useAIAssistantAnalyticsContext: (): unknown => ({
threadId: undefined,
page: '/',
mode: 'sidepane',
}),
}));
// eslint-disable-next-line import/first
import ChatInput from '../ChatInput';
describe('ChatInput — alerts context picker', () => {
it('lists the rules from one v3 rules request', async () => {
const [first, second] = alertRulesFixture;
const requests: string[] = [];
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
requests.push(req.url.search);
return res(
ctx.status(200),
ctx.json(listRulesV3Response([first, second], req.url.searchParams)),
);
}),
);
render(<ChatInput onSend={jest.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
await userEvent.click(await screen.findByTestId('ai-context-tab-Alerts'));
await expect(screen.findByText(first.alert)).resolves.toBeInTheDocument();
expect(screen.getByText(second.alert)).toBeInTheDocument();
expect(requests).toStrictEqual(['?limit=200']);
});
});

View File

@@ -12,12 +12,8 @@ jest.mock('api/generated/services/dashboard', () => ({
}));
jest.mock('api/generated/services/rules', () => ({
useListRulesV3: (): unknown => ({
data: undefined,
isLoading: false,
isError: false,
}),
getListRulesV3QueryKey: (): string[] => ['rules'],
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
getListRulesQueryKey: (): string[] => ['rules'],
}));
jest.mock('hooks/useQueryService', () => ({

View File

@@ -18,7 +18,7 @@ import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
import './styles.scss';
import {
invalidateGetRuleByID,
invalidateListRulesV3,
invalidateListRules,
} from 'api/generated/services/rules';
import { useQueryClient } from 'react-query';
@@ -137,7 +137,7 @@ function Footer(): JSX.Element {
{
onSuccess: () => {
void invalidateGetRuleByID(queryClient, { id: ruleId });
void invalidateListRulesV3(queryClient);
void invalidateListRules(queryClient);
toast.success('Alert rule updated successfully');
safeNavigate('/alerts');

View File

@@ -1,22 +1,24 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Button, Skeleton } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import logEvent from 'api/common/logEvent';
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { useListRules } from 'api/generated/services/rules';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { AppLink } from 'lib/router/AppLink';
import { navigate } from 'lib/router/navigation';
import { useAppLocation } from 'lib/router/useAppLocation';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
import Card from 'periscope/components/Card/Card';
import { useAppContext } from 'providers/App/App';
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
import { USER_ROLES } from 'types/roles';
import beaconUrl from '@/assets/Icons/beacon.svg';
import { getItemIcon } from '../constants';
import { useHomeAlertRules } from './useHomeAlertRules';
export default function AlertRules({
onUpdateChecklistDoneItem,
@@ -26,19 +28,49 @@ export default function AlertRules({
loadingUserPreferences: boolean;
}): JSX.Element {
const { user } = useAppContext();
const [rulesExist, setRulesExist] = useState(false);
const [sortedAlertRules, setSortedAlertRules] = useState<RuletypesRuleDTO[]>(
[],
);
const location = useAppLocation();
const params = new URLSearchParams(location.search);
// Fetch Alerts
const { rules: alertRules, isError, isLoading } = useHomeAlertRules();
const rulesExist = alertRules.length > 0;
const {
data: alerts,
isError,
isLoading,
} = useListRules({
query: { cacheTime: 0 },
});
useEffect(() => {
if (rulesExist && !loadingUserPreferences) {
const rules = alerts?.data ?? [];
setRulesExist(rules.length > 0);
const sortedRules = [...rules].sort((a, b) => {
// First, prioritize firing alerts
if (a.state === 'firing' && b.state !== 'firing') {
return -1;
}
if (a.state !== 'firing' && b.state === 'firing') {
return 1;
}
// Then sort by updatedAt timestamp
return (
new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime()
);
});
if (sortedRules.length > 0 && !loadingUserPreferences) {
onUpdateChecklistDoneItem('SETUP_ALERTS');
}
}, [rulesExist, onUpdateChecklistDoneItem, loadingUserPreferences]);
setSortedAlertRules(sortedRules.slice(0, 5));
}, [alerts, onUpdateChecklistDoneItem, loadingUserPreferences]);
const emptyStateCard = (): JSX.Element => (
<div className="empty-state-container">
@@ -63,7 +95,7 @@ export default function AlertRules({
className="periscope-btn secondary"
icon={<Plus size={16} />}
onClick={(): void => {
void logEvent('Homepage: Create alert rule clicked', {});
logEvent('Homepage: Create alert rule clicked', {});
}}
>
Create Alert Rule
@@ -74,7 +106,7 @@ export default function AlertRules({
type="link"
className="learn-more-link"
onClick={(): void => {
void logEvent('Homepage: Learn more clicked', {
logEvent('Homepage: Learn more clicked', {
source: 'Alert Rules',
});
@@ -93,13 +125,26 @@ export default function AlertRules({
</div>
);
const onEditHandler = (record: RuletypesListableRuleDTO) => (): void => {
void logEvent('Homepage: Alert clicked', {
const onEditHandler = (record: RuletypesRuleDTO) => (): void => {
logEvent('Homepage: Alert clicked', {
ruleId: record.id,
ruleName: record.alert,
ruleState: record.state,
});
const compositeQuery = mapQueryDataFromApi(
toCompositeMetricQuery(record.condition.compositeQuery),
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = record.condition.compositeQuery.panelType;
if (panelType) {
params.set(QueryParams.panelTypes, panelType);
}
params.set(QueryParams.ruleId, record.id);
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
@@ -108,12 +153,11 @@ export default function AlertRules({
const renderAlertRules = (): JSX.Element => (
<div className="alert-rules-container home-data-item-container">
<div className="alert-rules-list">
{alertRules.map((rule) => (
{sortedAlertRules.map((rule) => (
<div
role="button"
tabIndex={0}
className="alert-rule-item home-data-item"
data-testid={`home-alert-rule-${rule.id}`}
key={rule.id}
onClick={onEditHandler(rule)}
onKeyDown={(e): void => {
@@ -195,7 +239,7 @@ export default function AlertRules({
type="link"
className="periscope-btn link learn-more-link"
onClick={(): void => {
void logEvent('Homepage: All alert rules clicked', {});
logEvent('Homepage: All alert rules clicked', {});
}}
>
All Alert Rules <ArrowRight size={12} />

View File

@@ -1,51 +0,0 @@
import { useMemo } from 'react';
import { useListRulesV3 } from 'api/generated/services/rules';
import {
type ListRulesV3Params,
RuletypesAlertStateDTO,
type RuletypesListableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
const HOME_ALERT_RULES_LIMIT = 5;
const FIRING_RULES_PARAMS: ListRulesV3Params = {
states: [RuletypesAlertStateDTO.firing],
limit: HOME_ALERT_RULES_LIMIT,
};
interface HomeAlertRules {
rules: RuletypesListableRuleDTO[];
isLoading: boolean;
isError: boolean;
}
export function useHomeAlertRules(): HomeAlertRules {
const firing = useListRulesV3(FIRING_RULES_PARAMS, {
query: { cacheTime: 0 },
});
const firingRules = firing.data?.data.rules;
const latest = useListRulesV3(
{ limit: HOME_ALERT_RULES_LIMIT },
{
query: {
cacheTime: 0,
enabled:
firingRules !== undefined && firingRules.length < HOME_ALERT_RULES_LIMIT,
},
},
);
const rules = useMemo(() => {
const firingIds = new Set(firingRules?.map(({ id }) => id));
const others = (latest.data?.data.rules ?? []).filter(
({ id }) => !firingIds.has(id),
);
return [...(firingRules ?? []), ...others].slice(0, HOME_ALERT_RULES_LIMIT);
}, [firingRules, latest.data]);
return {
rules,
isLoading: firing.isLoading || latest.isLoading,
isError: firing.isError || latest.isError,
};
}

View File

@@ -1,119 +0,0 @@
import {
RuletypesAlertStateDTO,
type RuletypesRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { navigate } from 'lib/router/navigation';
import {
alertRulesFixture,
listRulesV3Response,
} from 'mocks-server/__mockdata__/alert_rules';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
import AlertRules from '../AlertRules/AlertRules';
jest.mock('lib/router/navigation', () => ({
...jest.requireActual('lib/router/navigation'),
navigate: jest.fn(),
}));
const rule = (id: string, state: RuletypesAlertStateDTO): RuletypesRuleDTO => ({
...alertRulesFixture[0],
id,
alert: `${id} rule`,
state,
});
const burning = rule('burning', RuletypesAlertStateDTO.firing);
const quiet = rule('quiet', RuletypesAlertStateDTO.inactive);
const waiting = rule('waiting', RuletypesAlertStateDTO.pending);
const serveRules = (
rules: RuletypesRuleDTO[],
rulesForLatest = rules,
): string[] => {
const requestedStates: string[] = [];
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
const states = req.url.searchParams.getAll('states').join();
requestedStates.push(states);
return res(
ctx.status(200),
ctx.json(
listRulesV3Response(
states === 'firing' ? rules : rulesForLatest,
req.url.searchParams,
),
),
);
}),
);
return requestedStates;
};
const renderAlertRules = (): void => {
render(
<AlertRules
onUpdateChecklistDoneItem={jest.fn()}
loadingUserPreferences={false}
/>,
);
};
const renderedRuleIds = (): (string | undefined)[] =>
screen.getAllByTestId(/^home-alert-rule-/).map((row) => row.dataset.testid);
describe('Home alert rules', () => {
it('lists firing rules first, then the latest others, and opens the clicked rule', async () => {
const requestedStates = serveRules([quiet, burning, waiting]);
renderAlertRules();
await expect(
screen.findByTestId('home-alert-rule-waiting'),
).resolves.toBeInTheDocument();
expect(renderedRuleIds()).toStrictEqual([
'home-alert-rule-burning',
'home-alert-rule-quiet',
'home-alert-rule-waiting',
]);
expect(requestedStates).toStrictEqual(['firing', '']);
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByTestId('home-alert-rule-burning'));
expect(navigate).toHaveBeenCalledWith(
`${ROUTES.ALERT_OVERVIEW}?${QueryParams.ruleId}=burning`,
);
});
it('skips the latest rules request when 5 rules are firing', async () => {
const firing = Array.from({ length: 5 }, (_, index) =>
rule(`firing-${index}`, RuletypesAlertStateDTO.firing),
);
const requestedStates = serveRules([quiet, ...firing]);
renderAlertRules();
await expect(
screen.findByTestId('home-alert-rule-firing-4'),
).resolves.toBeInTheDocument();
expect(renderedRuleIds()).toStrictEqual(
firing.map(({ id }) => `home-alert-rule-${id}`),
);
expect(requestedStates).toStrictEqual(['firing']);
});
it('shows a rule once when it stops firing between the two requests', async () => {
const settled = { ...burning, state: RuletypesAlertStateDTO.inactive };
serveRules([burning], [settled, quiet]);
renderAlertRules();
await expect(
screen.findByTestId('home-alert-rule-quiet'),
).resolves.toBeInTheDocument();
expect(renderedRuleIds()).toStrictEqual([
'home-alert-rule-burning',
'home-alert-rule-quiet',
]);
});
});

View File

@@ -3,11 +3,13 @@ import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRulesV3 } from 'api/generated/services/rules';
import type {
ListRulesV3200,
ListRulesV3Params,
RenderErrorResponseDTO,
RuletypesLabelPairDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
import { isUndefined } from 'lodash-es';
import type { AlertRule } from './types';
@@ -33,7 +35,18 @@ export function useAlertRulesData(
const hasLoggedEvent = useRef(false);
const rulesResponse = useListRulesV3(params, {
query: { keepPreviousData: true },
query: {
keepPreviousData: true,
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
queryFn: ({ signal }) =>
GeneratedAPIInstance<ListRulesV3200>({
url: '/api/v3/rules',
method: 'GET',
params,
paramsSerializer: { indexes: null },
signal,
}),
},
});
const rules = useMemo(

View File

@@ -16,11 +16,11 @@ import {
useDeleteDowntimeScheduleByID,
useListDowntimeSchedules,
} from 'api/generated/services/downtimeschedules';
import { useListRules } from 'api/generated/services/rules';
import type { AlertmanagertypesPlannedMaintenanceDTO } from 'api/generated/services/sigNoz.schemas';
import { ColumnSelector } from 'components/TanStackTableView';
import TextToolTip from 'components/TextToolTip';
import dayjs from 'dayjs';
import { useListAllRules } from 'hooks/alerts/useListAllRules';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
@@ -55,7 +55,9 @@ export function PlannedDowntime(): JSX.Element {
isError,
isLoading,
isFetching: isFetchingAlerts,
} = useListAllRules({ cacheTime: 0 });
} = useListRules({
query: { cacheTime: 0 },
});
const [isOpen, setIsOpen] = React.useState(false);
const [form] = Form.useForm();
const { user } = useAppContext();
@@ -71,7 +73,7 @@ export function PlannedDowntime(): JSX.Element {
const downtimeSchedules = useListDowntimeSchedules();
const alertOptions = React.useMemo(
() =>
alertsData?.map((i) => ({
alertsData?.data?.map((i) => ({
label: i.alert,
value: i.id,
})),
@@ -179,7 +181,7 @@ export function PlannedDowntime(): JSX.Element {
};
const refetchAllSchedules = (): void => {
void downtimeSchedules.refetch();
downtimeSchedules.refetch();
};
const {

View File

@@ -4,7 +4,6 @@ import { fireEvent, screen } from '@testing-library/react';
import type {
ListDowntimeSchedules200,
RenderErrorResponseDTO,
RuletypesListableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
AlertmanagertypesRepeatOnDTO,
@@ -127,19 +126,19 @@ jest.mock('api/generated/services/downtimeschedules', () => ({
}));
let mockRulesState: {
data?: Pick<RuletypesListableRuleDTO, 'id' | 'alert'>[];
data?: { data: Array<{ id: number | string; alert: string }> };
isError: boolean;
isLoading: boolean;
isFetching: boolean;
} = {
data: [],
data: { data: [] },
isError: false,
isLoading: false,
isFetching: false,
};
jest.mock('hooks/alerts/useListAllRules', () => ({
useListAllRules: (): typeof mockRulesState => mockRulesState,
jest.mock('api/generated/services/rules', () => ({
useListRules: (): typeof mockRulesState => mockRulesState,
}));
describe('PlannedDowntime Component', () => {
@@ -152,7 +151,7 @@ describe('PlannedDowntime Component', () => {
});
mockDowntimeQueryResult.data = MOCK_DOWNTIME_RESPONSE;
mockRulesState = {
data: [],
data: { data: [] },
isError: false,
isLoading: false,
isFetching: false,
@@ -363,7 +362,7 @@ describe('PlannedDowntime Component', () => {
it('shows "Unknown rule" when scoped rule IDs cannot be resolved (Bug 56)', async () => {
mockRulesState = {
data: [],
data: { data: [] },
isError: false,
isLoading: false,
isFetching: false,
@@ -388,7 +387,14 @@ describe('PlannedDowntime Component', () => {
it('shows resolved alert rule name when scoped rule ID exists', async () => {
mockRulesState = {
data: [{ id: 'rule-42', alert: 'Disk Space High' }],
data: {
data: [
{
id: 'rule-42',
alert: 'Disk Space High',
} as any,
],
},
isError: false,
isLoading: false,
isFetching: false,

View File

@@ -4,7 +4,7 @@ import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import BucketOptions from 'components/QueryBuilderV2/QueryV2/QueryAddOns/BucketOptions/BucketOptions';
import { parseLimit, sanitizeLimit } from 'components/QueryBuilderV2/utils';
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';
@@ -180,9 +180,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}

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -1,40 +0,0 @@
import {
alertRulesFixture,
listRulesV3Response,
} from 'mocks-server/__mockdata__/alert_rules';
import { rest, server } from 'mocks-server/server';
import { AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
import { useListAllRules } from '../useListAllRules';
const TOTAL = 250;
const rules = Array.from({ length: TOTAL }, (_, index) => ({
...alertRulesFixture[0],
id: `rule-${index}`,
alert: `Rule ${index}`,
}));
describe('useListAllRules', () => {
it('fetches every page past the 200-rule cap', async () => {
const offsets: string[] = [];
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
offsets.push(req.url.searchParams.get('offset') ?? '');
return res(
ctx.status(200),
ctx.json(listRulesV3Response(rules, req.url.searchParams)),
);
}),
);
const { result } = renderHook(() => useListAllRules(), {
wrapper: AllTheProviders,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toHaveLength(TOTAL);
expect(result.current.data?.[TOTAL - 1]?.id).toBe(`rule-${TOTAL - 1}`);
expect(offsets.sort()).toStrictEqual(['0', '200']);
});
});

View File

@@ -1,40 +0,0 @@
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import {
getListRulesV3QueryKey,
listRulesV3,
} from 'api/generated/services/rules';
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { range } from 'lodash-es';
// The list API clamps any larger limit to 200.
const PAGE_SIZE = 200;
export const LIST_ALL_RULES_QUERY_KEY = [...getListRulesV3QueryKey(), 'all'];
function fetchPage(offset: number): ReturnType<typeof listRulesV3> {
return listRulesV3({ limit: PAGE_SIZE, offset });
}
// TODO(AshwinBhatkal): temporary. Replace with server-side search plus a lookup of saved rules by ID once
// /api/v3/rules can filter by IDs: https://github.com/SigNoz/pulse-pod/issues/715
async function fetchAllRules(): Promise<RuletypesListableRuleDTO[]> {
const first = await fetchPage(0);
const rest = await Promise.all(
range(PAGE_SIZE, first.data.total, PAGE_SIZE).map(fetchPage),
);
return [...first.data.rules, ...rest.flatMap((page) => page.data.rules)];
}
export function useListAllRules(
options?: Omit<
UseQueryOptions<RuletypesListableRuleDTO[], Error>,
'queryKey' | 'queryFn'
>,
): UseQueryResult<RuletypesListableRuleDTO[], Error> {
return useQuery<RuletypesListableRuleDTO[], Error>({
queryKey: LIST_ALL_RULES_QUERY_KEY,
queryFn: fetchAllRules,
...options,
});
}

View File

@@ -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:
@@ -304,13 +304,12 @@ export const useQueryOperations: UseQueryOperations = ({
timeAggregation: '',
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
// 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
// Heatmap and distribution count observations per `le` band, the one
// option those kinds offer — 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,
},
];

View File

@@ -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);
});
});

View File

@@ -49,8 +49,23 @@ describe('handleQueryChange bucket options', () => {
);
});
// 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('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 and distribution, 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', () => {
const result = handleQueryChange(
PANEL_TYPES.TIME_SERIES,

View 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
);
}

View File

@@ -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;
}

View File

@@ -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),

View File

@@ -0,0 +1,139 @@
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 fakePlot: Partial<uPlot> = {
valToPos: (value: number, scaleKey: string): number =>
scaleKey === 'x' ? value * 10 : 100 - value * 10,
ctx: {} as CanvasRenderingContext2D,
};
const plot = fakePlot as uPlot;
type Fills = Map<CanvasRenderingContext2D['fillStyle'], Path2D>;
function fillsOf(paths: ReturnType<uPlot.Series.PathBuilder>): Fills {
const fill = paths?.fill;
if (!(fill instanceof Map)) {
throw new Error('expected a fill per style');
}
return fill;
}
function rectsOf(path: Path2D | undefined): number[][] {
if (!(path instanceof FakePath2D)) {
throw new Error('expected a FakePath2D');
}
return path.rects;
}
describe('createVariableWidthBarsPathBuilder', () => {
const originalPath2D = globalThis.Path2D;
beforeAll(() => {
Object.defineProperty(globalThis, 'Path2D', {
value: FakePath2D,
configurable: true,
});
});
afterAll(() => {
Object.defineProperty(globalThis, 'Path2D', {
value: originalPath2D,
configurable: true,
});
});
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 = fillsOf(build());
expect(rectsOf(fills.get('#ff0000'))).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 = fillsOf(build(pattern));
expect([...fills.keys()]).toStrictEqual(['#ff0000', '#ff000066', pattern]);
expect(rectsOf(fills.get(pattern))).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 = fillsOf(builder(plot, 1, 0, 0));
fill = '#0000ff';
const second = fillsOf(builder(plot, 1, 0, 0));
expect([...first.keys()]).toStrictEqual(['#ff0000']);
expect([...second.keys()]).toStrictEqual(['#0000ff']);
});
it('keeps solid bars when the hatched fill is the same colour', () => {
const fills = fillsOf(
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),
);
expect(rectsOf(fills.get('red'))).toHaveLength(2);
});
it('hatches only overflow bars when both fills are the same colour', () => {
const pattern = {} as CanvasPattern;
const fills = fillsOf(
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: () => pattern,
gapPx: 0,
})(plot, 1, 0, 0),
);
expect(rectsOf(fills.get(pattern))).toStrictEqual([[10, 90, 10, 10]]);
});
});

View File

@@ -0,0 +1,88 @@
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();
// The hatch overlays only overflow bars, so they keep a path of their own even
// when one colour fill covers both (a Map keyed by fill holds one path a fill).
const hatchedPath = new Path2D();
const isSharedFill = overflowFill === solidFill;
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 rect = [left + gap, top, width - 2 * gap, bottom - top] as const;
if (!bar.hatched || isSharedFill) {
solidPath.rect(...rect);
}
if (bar.hatched) {
hatchedPath.rect(...rect);
hasHatched = true;
}
});
const fills = new Map<CanvasRenderingContext2D['fillStyle'], Path2D>([
[solidFill, solidPath],
]);
if (hasHatched) {
if (!isSharedFill) {
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;
}

View File

@@ -0,0 +1,5 @@
.notice {
padding: var(--spacing-2) var(--spacing-4);
font-size: var(--font-size-xs);
color: var(--muted-foreground);
}

View File

@@ -0,0 +1,279 @@
import { useCallback, useMemo } from 'react';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import {
LegendPosition,
type TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import type { LegendItem } from 'lib/uPlotV2/config/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 { resolveOtherColor, resolveSeriesColor } from './colors';
import DistributionTooltip from './components/DistributionTooltip/DistributionTooltip';
import { buildDistributionConfig } from './config';
import {
clampDistributionXAxis,
cropDistributionXAxis,
resolveDistributionBars,
resolveDistributionXAxis,
sumVisibleCounts,
} from './layout';
import { computeDistributionPercentiles } from './percentiles';
import { groupIntoOther, resolveMaxDrawnGroups } from './topGroups';
import { DistributionYScale } from './types';
import { useDistributionBarClick } from './useDistributionBarClick';
import styles from './Distribution.module.scss';
import { createBoundFormatter, prepareDistributionChartData } 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;
const NO_PERCENTILES: readonly number[] = Object.freeze([]);
export default function Distribution({
id,
buckets,
series,
width,
height,
isDarkMode,
xMin,
xMax,
yScale = DistributionYScale.Linear,
stack = StackMode.Normal,
xAxisUnit,
decimalPrecision,
customColors,
percentiles = NO_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);
const visibleTotals = sumVisibleCounts(counts, visible, full.rows.length);
// An explicit max may reach past the last occupied bucket; only auto-crop without one.
const fitted =
xMax === undefined ? cropDistributionXAxis(full, visibleTotals) : full;
return {
xAxis: clampDistributionXAxis(fitted, xMin, xMax),
totals: visibleTotals,
};
}, [buckets, xMin, xMax, 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 layout = useMemo(
() =>
resolveDistributionBars({
xAxis,
counts: drawn.counts,
stack,
isLogY,
}),
[xAxis, drawn, 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,
stack,
isLogY,
isDarkMode,
markers,
xAxisUnit,
decimalPrecision: boundaryPrecision,
}),
[
id,
xAxis,
layout,
drawn,
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}
formatBound={formatBound}
renderTooltipFooter={renderTooltipFooter}
/>
),
[xAxis, layout, drawn, formatBound, renderTooltipFooter],
);
const handleClick = useDistributionBarClick({
xAxis,
layout,
drawn,
series,
onBarClick,
});
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={handleClick}
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}
/>
);
}

View File

@@ -0,0 +1,115 @@
import type React from 'react';
import type { LegendItem } from 'lib/uPlotV2/config/types';
import { render, screen, userEvent } 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', () => {
// Legend items count from 1, as uPlot's data series do.
const CART = 1;
const CHECKOUT = 2;
// The selection persists under the panel id, which every case here shares.
beforeEach(() => localStorage.clear());
function legendItem(seriesIndex: number): HTMLElement {
return screen.getByTestId(`legend-item-${seriesIndex}`);
}
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 row is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDistribution();
await user.click(legendItem(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 () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderDistribution();
await user.click(screen.getByTestId(`legend-marker-${CHECKOUT}`));
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(1)).toHaveAttribute('aria-label', 'group-0');
});
it('shows no notice when every group fits', () => {
renderDistribution();
expect(
screen.queryByTestId('distribution-other-notice'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,126 @@
import { StackMode } from 'lib/uPlotV2/config/types';
import type uPlot from 'uplot';
import { resolveDistributionBars, resolveDistributionXAxis } from '../layout';
import { buildDistributionConfig } from '../config';
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4]. Only (10, 20] is occupied.
const xAxis = resolveDistributionXAxis([10, 20, 40]);
const counts = [
[0, 4, 0, 0],
[0, 6, 0, 0],
];
function setCursorHook(drawnCounts = counts): (u: uPlot) => void {
const config = buildDistributionConfig({
id: 'panel-1',
xAxis,
layout: resolveDistributionBars({
xAxis,
counts: drawnCounts,
stack: StackMode.Normal,
isLogY: false,
}),
labels: drawnCounts.map((_, index) => `group-${index}`),
colors: drawnCounts.map(() => '#ff0000'),
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 plot: Partial<uPlot> = {
cursor: { left, top },
posToVal: (position: number) => position,
setSeries,
redraw,
};
return { u: plot as uPlot, setSeries, redraw };
}
describe('buildDistributionConfig cursor', () => {
it('turns off drag-to-zoom', () => {
const config = buildDistributionConfig({
id: 'panel-1',
xAxis,
layout: resolveDistributionBars({
xAxis,
counts,
stack: StackMode.Normal,
isLogY: false,
}),
labels: ['group-0', 'group-1'],
colors: ['#ff0000', '#ff0000'],
stack: StackMode.Normal,
isLogY: false,
isDarkMode: true,
markers: [],
}).getConfig();
expect(config.cursor?.drag).toMatchObject({ x: false, y: false });
});
});
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(1.5, 8);
setCursorHook()(u);
expect(setSeries).toHaveBeenCalledWith(2, { focus: true });
expect(redraw).toHaveBeenCalledWith(true, false);
});
it('focuses the nearest group above the stack, dimming the rest', () => {
const { u, setSeries, redraw } = plotAt(1.5, 50);
setCursorHook()(u);
expect(setSeries).toHaveBeenCalledWith(2, { focus: true });
expect(redraw).toHaveBeenCalledWith(true, false);
});
it('keeps a tooltip over an empty bucket without dimming any group', () => {
const hook = setCursorHook();
const { u, setSeries, redraw } = plotAt(1.5, 8);
hook(u);
redraw.mockClear();
u.cursor.left = 2.5;
u.cursor.top = 1;
hook(u);
expect(setSeries).toHaveBeenLastCalledWith(1, { focus: true });
expect(redraw).toHaveBeenCalledWith(true, false);
});
it('focuses nothing off the plot', () => {
const hook = setCursorHook();
const { u, setSeries } = plotAt(1.5, 8);
hook(u);
u.cursor.left = -1;
hook(u);
expect(setSeries).toHaveBeenLastCalledWith(null, { focus: true });
});
it('focuses nothing over an empty bucket when every group is hidden', () => {
const { u, setSeries } = plotAt(2.5, 1);
setCursorHook([])(u);
expect(setSeries).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,58 @@
import {
layoutMarkerLabels,
resolveHoveredMarker,
} from '../drawPercentileMarkers';
describe('resolveHoveredMarker', () => {
const areas = [
{
x: 100,
badges: [
{ left: 92, right: 108, top: 0, bottom: 16 },
{ left: 92, right: 108, top: 184, bottom: 200 },
],
},
{
x: 120,
badges: [{ left: 112, right: 128, top: 18, bottom: 34 }],
},
];
it('picks the line nearest x within the tolerance', () => {
expect(resolveHoveredMarker(areas, 103, 100, 4)).toBe(0);
expect(resolveHoveredMarker(areas, 117, 100, 4)).toBe(1);
expect(resolveHoveredMarker(areas, 110, 100, 4)).toBeNull();
});
it('picks the marker whose badge is under the cursor', () => {
expect(resolveHoveredMarker(areas, 107, 190, 4)).toBe(0);
expect(resolveHoveredMarker(areas, 126, 20, 4)).toBe(1);
});
});
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]);
});
});

View File

@@ -0,0 +1,286 @@
import { StackMode } from 'lib/uPlotV2/config/types';
import {
clampDistributionXAxis,
cropDistributionXAxis,
LOG_Y_BASE,
resolveDistributionBars,
resolveDistributionXAxis,
resolveNearestSeries,
sumVisibleCounts,
thinLabelledSplits,
} from '../layout';
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]);
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('gives every bucket the same width, ticks on the bounds', () => {
const axis = resolveDistributionXAxis([0, 5, 10, 25, 1000]);
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]);
const cropped = cropDistributionXAxis(axis, [1, 5, 0, 0, 0]);
expect(cropped.max).toBe(3);
expect(cropped.overflowSplit).toBeNull();
});
});
describe('clampDistributionXAxis', () => {
// Buckets ≤ 10, (10, 20], (20, 40], (40, 80], > 80 in slots [0, 1] … [4, 5].
const axis = resolveDistributionXAxis([10, 20, 40, 80]);
it('keeps every bucket without a range', () => {
expect(clampDistributionXAxis(axis)).toStrictEqual(axis);
});
it('hides buckets wholly outside the range, keeping straddling ones whole', () => {
const clamped = clampDistributionXAxis(axis, 20, 50);
expect([clamped.min, clamped.max]).toStrictEqual([2, 4]);
expect(clamped.overflowSplit).toBeNull();
});
it('keeps the overflow tick while the overflow bucket shows', () => {
const clamped = clampDistributionXAxis(axis, 15);
expect([clamped.min, clamped.max]).toStrictEqual([1, 5]);
expect(clamped.overflowSplit).toBe(5);
});
it('keeps one bucket when the range misses them all', () => {
const clamped = clampDistributionXAxis(axis, 1000);
expect([clamped.min, clamped.max]).toStrictEqual([4, 5]);
});
it('narrows an already cropped axis, never widening it', () => {
const cropped = cropDistributionXAxis(axis, [1, 5, 0, 0, 0]);
expect(clampDistributionXAxis(cropped, 15).max).toBe(cropped.max);
});
});
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]);
const counts = [
[4, 6, 0],
[1, 3, 2],
];
it('sizes the count axis by the buckets left in the window', () => {
// Buckets ≤ 1, (1, 2], > 2; a max of 1 keeps only the first.
const { yMax } = resolveDistributionBars({
xAxis: clampDistributionXAxis(xAxis, undefined, 1),
counts: [[4, 1000, 0]],
stack: StackMode.Normal,
isLogY: false,
});
expect(yMax).toBe(4);
});
it('stacks series in each bucket under normal', () => {
const { rects, yMax } = resolveDistributionBars({
xAxis,
counts,
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,
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,
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 series under none', () => {
const { rects, yMax } = resolveDistributionBars({
xAxis,
counts,
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('starts bars at the log base and skips zero counts', () => {
const { rects } = resolveDistributionBars({
xAxis,
counts,
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]);
});
it('skips bars whose top stays under the log base, never inverting one', () => {
const fractional = [
[0.5, 3, 0],
[0.25, 0.5, 2],
];
const stacked = resolveDistributionBars({
xAxis,
counts: fractional,
stack: StackMode.Normal,
isLogY: true,
});
const sideBySide = resolveDistributionBars({
xAxis,
counts: fractional,
stack: StackMode.None,
isLogY: true,
});
expect(
stacked.rects.map((rects) => rects.map(({ y0, y1 }) => [y0, y1])),
).toStrictEqual([
[[LOG_Y_BASE, 3]],
[
[3, 3.5],
[LOG_Y_BASE, 2],
],
]);
expect(
sideBySide.rects.map((rects) => rects.map(({ bucketIndex }) => bucketIndex)),
).toStrictEqual([[1], [2]]);
});
});
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]);
});
});
describe('resolveNearestSeries', () => {
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
const xAxis = resolveDistributionXAxis([10, 20, 40]);
const counts = [
[0, 4, 0, 0],
[0, 6, 0, 0],
];
const stacked = resolveDistributionBars({
xAxis,
counts,
stack: StackMode.Normal,
isLogY: false,
});
it('names the group whose bar is under the point', () => {
expect(resolveNearestSeries(stacked, 1, 1.5, 2)).toBe(0);
expect(resolveNearestSeries(stacked, 1, 1.5, 8)).toBe(1);
});
it('takes the vertically nearest group off the bars, as a bar chart does', () => {
expect(resolveNearestSeries(stacked, 1, 1.5, 50)).toBe(1);
});
it('is null over an empty bucket', () => {
expect(resolveNearestSeries(stacked, 2, 2.5, 1)).toBeNull();
});
it('picks the sub-bar under the cursor side by side', () => {
const sideBySide = resolveDistributionBars({
xAxis,
counts,
stack: StackMode.None,
isLogY: false,
});
expect(resolveNearestSeries(sideBySide, 1, 1.25, 50)).toBe(0);
expect(resolveNearestSeries(sideBySide, 1, 1.75, 1)).toBe(1);
});
});

View File

@@ -0,0 +1,97 @@
import { resolveDistributionXAxis } from '../layout';
import {
computeDistributionPercentiles,
formatPercentileBadge,
formatPercentileName,
formatPercentileValue,
groupPercentileMarkers,
} from '../percentiles';
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
const xAxis = resolveDistributionXAxis([10, 20, 40]);
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(1.5);
});
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: 3, isOverflow: true });
});
it('marks nothing when every count is zero', () => {
expect(
computeDistributionPercentiles({
xAxis,
totals: [0, 0, 0, 0],
percentiles: [50],
}),
).toStrictEqual([]);
});
});
describe('formatPercentileName', () => {
it('keeps fractional percentile names short', () => {
expect(formatPercentileName(99.9)).toBe('p99.9');
expect(formatPercentileName(50)).toBe('p50');
});
});
describe('percentile marker groups', () => {
const formatBound = (value: number): string => `${value} ms`;
const p50 = { percentile: 50, value: 15, axisValue: 1.5, isOverflow: false };
const p99 = { percentile: 99, value: 40, axisValue: 3, isOverflow: true };
const p90 = { percentile: 90, value: 40, axisValue: 3, isOverflow: true };
it('groups markers on the same spot, lowest percentile first', () => {
expect(groupPercentileMarkers([p50, p99, p90])).toStrictEqual([
[p50],
[p90, p99],
]);
});
it('names a group by its lowest percentile', () => {
expect(formatPercentileBadge([p50])).toBe('p50');
expect(formatPercentileBadge([p90, p99])).toBe('p90+');
});
it('shows the estimate, or the overflow bound once for the group', () => {
expect(formatPercentileValue([p50], formatBound)).toBe('p50 ≈ 15 ms');
expect(formatPercentileValue([p90, p99], formatBound)).toBe(
'p90, p99 > 40 ms',
);
});
});

View File

@@ -0,0 +1,126 @@
import { StackMode } from 'lib/uPlotV2/config/types';
import { resolveDistributionXAxis } from '../layout';
import {
groupIntoOther,
resolveClickedGroup,
MAX_STACKED_GROUPS,
OTHER_GROUP_LABEL,
resolveMaxDrawnGroups,
} from '../topGroups';
describe('resolveMaxDrawnGroups', () => {
// Four equal buckets plus underflow and overflow: six slots of one unit each.
const xAxis = resolveDistributionXAxis([1, 2, 3, 4, 5]);
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.sourceIndexes).toStrictEqual([1, 3, null]);
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.sourceIndexes).toStrictEqual([3, null]);
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();
});
it('maps by source index, not label, when groups share a legend', () => {
const twins = [
{ label: 'Other', groupLabels: { pod: 'a' }, counts: [1] },
{ label: 'Other', groupLabels: { pod: 'b' }, counts: [2] },
];
const drawnTwins = groupIntoOther({
labels: twins.map(({ label }) => label),
counts: twins.map(({ counts }) => counts),
colors: ['#a', '#b'],
visible: [true, true],
maxGroups: 2,
otherColor: '#999',
});
expect(resolveClickedGroup(drawnTwins, 1, twins)).toStrictEqual({
series: twins[1],
color: '#b',
});
});
});

View File

@@ -0,0 +1,29 @@
import { Color } from '@signozhq/design-tokens';
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { toAlphaHex } from 'lib/uPlotV2/utils/fillOpacity';
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
export function withOpacity(color: string, opacity: number): string {
return HEX_COLOR.test(color) ? `${color}${toAlphaHex(opacity)}` : color;
}
/** 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,
)
);
}

View File

@@ -0,0 +1,116 @@
@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);
}
// Same as the heatmap tooltip's bucket row.
.title {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: var(--spacing-6) var(--gutter);
}
.titleBucket,
.titleCount {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-weight: 700;
letter-spacing: -0.01em;
color: var(--popover-foreground);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.titleSeparator {
flex: 1;
border-width: 0.5px;
border-style: dashed;
border-color: currentColor;
min-width: 24px;
opacity: 0.5;
}
// One grid for the heading and every row, so their columns line up.
.rows {
display: grid;
grid-template-columns: 12px minmax(0, 1fr) repeat(3, auto);
column-gap: var(--spacing-6);
row-gap: var(--spacing-1);
align-content: start;
padding: var(--spacing-4) var(--spacing-4) var(--spacing-6);
max-height: 320px;
overflow-y: auto;
@include custom-scrollbar;
}
.columnHeader,
.rows .row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid;
// The shared item's own `gap` would otherwise shift the subgrid off the parent.
column-gap: var(--spacing-6);
align-items: center;
}
.columnHeader {
padding: 0 6px var(--spacing-2);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground);
> span:nth-child(n + 2) {
text-align: right;
}
}
.columnLabel {
grid-column: 1 / 3;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rows .row {
border-radius: var(--radius);
font-variant-numeric: tabular-nums;
&[data-is-active='true'] {
background: var(--l3-background);
}
> :nth-child(n + 3) {
text-align: right;
}
}

View File

@@ -0,0 +1,154 @@
import { useMemo } from 'react';
import cx from 'classnames';
import {
formatRowLabel,
resolveRowIndex,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import TooltipPinnedBadge from 'lib/uPlotV2/components/Tooltip/components/TooltipPinnedBadge/TooltipPinnedBadge';
import ItemStyles from 'lib/uPlotV2/components/Tooltip/components/TooltipItem/TooltipItem.module.scss';
import { resolveNearestSeries } from '../../layout';
import {
buildDistributionTooltipRows,
formatPercent,
resolveGroupColumn,
} 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,
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: resolveNearestSeries(
layout,
bucket,
x,
uPlotInstance.posToVal(top, 'y'),
),
};
}, [left, top, uPlotInstance, xAxis, layout]);
const groupColumn = useMemo(() => resolveGroupColumn(labels), [labels]);
const rows = useMemo(
() =>
hover
? buildDistributionTooltipRows({
labels,
colors,
counts,
bucket: hover.bucket,
hoveredSeries: hover.series,
formatLabel: groupColumn.format,
})
: [],
[hover, labels, colors, counts, groupColumn],
);
if (!hover) {
return null;
}
const bucket = xAxis.rows[hover.bucket];
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.title}>
<span
className={Styles.titleBucket}
data-testid="distribution-tooltip-range"
>
{formatRowLabel(bucket, formatBound)}
</span>
<span className={Styles.titleSeparator} />
<span
className={Styles.titleCount}
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}
title={groupColumn.heading}
data-testid="distribution-tooltip-group-heading"
>
{groupColumn.heading}
</span>
<span>Count</span>
<span>% Total</span>
<span data-testid="distribution-tooltip-cumulative-heading">
{bucket.isOverflow ? 'All' : `≤ ${formatBound(bucket.upper)}`}
</span>
</div>
{rows.map((row) => (
<div
key={row.seriesIndex}
className={cx(ItemStyles.uplotTooltipItem, Styles.row)}
data-is-active={row.isHovered}
data-testid="distribution-tooltip-row"
data-hovered={row.isHovered}
>
<span
className={ItemStyles.uplotTooltipItemMarker}
style={{ borderColor: row.color, backgroundColor: row.color }}
/>
<span className={ItemStyles.uplotTooltipItemLabel} title={row.label}>
{row.label}
</span>
<span className={ItemStyles.uplotTooltipItemValue}>
{row.count.toLocaleString()}
</span>
<span className={ItemStyles.uplotTooltipItemValue}>
{formatPercent(row.share)}
</span>
<span className={ItemStyles.uplotTooltipItemValue}>
{formatPercent(row.cumulativeShare)}
</span>
</div>
))}
</div>
{renderTooltipFooter?.({ isPinned, dismiss })}
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { StackMode } from 'lib/uPlotV2/config/types';
import {
resolveDistributionBars,
resolveDistributionXAxis,
} from 'lib/visualization/charts/Distribution/layout';
import { render, screen } from 'tests/test-utils';
import type uPlot from 'uplot';
import DistributionTooltip from '../DistributionTooltip';
// Buckets ≤ 10, (10, 20], (20, 40], > 40 in slots [0, 1] … [3, 4].
const xAxis = resolveDistributionXAxis([10, 20, 40]);
const counts = [
[1, 4, 2, 0],
[0, 6, 1, 1],
];
const layout = resolveDistributionBars({
xAxis,
counts,
stack: StackMode.Normal,
isLogY: false,
});
/** Pixels are values: the cursor at (1.5, 2) is over the first group in (10, 20]. */
function plotAt(left: number, top: number): uPlot {
const plot: Partial<uPlot> = {
cursor: { left, top },
posToVal: (position: number) => position,
};
return plot as uPlot;
}
function renderTooltip(uPlotInstance: uPlot): void {
render(
<DistributionTooltip
uPlotInstance={uPlotInstance}
isPinned={false}
dismiss={jest.fn()}
xAxis={xAxis}
layout={layout}
labels={['{service.name="cart"}', '{service.name="checkout"}']}
colors={['#f00', '#0f0']}
counts={counts}
formatBound={(value): string => `${value} ms`}
/>,
);
}
describe('DistributionTooltip', () => {
it('names the bucket under the cursor and its total', () => {
renderTooltip(plotAt(1.5, 2));
expect(screen.getByTestId('distribution-tooltip-range')).toHaveTextContent(
'10 ms – 20 ms',
);
expect(screen.getByTestId('distribution-tooltip-total')).toHaveTextContent(
'10',
);
});
it('heads the columns with the group-by key and the bucket bound', () => {
renderTooltip(plotAt(1.5, 2));
expect(
screen.getByTestId('distribution-tooltip-group-heading'),
).toHaveTextContent('service.name');
expect(
screen.getByTestId('distribution-tooltip-cumulative-heading'),
).toHaveTextContent('≤ 20 ms');
});
it('lists every group in legend order, the hovered one active', () => {
renderTooltip(plotAt(1.5, 2));
const rows = screen.getAllByTestId('distribution-tooltip-row');
expect(rows).toHaveLength(2);
expect(rows[0]).toHaveTextContent('cart457%71%');
expect(rows[0]).toHaveAttribute('data-is-active', 'true');
expect(rows[1]).toHaveTextContent('checkout675%75%');
expect(rows[1]).toHaveAttribute('data-is-active', 'false');
});
it('renders nothing off the plot', () => {
renderTooltip(plotAt(-1, -1));
expect(screen.queryByTestId('distribution-tooltip')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,73 @@
import {
buildDistributionTooltipRows,
formatPercent,
resolveGroupColumn,
} from '../distributionTooltipContent';
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('resolveGroupColumn', () => {
it('names the column after a shared single key and shows its values', () => {
const column = resolveGroupColumn([
'{service.name="cart"}',
'{service.name="checkout"}',
]);
expect(column.heading).toBe('service.name');
expect(column.format('{service.name="cart"}')).toBe('cart');
});
it('keeps full labels for several keys or mixed labels', () => {
const several = resolveGroupColumn(['{env="prod", service.name="cart"}']);
const mixed = resolveGroupColumn(['{service.name="cart"}', 'Other']);
expect(several.heading).toBe('Group');
expect(several.format('{env="prod", service.name="cart"}')).toBe(
'{env="prod", service.name="cart"}',
);
expect(mixed.heading).toBe('Group');
});
});
describe('buildDistributionTooltipRows', () => {
it('gives each drawn group, in legend order, its count, share and cumulative share', () => {
const rows = buildDistributionTooltipRows({
labels: ['cart', 'checkout'],
colors: ['#f00', '#0f0'],
counts: [
[2, 6, 2],
[5, 0, 5],
],
bucket: 1,
hoveredSeries: 0,
formatLabel: (label) => label.toUpperCase(),
});
expect(rows).toStrictEqual([
{
seriesIndex: 0,
label: 'CART',
color: '#f00',
count: 6,
share: 0.6,
cumulativeShare: 0.8,
isHovered: true,
},
{
seriesIndex: 1,
label: 'CHECKOUT',
color: '#0f0',
count: 0,
share: 0,
cumulativeShare: 0.5,
isHovered: false,
},
]);
});
});

View File

@@ -0,0 +1,70 @@
import { DistributionGroupColumn, DistributionTooltipRow } from './types';
const SINGLE_LABEL = /^\{([^=,]+)="([^"]*)"\}$/;
/** `<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)}%`;
}
/**
* Names the group column after the one group-by key every label shares, showing just
* its values; anything else (several keys, Other) keeps the full labels.
*/
export function resolveGroupColumn(labels: string[]): DistributionGroupColumn {
const keys = new Set<string>();
labels.forEach((label) => {
keys.add(SINGLE_LABEL.exec(label)?.[1] ?? '');
});
const [key] = [...keys];
if (keys.size !== 1 || !key) {
return { heading: 'Group', format: (label) => label };
}
return {
heading: key,
format: (label) => SINGLE_LABEL.exec(label)?.[2] ?? label,
};
}
/** One row per drawn group, in legend order, with its count in `bucket`. */
export function buildDistributionTooltipRows({
labels,
colors,
counts,
bucket,
hoveredSeries,
formatLabel,
}: {
labels: string[];
colors: string[];
/** `counts[series][bucket]`. */
counts: number[][];
bucket: number;
hoveredSeries: number | null;
formatLabel: (label: string) => string;
}): DistributionTooltipRow[] {
return labels.map((label, series) => {
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 {
seriesIndex: series,
label: formatLabel(label),
color: colors[series],
count,
share: total > 0 ? count / total : 0,
cumulativeShare: total > 0 ? atOrBelow / total : 0,
isHovered: series === hoveredSeries,
};
});
}

View File

@@ -0,0 +1,39 @@
import type {
IRenderTooltipFooterArgs,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import type { DistributionBarsLayout } from '../../layout';
import type { DistributionXAxis } from '../../types';
export interface DistributionTooltipRow {
/** Index among the drawn series. */
seriesIndex: number;
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 DistributionGroupColumn {
heading: string;
format: (label: string) => string;
}
export interface DistributionTooltipProps extends Pick<
TooltipRenderArgs,
'uPlotInstance' | 'isPinned' | 'dismiss'
> {
xAxis: DistributionXAxis;
layout: DistributionBarsLayout;
labels: string[];
colors: string[];
/** `counts[series][bucket]`. */
counts: number[][];
formatBound: (value: number) => string;
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
}

View File

@@ -0,0 +1,288 @@
import { PrecisionOption } from 'components/Graph/types';
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 uPlot from 'uplot';
import { withOpacity } from './colors';
import {
drawPercentileMarkers,
MARKER_HIT_PX,
PercentileMarkerHitArea,
resolveHoveredMarker,
} from './drawPercentileMarkers';
import {
DistributionBarsLayout,
LOG_Y_BASE,
resolveNearestSeries,
thinLabelledSplits,
} from './layout';
import { DistributionPercentile, groupPercentileMarkers } from './percentiles';
import { DistributionXAxis } from './types';
import { createBoundFormatter } from './utils';
/** 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_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;
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];
}
export interface BuildDistributionConfigArgs {
id: string;
xAxis: DistributionXAxis;
layout: DistributionBarsLayout;
labels: string[];
colors: string[];
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,
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 });
// The x range is pinned to the buckets, so a drag-zoom would only flash a box.
builder.setCursor({
focus: { prox: -1 },
points: { show: false },
drag: { x: false, y: 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];
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.min && 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;
marker: number | null;
} = { series: null, focused: null, marker: null };
const plotMarkers = groupPercentileMarkers(
markers.filter(
({ axisValue }) => axisValue >= xAxis.min && axisValue <= xAxis.max,
),
);
let markerHitAreas: PercentileMarkerHitArea[] = [];
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,
showPoints: false,
spanGaps: false,
});
});
builder.addHook('draw', (u: uPlot): void => {
markerHitAreas = drawPercentileMarkers(
u,
plotMarkers,
formatBound,
isDarkMode,
hover.marker,
);
});
// 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 bucket = isOnPlot ? resolveRowIndex(xAxis.edges, x) : null;
const marker = isOnPlot
? resolveHoveredMarker(markerHitAreas, left, top, MARKER_HIT_PX)
: null;
// Over a marker, the bars neither dim nor show their tooltip.
const hovered =
bucket === null || marker !== null
? null
: resolveNearestSeries(layout, bucket, x, u.posToVal(top, 'y'));
// An empty bucket still gets a tooltip, as a bar chart's zero column does; with no
// nearest group to name, the first stands in.
const isEmptyBucket = bucket !== null && marker === null && hovered === null;
const focused = isEmptyBucket && labels.length > 0 ? 0 : hovered;
if (focused !== hover.focused) {
hover.focused = focused;
u.setSeries(focused === null ? null : focused + 1, { focus: true });
}
if (marker !== hover.marker) {
// The crosshair would cut through the marker's badges.
u.root
.querySelectorAll<HTMLElement>('.u-cursor-x, .u-cursor-y')
.forEach((line) => {
line.style.visibility = marker === null ? '' : 'hidden';
});
}
if (hovered !== hover.series || marker !== hover.marker) {
hover.series = hovered;
hover.marker = marker;
u.redraw(true, false);
}
});
return builder;
}

View File

@@ -0,0 +1,242 @@
import { Color } from '@signozhq/design-tokens';
import { themeColors } from 'constants/theme';
import uPlot from 'uplot';
import {
DistributionPercentile,
formatPercentileBadge,
formatPercentileValue,
} from './percentiles';
const MARKER_DASH: [number, number] = [3, 3];
const MARKER_FONT_SIZE_PX = 9;
/** Space between a badge's name and its diamond's edges. */
const MARKER_BADGE_PADDING_PX = 2;
const MARKER_BADGE_GAP_PX = 2;
const MARKER_VALUE_FONT_SIZE_PX = 11;
/** How far from a marker line the cursor still hovers it, in CSS pixels. */
export const MARKER_HIT_PX = 4;
const MARKER_PILL_PADDING_X_PX = 6;
const MARKER_PILL_PADDING_Y_PX = 3;
const MARKER_PILL_RADIUS_PX = 4;
/** Height of the pill's notch pointing up at the line; also its gap from the axis. */
const MARKER_PILL_POINTER_PX = 4;
interface HitRect {
left: number;
right: number;
top: number;
bottom: number;
}
/** A drawn marker's hover targets, in CSS pixels relative to the plot. */
export interface PercentileMarkerHitArea {
x: number;
badges: HitRect[];
}
/**
* The marker under `(x, y)`: one whose badge it is over, else the line nearest
* `x` within `tolerance`. `null` when none is.
*/
export function resolveHoveredMarker(
areas: PercentileMarkerHitArea[],
x: number,
y: number,
tolerance: number,
): number | null {
const overBadge = areas.findIndex(({ badges }) =>
badges.some(
(badge) =>
x >= badge.left && x <= badge.right && y >= badge.top && y <= badge.bottom,
),
);
if (overBadge !== -1) {
return overBadge;
}
let nearest: number | null = null;
let nearestDistance = tolerance;
areas.forEach((area, index) => {
const distance = Math.abs(area.x - x);
if (distance <= nearestDistance) {
nearest = index;
nearestDistance = distance;
}
});
return nearest;
}
/**
* Stacks marker badges into rows so none overlap: each goes in the first row whose
* last badge 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;
}
/** A square diamond centred on `(x, y)` with half-diagonal `r`, filled then outlined. */
function drawDiamond(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
r: number,
fill: string,
stroke: string,
): void {
ctx.beginPath();
ctx.moveTo(x, y - r);
ctx.lineTo(x + r, y);
ctx.lineTo(x, y + r);
ctx.lineTo(x - r, y);
ctx.closePath();
ctx.fillStyle = fill;
ctx.fill();
ctx.strokeStyle = stroke;
ctx.stroke();
}
/**
* Dashed lines across the plot with a diamond badge at each end naming the
* percentile, one per group on the same spot; badges stack into rows so none overlap. The hovered marker is
* emphasised and shows its value on the x axis. Returns each marker's hover targets.
*/
export function drawPercentileMarkers(
u: uPlot,
markers: DistributionPercentile[][],
formatBound: (value: number) => string,
isDarkMode: boolean,
hovered: number | null,
): PercentileMarkerHitArea[] {
if (markers.length === 0) {
return [];
}
const { ctx, bbox } = u;
const { pxRatio } = uPlot;
const color = isDarkMode ? themeColors.white : themeColors.black;
const background = isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100;
const bottom = bbox.top + bbox.height;
ctx.save();
ctx.font = `600 ${MARKER_FONT_SIZE_PX * pxRatio}px Inter, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const named = markers.map((group) => {
const text = formatPercentileBadge(group);
return { text, width: ctx.measureText(text).width };
});
// One square diamond for all, fitting the widest name's box: |dx| + |dy| ≤ r at
// its corners. Cap height stands in for the text's height.
const r =
(Math.max(...named.map(({ width }) => width)) +
MARKER_FONT_SIZE_PX * pxRatio * 0.7) /
2 +
MARKER_BADGE_PADDING_PX * pxRatio;
const badges = markers.map((group, index) => ({
text: named[index].text,
x: u.valToPos(group[0].axisValue, 'x', true),
}));
const rows = layoutMarkerLabels(
badges.map(({ x }) => ({ x: x - r, width: 2 * r })),
MARKER_BADGE_GAP_PX * pxRatio,
);
const rowStep = 2 * r + MARKER_BADGE_GAP_PX * pxRatio;
const badgeCentres = (index: number): [number, number] => {
const offset = r + rows[index] * rowStep;
return [bbox.top + offset, bottom - offset];
};
const drawMarker = (index: number): void => {
const { text, x } = badges[index];
const isHovered = index === hovered;
ctx.globalAlpha = isHovered ? 1 : 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, bottom);
ctx.stroke();
ctx.globalAlpha = 1;
ctx.setLineDash([]);
badgeCentres(index).forEach((y) => {
drawDiamond(ctx, x, y, r, isHovered ? color : background, color);
ctx.fillStyle = isHovered ? background : color;
ctx.fillText(text, x, y);
});
if (!isHovered) {
return;
}
// The value sits on the x axis under the line, over the tick labels, in a pill
// whose notch points up at the line.
ctx.font = `500 ${MARKER_VALUE_FONT_SIZE_PX * pxRatio}px Inter, sans-serif`;
const value = formatPercentileValue(markers[index], formatBound);
const padX = MARKER_PILL_PADDING_X_PX * pxRatio;
const padY = MARKER_PILL_PADDING_Y_PX * pxRatio;
const pointer = MARKER_PILL_POINTER_PX * pxRatio;
const width = ctx.measureText(value).width + 2 * padX;
const height = MARKER_VALUE_FONT_SIZE_PX * pxRatio + 2 * padY;
const left = Math.min(Math.max(x - width / 2, 0), ctx.canvas.width - width);
const top = bottom + 2 * pointer;
const notchX = Math.min(
Math.max(x, left + pointer + MARKER_PILL_RADIUS_PX * pxRatio),
left + width - pointer - MARKER_PILL_RADIUS_PX * pxRatio,
);
// A halo in the chart's background hides the tick labels it overlaps.
ctx.fillStyle = background;
ctx.fillRect(left - padX, top, width + 2 * padX, height + 2 * padY);
ctx.fillStyle = isDarkMode ? Color.BG_SLATE_400 : Color.BG_INK_400;
ctx.beginPath();
ctx.roundRect(left, top, width, height, MARKER_PILL_RADIUS_PX * pxRatio);
ctx.moveTo(notchX - pointer, top);
ctx.lineTo(notchX, top - pointer);
ctx.lineTo(notchX + pointer, top);
ctx.closePath();
ctx.fill();
ctx.fillStyle = Color.BG_VANILLA_100;
ctx.fillText(value, left + width / 2, top + height / 2);
};
// The hovered one last, so it draws over its neighbours.
badges.forEach((_, index) => index !== hovered && drawMarker(index));
if (hovered !== null && hovered < badges.length) {
drawMarker(hovered);
}
ctx.restore();
return badges.map(({ x }, index) => {
const toPlot = (value: number, origin: number): number =>
(value - origin) / pxRatio;
return {
x: toPlot(x, bbox.left),
badges: badgeCentres(index).map((y) => ({
left: toPlot(x - r, bbox.left),
right: toPlot(x + r, bbox.left),
top: toPlot(y - r, bbox.top),
bottom: toPlot(y + r, bbox.top),
})),
};
});
}

View File

@@ -0,0 +1,232 @@
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 } from './types';
/** Bottom of a log count axis: a bar has to start somewhere above zero. */
export const LOG_Y_BASE = 1;
/**
* N bounds give N + 1 buckets: `≤ first`, the finite ones, and `> last`. Every bucket
* is equally wide: bucket `i` spans `[i, i + 1]`, and ticks still name the bounds.
*/
export function resolveDistributionXAxis(buckets: number[]): DistributionXAxis {
const { rows, overflowSplit } = resolveHeatmapYAxis(
buckets,
HeatmapAxisScale.Linear,
);
const edges = rows.map((_, index) => index).concat(rows.length);
return {
rows,
edges,
splits: rows.slice(0, -1).map((_, index) => index + 1),
overflowSplit: 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,
};
}
/** 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]),
);
}
/**
* Narrows the axis window to the buckets overlapping `[min, max]`, never below one.
* A bucket straddling a bound stays whole: cutting it would misstate its count.
*/
export function clampDistributionXAxis(
axis: DistributionXAxis,
min?: number,
max?: number,
): DistributionXAxis {
const { rows, edges } = axis;
let first = edges.indexOf(axis.min);
let last = edges.indexOf(axis.max) - 1;
while (min !== undefined && first < last && rows[first].upper <= min) {
first += 1;
}
while (max !== undefined && last > first && rows[last].lower >= max) {
last -= 1;
}
return {
...axis,
overflowSplit: rows[last].isOverflow ? axis.overflowSplit : null,
min: edges[first],
max: edges[last + 1],
};
}
export interface DistributionBarsLayout {
/** One list per series, in series order. */
rects: DistributionBarRect[][];
/** Top of the tallest bar inside the axis window, for the count axis range. */
yMax: number;
}
/**
* Every bar, in scale space. `normal` stacks the series in each bucket, `percent`
* stacks their shares of it, `none` splits the bucket width between them.
*/
export function resolveDistributionBars({
xAxis,
counts,
stack,
isLogY,
}: {
xAxis: DistributionXAxis;
/** `counts[series][bucket]`. */
counts: number[][];
stack: StackMode;
isLogY: boolean;
}): DistributionBarsLayout {
const { rows, edges } = xAxis;
const rects: DistributionBarRect[][] = counts.map(() => []);
const base = isLogY ? LOG_Y_BASE : 0;
let yMax = 0;
rows.forEach((row, bucketIndex) => {
const x0 = edges[bucketIndex];
const x1 = edges[bucketIndex + 1];
// Buckets outside the window are off the plot and mustn't set its height.
const isInWindow = x0 >= xAxis.min && x1 <= xAxis.max;
const total = counts.reduce(
(sum, seriesCounts) => sum + (seriesCounts[bucketIndex] ?? 0),
0,
);
let cumulative = 0;
counts.forEach((seriesCounts, seriesIndex) => {
const count = seriesCounts[bucketIndex] ?? 0;
if (count <= 0) {
return;
}
const bar = { bucketIndex, hatched: row.isOverflow };
if (stack === StackMode.None) {
// Below the log base a bar would draw downwards from the axis.
if (count <= base) {
return;
}
const width = (x1 - x0) / counts.length;
rects[seriesIndex].push({
...bar,
x0: x0 + seriesIndex * width,
x1: x0 + (seriesIndex + 1) * width,
y0: base,
y1: count,
});
if (isInWindow) {
yMax = Math.max(yMax, count);
}
return;
}
const scale = stack === StackMode.Percent ? 100 / total : 1;
const y0 = cumulative * scale;
cumulative += count;
const y1 = cumulative * scale;
if (y1 <= base) {
return;
}
rects[seriesIndex].push({
...bar,
x0,
x1,
y0: Math.max(y0, base),
y1,
});
if (isInWindow) {
yMax = Math.max(yMax, y1);
}
});
});
return { rects, yMax };
}
/**
* The group nearest `(x, y)` vertically in `bucket`, as uPlot focuses bar series;
* side by side, only the sub-bar under `x` competes. `null` over an empty bucket.
*/
export function resolveNearestSeries(
layout: DistributionBarsLayout,
bucket: number,
x: number,
y: number,
): number | null {
const candidates = layout.rects.flatMap((rects, series) =>
rects
.filter((rect) => rect.bucketIndex === bucket)
.map((rect) => ({ rect, series })),
);
const underX = candidates.filter(({ rect }) => x >= rect.x0 && x < rect.x1);
let nearest: number | null = null;
let nearestDistance = Number.POSITIVE_INFINITY;
(underX.length > 0 ? underX : candidates).forEach(({ rect, series }) => {
const distance = Math.max(rect.y0 - y, y - rect.y1, 0);
if (distance < nearestDistance) {
nearest = series;
nearestDistance = distance;
}
});
return nearest;
}
/**
* 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();
}

View File

@@ -0,0 +1,110 @@
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: readonly 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))}`;
}
/**
* Markers that fall on the same spot, sharing one badge: the percentiles landing
* in overflow all sit on its edge. Each group is in ascending percentile order.
*/
export function groupPercentileMarkers(
markers: DistributionPercentile[],
): DistributionPercentile[][] {
const groups = new Map<number, DistributionPercentile[]>();
markers.forEach((marker) => {
groups.set(marker.axisValue, [
...(groups.get(marker.axisValue) ?? []),
marker,
]);
});
return [...groups.values()].map((group) =>
[...group].sort((a, b) => a.percentile - b.percentile),
);
}
/** `p90` for one marker; `p90+` for a group, starting at its lowest percentile. */
export function formatPercentileBadge(group: DistributionPercentile[]): string {
const name = formatPercentileName(group[0].percentile);
return group.length > 1 ? `${name}+` : name;
}
/**
* `p99 ≈ 412 ms` for an estimate; `p90, p99 > 5 s` where only the overflow bound is
* known. A group shares one spot, so one value and overflow flag.
*/
export function formatPercentileValue(
group: DistributionPercentile[],
formatBound: (value: number) => string,
): string {
const names = group
.map(({ percentile }) => formatPercentileName(percentile))
.join(', ');
const [{ value, isOverflow }] = group;
return `${names} ${isOverflow ? '>' : '≈'} ${formatBound(value)}`;
}

View File

@@ -0,0 +1,133 @@
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[];
/** Each drawn group's index among the source series; `null` for Other. */
sourceIndexes: (number | null)[];
/** 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]),
sourceIndexes: visibleIndexes,
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],
sourceIndexes: [...kept, null],
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 source =
drawnIndex === null ? null : (drawn.sourceIndexes[drawnIndex] ?? null);
if (drawnIndex === null || source === null) {
return null;
}
return { series: series[source], color: drawn.colors[drawnIndex] };
}

View File

@@ -0,0 +1,31 @@
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 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;
}

View File

@@ -0,0 +1,56 @@
import { useCallback, useRef } from 'react';
import { resolveRowIndex } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { type DistributionBarsLayout, resolveNearestSeries } from './layout';
import { type DrawnGroups, resolveClickedGroup } from './topGroups';
import type {
DistributionBarClick,
DistributionSeries,
DistributionXAxis,
} from './types';
/** Resolves a plot click to the group under it; Other and empty buckets report none. */
export function useDistributionBarClick({
xAxis,
layout,
drawn,
series,
onBarClick,
}: {
xAxis: DistributionXAxis;
layout: DistributionBarsLayout;
drawn: DrawnGroups;
series: DistributionSeries[];
onBarClick?: (click: DistributionBarClick) => void;
}): ((data: ChartClickData) => void) | undefined {
const onBarClickRef = useRef(onBarClick);
onBarClickRef.current = onBarClick;
const handleClick = useCallback(
({
xValue,
yValue,
absoluteMouseX,
absoluteMouseY,
}: ChartClickData): void => {
const bucket = resolveRowIndex(xAxis.edges, xValue);
const clicked = resolveClickedGroup(
drawn,
bucket === null
? null
: resolveNearestSeries(layout, bucket, xValue, yValue),
series,
);
if (clicked) {
onBarClickRef.current?.({
...clicked,
coordinates: { x: absoluteMouseX, y: absoluteMouseY },
});
}
},
[xAxis, layout, drawn, series],
);
return onBarClick ? handleClick : undefined;
}

View File

@@ -0,0 +1,32 @@
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import uPlot from 'uplot';
import { DistributionXAxis } from './types';
export function createBoundFormatter(
unit?: string,
decimalPrecision?: PrecisionOption,
): (value: number) => string {
return (value: number): string =>
getToolTipValue(String(value), unit, decimalPrecision);
}
/**
* `[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;
}

View File

@@ -26,6 +26,11 @@ 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,
DistributionYScale,
} from 'lib/visualization/charts/Distribution/types';
interface BaseChartProps {
width: number;
@@ -167,6 +172,48 @@ 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;
/** Buckets wholly below it are hidden. */
xMin?: number;
/** Buckets wholly above it are hidden; unset crops trailing empty buckets. */
xMax?: number;
/** 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?: readonly 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.

View File

@@ -10,7 +10,7 @@ import {
getGetRuleByIDQueryKey,
getGetRuleHistoryTimelineQueryOptions,
invalidateGetRuleByID,
invalidateListRulesV3,
invalidateListRules,
updateRuleByID,
useGetRuleByID,
useGetRuleHistoryOverallStatus,
@@ -443,7 +443,7 @@ export const useAlertRuleClone = ({
message: `Success`,
});
await invalidateListRulesV3(queryClient);
await invalidateListRules(queryClient);
params.set(QueryParams.ruleId, response.data.id);
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
@@ -494,7 +494,7 @@ export const useAlertRuleUpdate = ({
data: { ...existingRule.data, alert: intermediateName },
});
}
void invalidateListRulesV3(queryClient);
void invalidateListRules(queryClient);
notifications.success({ message: 'Alert renamed successfully' });
},
onError: (error) => {

View File

@@ -17,8 +17,10 @@ import {
FIRST_DOWNTIME_NAME,
} from './__story_mockdata__/plannedDowntime';
import { alertRulesPage } from '../../AlertRules/stories/__story_mockdata__/ruleViews';
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
import {
alertRulesResponse,
RULE_MAX,
} from '../../stories/__story_mockdata__/alerts';
import { AlertListSubTabs, AlertListTabs } from '../../types';
const LIST = 'Planned downtime · list';
@@ -84,20 +86,18 @@ export const plannedDowntimeMocks = defineStoryMocks({
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
if (values.rulesState === 'error') {
return res(ctx.status(500), ctx.json({ status: 'error' }));
}
return res(
ctx.json(
alertRulesPage(
values.silencedRules,
{ severity: 'mixed', state: 'mixed' },
req.url.searchParams,
rest.get('http://localhost/api/v2/rules', (_req, res, ctx) =>
values.rulesState === 'error'
? res(ctx.status(500), ctx.json({ status: 'error' }))
: res(
ctx.json(
alertRulesResponse(values.silencedRules, {
severity: 'mixed',
state: 'mixed',
}),
),
),
),
);
}),
),
],
config: () => ({
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.PLANNED_DOWNTIME}`,

View File

@@ -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;

View File

@@ -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({

View File

@@ -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],
);
}

View File

@@ -8,6 +8,12 @@ import {
} from '../../../Panels/capabilities';
import type { PanelKind } from '../../../Panels/types/panelKind';
/** Their queries differ in shape, so neither converts into the other. */
const UNSWITCHABLE_KINDS = new Set<PanelKind>([
'signoz/HistogramPanel',
'signoz/DistributionPanel',
]);
const QUERY_TYPE_LABEL: Record<EQueryType, string> = {
[EQueryType.QUERY_BUILDER]: 'Query Builder',
[EQueryType.CLICKHOUSE]: 'ClickHouse',
@@ -29,15 +35,26 @@ 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 !== undefined &&
fromKind !== kind &&
UNSWITCHABLE_KINDS.has(fromKind) &&
UNSWITCHABLE_KINDS.has(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.

View File

@@ -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} />

View File

@@ -180,6 +180,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" />

View File

@@ -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-range/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 =>

View File

@@ -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

View File

@@ -0,0 +1,70 @@
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={
<span data-testid="panel-editor-v2-percentile-limit">
{values.length >= MAX_PERCENTILE_MARKERS
? `Limit of ${MAX_PERCENTILE_MARKERS} reached; remove one to pick another.`
: `Up to ${MAX_PERCENTILE_MARKERS}.`}
</span>
}
{...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;

View File

@@ -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

View File

@@ -0,0 +1,83 @@
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
.setup({ pointerEventsCheck: 0 })
.click(screen.getByTestId('panel-editor-v2-percentile-markers'));
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('DistributionPercentilesField limit', () => {
it('says when the limit is reached', () => {
const { rerender } = render(
<DistributionPercentilesField
value={{ enabled: true, values: [50, 90, 99] }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-percentile-limit'),
).toHaveTextContent('Up to 5.');
rerender(
<DistributionPercentilesField
value={{ enabled: true, values: [50, 75, 90, 95, 99] }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-percentile-limit'),
).toHaveTextContent('Limit of 5 reached; remove one to pick another.');
});
});
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',
]);
});
});

View File

@@ -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.",
};

View File

@@ -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}`,
}));
}

View File

@@ -0,0 +1,10 @@
.axes {
display: flex;
flex-direction: column;
gap: 24px;
}
.axis + .axis {
padding-top: 24px;
border-top: 1px solid var(--l1-border);
}

View File

@@ -0,0 +1,76 @@
import { Callout } from '@signozhq/ui/callout';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { DashboardtypesDistributionYScaleDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigRangeInput from '../../controls/ConfigRangeInput/ConfigRangeInput';
import { isRangeInverted } from '../../controls/ConfigRangeInput/utils';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import { createFieldResetter } from '../../utils/changes';
import { 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 xMin = value?.x?.min;
const xMax = value?.x?.max;
const yScale = value?.y?.scale ?? DashboardtypesDistributionYScaleDTO.linear;
return (
<div className={styles.axes}>
<div className={styles.axis}>
<ConfigField
label="X-axis range"
help={
isRangeInverted(xMin, xMax) ? (
<Callout type="error" size="small" showIcon>
Min can&apos;t be greater than max.
</Callout>
) : (
'Hides buckets outside the range. Leave empty to show them all.'
)
}
{...reset('x')}
>
<ConfigRangeInput
minTestId="panel-editor-v2-distribution-x-min"
maxTestId="panel-editor-v2-distribution-x-max"
min={xMin}
max={xMax}
onChangeMin={(min): void =>
onChange({ ...value, x: { ...value?.x, min } })
}
onChangeMax={(max): void =>
onChange({ ...value, x: { ...value?.x, max } })
}
/>
</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;

View File

@@ -0,0 +1,66 @@
import { 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: every bucket, linear counts', () => {
render(<DistributionAxesSection value={undefined} onChange={jest.fn()} />);
expect(
screen.getByText(
'Hides buckets outside the range. Leave empty to show them all.',
),
).toBeInTheDocument();
expect(
screen.getByText('Bar heights proportional to counts.'),
).toBeInTheDocument();
});
it('writes the x min without dropping the max or the y scale', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(
<DistributionAxesSection
value={{
x: { max: 100 },
y: { scale: DashboardtypesDistributionYScaleDTO.log },
}}
onChange={onChange}
/>,
);
await user.click(screen.getByTestId('panel-editor-v2-distribution-x-min'));
await user.paste('10');
expect(onChange).toHaveBeenCalledWith({
x: { min: 10, max: 100 },
y: { scale: DashboardtypesDistributionYScaleDTO.log },
});
});
it('flags a min above the max', () => {
render(
<DistributionAxesSection
value={{ x: { min: 100, max: 10 } }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByText("Min can't be greater than max."),
).toBeInTheDocument();
});
it('writes the y scale', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const onChange = jest.fn();
render(<DistributionAxesSection value={undefined} onChange={onChange} />);
await user.click(screen.getByText('Logarithmic'));
expect(onChange).toHaveBeenCalledWith({
y: { scale: DashboardtypesDistributionYScaleDTO.log },
});
});
});

View File

@@ -0,0 +1,14 @@
import { DashboardtypesDistributionYScaleDTO } from 'api/generated/services/sigNoz.schemas';
import { pickScaleOptions } from '../AxesSection/options';
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.',
};

View File

@@ -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, {

View File

@@ -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);
};

View File

@@ -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,

View File

@@ -0,0 +1,136 @@
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,
resolveDistributionYScale,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import { enrichDistributionClick } from '../../utils/drilldown/enrichDistributionClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
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 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}
xMin={spec.axes?.x?.min ?? undefined}
xMax={spec.axes?.x?.max ?? undefined}
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;

View File

@@ -0,0 +1,152 @@
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: [
{ alias: '__result_0', 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: [
{
alias: '__result_0',
index: 0,
meta: {},
distributions: [{ counts: [1] }],
},
{
alias: '__result_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: '',
},
);
});
});

View File

@@ -0,0 +1,70 @@
import {
DashboardtypesPanelKindDTO,
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDistributionPanelSpecDTOKind as DistributionKind,
type DashboardtypesDistributionPanelSpecDTO,
type Querybuildertypesv5DistributionDataDTO,
Querybuildertypesv5RequestTypeDTO,
} 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 {
const result: Querybuildertypesv5DistributionDataDTO = {
queryName: 'A',
aggregations: [
{
alias: '__result_0',
index: 0,
meta: { buckets },
distributions: counts.map((entry) => ({ counts: entry })),
},
],
};
return {
response: {
status: 'success',
data: {
type: Querybuildertypesv5RequestTypeDTO.distribution,
data: { results: [result] },
},
},
requestPayload: undefined,
legendMap: {},
};
}
function panelWith(
spec: Partial<DashboardtypesDistributionPanelSpecDTO> = {},
): PanelOfKind<'signoz/DistributionPanel'> {
return {
kind: DashboardtypesPanelKindDTO.Panel,
spec: {
display: { name: '' },
plugin: { kind: DistributionKind['signoz/DistributionPanel'], spec },
queries: [],
},
};
}
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('says nothing without bounds', () => {
expect(getDistributionDataWarning(panelWith(), dataWith([], []))).toBeNull();
});
});

View File

@@ -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,
},
};

View File

@@ -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;
}),
};
}

View File

@@ -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 },
},
];

View File

@@ -0,0 +1,48 @@
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;
/**
* Bounds that stop short, over every group regardless of the legend: too much
* landing in overflow.
*/
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 overflowShare = total > 0 ? overflow / total : 0;
if (overflowShare <= OVERFLOW_WARNING_SHARE) {
return null;
}
const bound = getToolTipValue(String(lastBound), spec.formatting?.unit);
return {
message: `${Math.round(overflowShare * 100)}% of observations are above ${bound}. The bucket bounds may be too low.`,
messages: [],
};
}

View File

@@ -0,0 +1,3 @@
.banner {
margin: var(--spacing-4) var(--spacing-6) 0;
}

View File

@@ -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;

View File

@@ -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,
);
});
});

View File

@@ -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]: {

View File

@@ -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,
);
}

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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,
};
/**

Some files were not shown because too many files have changed in this diff Show More