Compare commits

..

174 Commits

Author SHA1 Message Date
Abhi Kumar
0ec5756645 fix(query-builder): aggregate a histogram heatmap by count
The le labels are the axis, so every percentile draws what count draws. Leaving a heatmap
restores a percentile; entering one keeps a single enabled query.

Assisted-by: Claude Fable 5.1
2026-09-18 14:32:13 +05:30
Abhi Kumar
939a244a18 feat(query-builder): let a heatmap query pick its bucket axis
Request-level on the wire, edited beside the one query a heatmap draws, which is the formula
when its inputs are disabled. Hidden for a histogram metric, whose buckets are its own.

Assisted-by: Claude Fable 5.1
2026-09-18 14:32:10 +05:30
Abhi Kumar
688dba6e13 fix(dashboards): offer only the panel kind's signals in the query builder
Assisted-by: Claude Fable 5.1
2026-09-18 14:32:07 +05:30
Abhi Kumar
b051e14643 feat(dashboards): add the heatmap panel kind
Metrics only, heatmap request type, and a bucketed step interval so a wide range stays a
readable number of columns.

Assisted-by: Claude Fable 5.1
2026-09-18 14:32:04 +05:30
Abhi Kumar
b72456f935 feat(dashboards): add the heatmap's axis and colour controls
A zero step count is read as unset: the wire type is a plain int.

Assisted-by: Claude Fable 5.1
2026-09-18 14:32:00 +05:30
Abhi Kumar
a10be0d95d fix(dashboards): derive the metric y-axis unit instead of storing it
A state-and-effect pair over an array rebuilt every render schedules an update per render.

Assisted-by: Claude Fable 5.1
2026-09-18 14:31:57 +05:30
Abhi Kumar
73373f654f feat(dashboards): let the heatmap pick its axis scale and colour floor from the data
Auto chooses log, symlog or linear from the bucket boundaries. The log colour scale floors
at the smallest count rather than 1. Hover identity includes the count, so a refetch under a
still cursor redraws, and the overlay is clipped to the plot area.

Assisted-by: Claude Fable 5.1
2026-09-18 14:31:54 +05:30
Abhi Kumar
30bc4411d5 fix(dashboards): align the heatmap tooltip with the other chart tooltips
Assisted-by: Claude Fable 5.1
2026-09-18 14:31:15 +05:30
Abhi Kumar
90a55ce72d feat(dashboards): register the heatmap panel type
V2-only: the V1 maps carry it as null so the V2 registry renders it.

Assisted-by: Claude Fable 5.1
2026-09-18 14:31:12 +05:30
Abhi Kumar
67afca0016 chore(api): regenerate the client for the heatmap panel spec
Assisted-by: Claude Fable 5.1
2026-09-18 14:31:09 +05:30
Abhi kumar
4f04962e73 Merge branch 'nv/heatmap-dashboard-panel' into feat/heatmap-chart-layer 2026-09-18 13:25:09 +05:30
Abhi kumar
ff8ec64c2e Merge branch 'main' into nv/heatmap-dashboard-panel 2026-09-18 13:24:42 +05:30
Abhi kumar
02069e0a5e Merge branch 'main' into nv/heatmap-dashboard-panel 2026-09-17 15:26:41 +05:30
Abhi Kumar
a09d5338b3 Merge branch 'nv/heatmap-dashboard-panel' into feat/heatmap-chart-layer
Puts this branch on the base its PR already targets, so the panel schema stops
showing up as a change in the PRs stacked above it. That base has since caught
up with main, which brings the revamped chart legend (#12838) in with it.

The heatmap's group legend moves onto that legend's action API: it no longer
reads clicks off the DOM and decides what they meant, it applies the action the
legend sends. Isolate-and-restore now comes from the legend deriving Only/All
from how many rows are showing, so the hook stops tracking which group is
isolated.

Assisted-by: Claude Opus 5
2026-09-16 13:27:26 +05:30
Abhi Kumar
0241b8081c Merge branch 'nv/heatmap-dashboard-panel' into feat/heatmap-chart-layer
Puts this branch on the base its PR already targets, so the panel schema stops
showing up as a change in the PRs stacked above it.

The metrics statement builder keeps main's landed form of the histogram CTE
rewrite (#12764) over the pre-squash copy this branch merged in.

Assisted-by: Claude Opus 5
2026-09-16 13:02:14 +05:30
Naman Verma
4260363b45 chore: revert test changes 2026-09-16 12:21:32 +05:30
Naman Verma
19c4aed27c Merge branch 'main' into nv/heatmap-dashboard-panel 2026-09-16 12:20:18 +05:30
Abhi Kumar
f7b68ee687 feat(dashboards): add the Heatmap chart
Assembles the pieces into the chart a panel mounts: bucket × time density grid,
where columns are time slices, rows are bucket ranges and cell colour is the
observation count — so a distribution can be watched changing shape instead of
collapsing to percentile lines.

It takes bucket bounds plus one series per group, the shape the query response
already has, and pivots and sums them itself rather than making every caller get
the transpose right. It builds its own `UPlotConfigBuilder`, since the y axis
*is* the bucket axis and the bounds fully determine it.

The group legend goes through the shared `Legend` — click a label to isolate a
group, a marker to exclude one — and the ColorBar mounts in the new content
footer as the scale key.

Panel kind, spec and the `heatmap` request type land separately.

Assisted-by: Claude Opus 5
2026-09-11 18:53:20 +05:30
Abhi Kumar
232122ecda feat(dashboards): let a chart supply its own legend and a content footer
Three slots on the shared chart frame, for a chart whose legend is not a list of
uPlot series.

- `customLegend` replaces the config-driven legend. The heatmap's series are
  bucket rows; what belongs in its legend is the groups.
- `legendLabels` is what the chart/legend width split measures, which otherwise
  sizes itself against series labels the reader never sees.
- `contentFooter` renders under the plot but inside the chart column, so a scale
  key stays with the axis and sits beside a RIGHT legend rather than under it —
  `layoutChildren` stays below everything.

Each is optional and unset for every existing chart, which keeps its current
layout.

Assisted-by: Claude Opus 5
2026-09-11 18:53:00 +05:30
Abhi Kumar
42fdcdefef feat(dashboards): add the heatmap tooltip
A cell needs a different answer than a series point does, so this renders its
own container rather than joining the `TooltipProps` union the shared shell
draws as a flat series list.

It shows the hovered bucket with its neighbours, to give the count somewhere to
sit in the distribution, and switches to a per-group contribution breakdown when
the cell sums more than one group — otherwise a grouped query's densest cells
explain nothing about which group put them there.

Assisted-by: Claude Opus 5
2026-09-11 18:52:21 +05:30
Abhi Kumar
30ad2a303d feat(dashboards): add the ColorBar scale key
The legend for a colour-encoded value: the ramp, its end labels, and the ticks
between them. Takes the resolved stops and a formatter, so it knows nothing
about heatmaps and any density visualisation can mount it.

Assisted-by: Claude Opus 5
2026-09-11 18:52:01 +05:30
Abhi Kumar
d39938d462 feat(dashboards): draw the heatmap through uPlot's draw hooks
The rendering half. `paint` fills the cell rects on the canvas and hatches the
`null` ones, so no-data reads as absent rather than as a zero count, and
`hoverOverlay` puts the focused cell's outline in a DOM layer above the canvas
instead of redrawing the grid on every cursor move.

`heatmapPlugin` binds them to the plot. It exposes hooks for
`UPlotConfigBuilder.addHook` rather than a `uPlot.Plugin`, because uPlot appends
plugin hooks after the hook arrays and `setCursor` has to resolve the focused
cell before TooltipPlugin's own `setCursor` positions the tooltip — registered
as a plugin it trails a frame and the tooltip flashes at the origin.

The count domain resolves lazily on first draw so a refetch stays on uPlot's
`setData` path instead of forcing a config rebuild.

The uPlot test mock grows the two things a canvas-drawing chart needs from it: a
`root` element, since the chart reads `root.parentElement` to notice a
re-mounted container, and a pinned `pxRatio` so canvas-space maths is
deterministic under jsdom.

Assisted-by: Claude Opus 5
2026-09-11 18:51:49 +05:30
Abhi Kumar
e81e9a2fd3 feat(dashboards): add the heatmap colour scale
Turns a cell's count into its fill, and is the other half of the plugin that
needs no canvas to test.

`palettes` holds the ramps — ColorBrewer and matplotlib stop values, each
tagged with which end is dark so a resolver can read them in either direction.
`colorScale` normalises a count against the grid's domain and resolves it to a
colour, in either palette mode or the opacity mode that tints a single base
colour, and clamps the step count.

The domain is resolved rather than taken from the data alone: a caller can pin
it so the colours mean the same thing across a comparison.

Assisted-by: Claude Opus 5
2026-09-11 18:51:18 +05:30
Abhi Kumar
00718c0662 feat(dashboards): add the heatmap grid model
The pure half of the heatmap: no canvas, no React, no uPlot instance.

`geometry` maps between the three coordinate spaces the grid lives in — bucket
index, data value and pixel — and owns the bucket axis. Log bucket bounds are
placed as log10 values on a linear uPlot scale, which keeps the row heights
even; a range including zero or negatives extends symmetrically about it rather
than clamping, so the empty half of a signed distribution stays visible.

`grid` pivots the per-group series into the cell matrix the renderer walks, and
keeps `null` (no data) distinct from a `0` count all the way through — the two
mean different things to whoever is reading the chart, and only one of them is
worth colouring.

Assisted-by: Claude Opus 5
2026-09-11 18:50:53 +05:30
Abhi Kumar
55a0c8dd35 feat(dashboards): let an axis take splits and a time scale keep its range
Two gaps in the shared config builders that any chart whose marks span an
interval, rather than sitting on a point, runs into.

- `AxisProps.splits` passes an explicit tick list through to uPlot. A bucket
  axis puts its ticks on the bucket boundaries, which are data, not a function
  of the range.
- a time scale given an explicit `range` now returns it untouched. The
  alignment below it trims the window to whole minutes, which is right for a
  point series but drops the final column of anything drawn as a span.

Assisted-by: Claude Opus 5
2026-09-11 18:49:20 +05:30
Naman Verma
9f1b1476f4 Merge branch 'nv/heatmap' into nv/heatmap-dashboard-panel 2026-09-11 13:18:49 +05:30
Naman Verma
0bea671a2d Merge branch 'main' into nv/heatmap 2026-09-11 13:18:39 +05:30
Naman Verma
0b1695c9c2 chore: regenerate openapi spec 2026-09-11 13:16:29 +05:30
Naman Verma
b4f42cf388 feat(dashboards): add the heatmap panel 2026-09-11 13:15:25 +05:30
Naman Verma
65a8a58600 chore: regenerate api specs 2026-09-11 13:14:44 +05:30
Naman Verma
a3c90ab132 chore: move the heatmap dashboard panel to its own branch 2026-09-11 13:14:41 +05:30
Naman Verma
da4c5578d3 test: fix param in unit test 2026-09-11 13:09:23 +05:30
Naman Verma
bf01867c3c test: properly test for error messages 2026-09-11 12:58:26 +05:30
Naman Verma
41187a5fca chore: track full bucket details to be able to report the clashes in err msg 2026-09-11 12:38:14 +05:30
Naman Verma
d04dc6f7db test: add coarser scale in test 2026-09-11 12:15:11 +05:30
Naman Verma
71badcf65c chore: better comment 2026-09-11 12:03:26 +05:30
Naman Verma
68fe15d5fc chore: better comment 2026-09-11 12:02:43 +05:30
Naman Verma
f8aded8c8e test: use histogram sounding metric name for histogram unit tests 2026-09-11 12:00:45 +05:30
Naman Verma
f6a6793e96 test: remove logscale param from linear bucket in test 2026-09-11 11:57:45 +05:30
Naman Verma
452046d70c chore: remove POC 2026-09-11 11:45:43 +05:30
Naman Verma
2e9c9067f3 Merge branch 'main' into nv/heatmap 2026-09-11 11:45:01 +05:30
Naman Verma
2ddd45cb8c test: fix error msg assertion 2026-09-10 16:56:32 +05:30
Naman Verma
8809d8a7ac test: fix error msg assertion 2026-09-10 16:05:33 +05:30
Naman Verma
21edb05f2b fix: move bucket options to each query for dashboards 2026-09-10 16:04:17 +05:30
Naman Verma
f659205866 fix: make consume.go take in both upper and lower bounds 2026-09-10 14:38:15 +05:30
Naman Verma
215f6f8e0f fix: distinguish between no enabled and >1 enabled queries 2026-09-09 22:07:31 +05:30
Naman Verma
802f7e4a3d fix: more concise err message in promql no le case 2026-09-09 22:03:39 +05:30
Naman Verma
81f1105fbc fix: throw error if no le bucket if found in promql returned data 2026-09-09 21:56:43 +05:30
Naman Verma
575aa57426 Merge branch 'main' into nv/heatmap 2026-09-09 21:19:41 +05:30
Naman Verma
f75d3d8724 test: move rejection tests 2026-09-09 21:18:58 +05:30
Naman Verma
43538b92db fix: no value to be sent if values array is present 2026-09-09 19:57:51 +05:30
Naman Verma
ef65c2c08c chore: remove unneeded nil check 2026-09-09 19:46:47 +05:30
Naman Verma
8e41d3d30d Merge branch 'main' into nv/heatmap 2026-09-09 19:34:30 +05:30
Naman Verma
b249ab8275 fix: dont build heatmap bucket options for missing metrics 2026-09-09 12:25:40 +05:30
Naman Verma
61bca035a3 chore: use increase as time aggregation for regular percentiles as well 2026-09-09 11:55:40 +05:30
Naman Verma
9983b3344b Merge branch 'nv/heatmap' of https://github.com/SigNoz/signoz into nv/heatmap 2026-09-09 10:13:05 +05:30
Naman Verma
1313a289b6 Merge branch 'main' into nv/heatmap 2026-09-07 23:01:08 +05:30
Naman Verma
82a789c19e test: fix pylint in integration test 2026-09-07 21:01:03 +05:30
Naman Verma
ec09873573 chore: push temporary poc for testing 2026-09-07 21:01:03 +05:30
Naman Verma
00cc773792 test: add integration tests 2026-09-07 21:01:03 +05:30
Naman Verma
cf5dd79216 fix: return unsupported for logs and traces 2026-09-07 21:01:03 +05:30
Naman Verma
6546a0abc6 test: delete heatmap UTs (to be covered in integration tests) 2026-09-07 21:01:03 +05:30
Naman Verma
3874f3a6ec test: delete heatmap validation UTs (to be covered in integration tests) 2026-09-07 21:01:03 +05:30
Naman Verma
86db0fd26b chore: var renames 2026-09-07 21:01:03 +05:30
Naman Verma
f382a958db chore: code refactoring of bucketFormulaOutputAsHeatmap 2026-09-07 21:01:03 +05:30
Naman Verma
7c3481b230 chore: comments, code structure changes and renamings in resolveHeatmapBucketAxis 2026-09-07 21:01:03 +05:30
Naman Verma
24f3116527 chore: map var renames 2026-09-07 21:01:03 +05:30
Naman Verma
17f0f49398 chore: some code movement and clarity around ReindexValuesToNewUpperBounds 2026-09-07 21:01:03 +05:30
Naman Verma
f681bacdcb chore: method rename 2026-09-07 21:01:03 +05:30
Naman Verma
ded4ed3d76 chore: add comment for logs and traces supporting only 1 aggregation 2026-09-07 21:01:03 +05:30
Naman Verma
1f84f9c166 chore: remove comment 2026-09-07 21:01:03 +05:30
Naman Verma
87127b580c chore: upperbound instead of boundary 2026-09-07 21:01:03 +05:30
Naman Verma
fc4bd13fce test: remove unit tests that can be covered in integration tests 2026-09-07 21:01:03 +05:30
Naman Verma
075a4b164d chore: revert UT change 2026-09-07 21:01:03 +05:30
Naman Verma
2170189d14 chore: shorten comments 2026-09-07 21:01:03 +05:30
Naman Verma
b7720b655e chore: shorten comments 2026-09-07 21:01:03 +05:30
Naman Verma
f34973b897 chore: use the term upper bound instead of boundary 2026-09-07 21:01:03 +05:30
Naman Verma
d9d041a8fa fix: use separate typedef for cumulative column 2026-09-07 21:01:03 +05:30
Naman Verma
a0e66e3592 fix: return empty instead of err if promql resp does not have le 2026-09-07 21:01:03 +05:30
Naman Verma
a0dff26fbc test: remove unit tests that can be covered in integration tests 2026-09-07 21:01:03 +05:30
Naman Verma
db0b1b2b2a chore: remove capacity vars for an easier read 2026-09-07 21:01:03 +05:30
Naman Verma
fd8ed18592 chore: better var name 2026-09-07 21:01:03 +05:30
Naman Verma
392b5826fd chore: better comment 2026-09-07 21:01:03 +05:30
Naman Verma
4b4023d706 chore: move HeatmapBucketColumn 2026-09-07 21:01:03 +05:30
Naman Verma
f8d803d787 fix: allow other panel types in heatmap 2026-09-07 21:01:03 +05:30
Naman Verma
a33320c665 test: add unit tests for cumulative metrics 2026-09-07 21:01:03 +05:30
Naman Verma
695a481989 fix: use heatmapBucketing.LogScale correctly 2026-09-07 21:01:03 +05:30
Naman Verma
3fc763b95b chore: break down method for easier reading 2026-09-07 21:01:03 +05:30
Naman Verma
027b65574d chore: shorten comments 2026-09-07 21:01:03 +05:30
Naman Verma
43ec98b309 chore: rename method name 2026-09-07 21:01:03 +05:30
Naman Verma
a196a436be chore: code movement 2026-09-07 21:01:03 +05:30
Naman Verma
3f456b6602 chore: add separate file for heatmap accumulator 2026-09-07 21:01:03 +05:30
Naman Verma
f777e068c3 chore: reduce comment size 2026-09-07 21:01:03 +05:30
Naman Verma
5a690695e6 chore: split toResult method into two 2026-09-07 21:01:03 +05:30
Naman Verma
bf362d15db chore: shorten comment 2026-09-07 21:01:03 +05:30
Naman Verma
7e0f70ffa7 chore: remove unneeded comment 2026-09-07 21:01:03 +05:30
Naman Verma
590046adf3 fix: add heatmap to hasData check 2026-09-07 21:01:03 +05:30
Naman Verma
106cb9f6c2 chore: shorten comment 2026-09-07 21:01:03 +05:30
Naman Verma
f95699b1f7 chore: method name change 2026-09-07 21:01:03 +05:30
Naman Verma
1e75b66379 chore: method name change 2026-09-07 21:01:03 +05:30
Naman Verma
b80013d075 fix: return 501 for exp histogram instead of 400 2026-09-07 21:01:03 +05:30
Naman Verma
4a6e25708d chore: remove unneeded comments 2026-09-07 21:01:03 +05:30
Naman Verma
3b8f9a7830 chore: move MaxNumBuckets const to where it is actually used 2026-09-07 21:01:03 +05:30
Naman Verma
68bff5b053 chore: remove unneeded comments 2026-09-07 21:01:03 +05:30
Naman Verma
732c8ae18a chore: remove unneeded comments 2026-09-07 21:01:03 +05:30
Naman Verma
6911630c2a fix: dont allow bucket options in non heatmap requests 2026-09-07 21:01:03 +05:30
Naman Verma
2d78d8add9 chore: move valid bucket kinds to additional part of err 2026-09-07 21:01:03 +05:30
Naman Verma
8e47c7d337 chore: move consts to where they are actually used 2026-09-07 21:01:03 +05:30
Naman Verma
991e734c33 chore: minor code movement 2026-09-07 21:01:03 +05:30
Naman Verma
40c881b8f5 chore: remove unneeded comment 2026-09-07 21:01:03 +05:30
Naman Verma
a2c8dd5275 chore: remove unneeded comment 2026-09-07 21:01:03 +05:30
Naman Verma
32ae7a26d5 chore: shorten comment 2026-09-07 21:01:03 +05:30
Naman Verma
4067b04fa2 fix: update dashboard schema to latest spec 2026-09-07 21:01:03 +05:30
Naman Verma
70409f5ee3 fix: make ResolveHeatmapBucketing a method on MetricAggregation 2026-09-07 21:01:03 +05:30
Naman Verma
5d9953bb7e feat: add heatmap support in query and dashboards 2026-09-07 21:01:03 +05:30
Naman Verma
edfff0ee72 test: fix pylint in integration test 2026-09-07 16:42:23 +05:30
Naman Verma
44556060a3 chore: push temporary poc for testing 2026-09-07 15:54:09 +05:30
Naman Verma
545e0342d2 test: add integration tests 2026-09-07 14:57:47 +05:30
Naman Verma
2f559f34d3 fix: return unsupported for logs and traces 2026-09-06 12:14:39 +05:30
Naman Verma
e4d90b91b7 test: delete heatmap UTs (to be covered in integration tests) 2026-09-06 05:07:18 +05:30
Naman Verma
f33616a598 test: delete heatmap validation UTs (to be covered in integration tests) 2026-09-06 04:51:05 +05:30
Naman Verma
2ac94fc2fc chore: var renames 2026-09-06 04:46:39 +05:30
Naman Verma
7292a6946b chore: code refactoring of bucketFormulaOutputAsHeatmap 2026-09-06 04:32:54 +05:30
Naman Verma
dff49a4135 chore: comments, code structure changes and renamings in resolveHeatmapBucketAxis 2026-09-06 04:09:09 +05:30
Naman Verma
690c207519 chore: map var renames 2026-09-06 03:19:59 +05:30
Naman Verma
d9b6d1f425 chore: some code movement and clarity around ReindexValuesToNewUpperBounds 2026-09-06 03:18:01 +05:30
Naman Verma
1eb974d91a chore: method rename 2026-09-06 02:55:54 +05:30
Naman Verma
1141f6bf14 chore: add comment for logs and traces supporting only 1 aggregation 2026-09-06 02:50:12 +05:30
Naman Verma
a3fb926726 chore: remove comment 2026-09-06 02:25:27 +05:30
Naman Verma
fe1e86f385 chore: upperbound instead of boundary 2026-09-06 02:12:39 +05:30
Naman Verma
e30d9a1a14 test: remove unit tests that can be covered in integration tests 2026-09-06 01:51:03 +05:30
Naman Verma
c9a345713c chore: revert UT change 2026-09-06 01:49:52 +05:30
Naman Verma
22f8eba44a chore: shorten comments 2026-09-06 01:41:53 +05:30
Naman Verma
e8ad9044d5 chore: shorten comments 2026-09-06 01:40:08 +05:30
Naman Verma
576cd5052a chore: use the term upper bound instead of boundary 2026-09-06 01:37:33 +05:30
Naman Verma
4214ccf93d fix: use separate typedef for cumulative column 2026-09-06 01:34:44 +05:30
Naman Verma
63f5c17238 fix: return empty instead of err if promql resp does not have le 2026-09-06 01:25:53 +05:30
Naman Verma
fd6abd67dd test: remove unit tests that can be covered in integration tests 2026-09-05 16:25:23 +05:30
Naman Verma
a78c35d61c chore: remove capacity vars for an easier read 2026-09-05 16:19:46 +05:30
Naman Verma
b2c4916a86 chore: better var name 2026-09-05 16:08:02 +05:30
Naman Verma
feff1a0179 chore: better comment 2026-09-05 15:53:47 +05:30
Naman Verma
eafac1be69 chore: move HeatmapBucketColumn 2026-09-05 15:35:14 +05:30
Naman Verma
510d6985d0 fix: allow other panel types in heatmap 2026-09-05 15:23:15 +05:30
Naman Verma
758aacb065 test: add unit tests for cumulative metrics 2026-09-05 15:13:11 +05:30
Naman Verma
5599c352d0 fix: use heatmapBucketing.LogScale correctly 2026-09-05 14:29:47 +05:30
Naman Verma
96d0a2340c chore: break down method for easier reading 2026-09-05 14:19:11 +05:30
Naman Verma
c6b415ad04 chore: shorten comments 2026-09-05 14:14:39 +05:30
Naman Verma
9b922f297f chore: rename method name 2026-09-05 13:53:08 +05:30
Naman Verma
e81a4c4046 chore: code movement 2026-09-05 13:44:11 +05:30
Naman Verma
9ba13848ea chore: add separate file for heatmap accumulator 2026-09-05 13:16:18 +05:30
Naman Verma
2578f25dd4 chore: reduce comment size 2026-09-05 12:46:35 +05:30
Naman Verma
82352973bb chore: split toResult method into two 2026-09-05 12:36:47 +05:30
Naman Verma
3eb55692f1 chore: shorten comment 2026-09-05 04:04:45 +05:30
Naman Verma
61ceb6d300 chore: remove unneeded comment 2026-09-05 04:03:43 +05:30
Naman Verma
e33fc20579 fix: add heatmap to hasData check 2026-09-05 03:50:44 +05:30
Naman Verma
b130a61049 chore: shorten comment 2026-09-05 03:25:14 +05:30
Naman Verma
132e9bb740 chore: method name change 2026-09-05 03:15:13 +05:30
Naman Verma
9d577731e1 chore: method name change 2026-09-05 03:07:14 +05:30
Naman Verma
4d54568fe1 fix: return 501 for exp histogram instead of 400 2026-09-05 03:04:41 +05:30
Naman Verma
6f82624523 chore: remove unneeded comments 2026-09-05 02:53:15 +05:30
Naman Verma
da0bc4f438 chore: move MaxNumBuckets const to where it is actually used 2026-09-05 02:51:29 +05:30
Naman Verma
2dcb764632 chore: remove unneeded comments 2026-09-05 02:50:27 +05:30
Naman Verma
1bc25e16b8 chore: remove unneeded comments 2026-09-05 02:50:03 +05:30
Naman Verma
eebc6b400d fix: dont allow bucket options in non heatmap requests 2026-09-05 02:49:41 +05:30
Naman Verma
30c4508113 chore: move valid bucket kinds to additional part of err 2026-09-05 02:38:00 +05:30
Naman Verma
c4fe619920 chore: move consts to where they are actually used 2026-09-05 02:33:59 +05:30
Naman Verma
d597adddda chore: minor code movement 2026-09-05 02:31:10 +05:30
Naman Verma
9a05f33e7a chore: remove unneeded comment 2026-09-05 02:25:48 +05:30
Naman Verma
3812937c6f chore: remove unneeded comment 2026-09-05 02:25:29 +05:30
Naman Verma
34aa719cba chore: shorten comment 2026-09-05 02:24:15 +05:30
Naman Verma
cdfce4c4ab fix: update dashboard schema to latest spec 2026-09-05 02:12:34 +05:30
Naman Verma
d8ef672f83 Merge branch 'main' into nv/heatmap 2026-09-05 02:00:57 +05:30
Naman Verma
00efffc127 fix: make ResolveHeatmapBucketing a method on MetricAggregation 2026-09-03 13:50:09 +05:30
Naman Verma
dec922a83f feat: add heatmap support in query and dashboards 2026-09-03 12:07:27 +05:30
157 changed files with 10720 additions and 1473 deletions

View File

@@ -384,49 +384,13 @@ components:
required:
- routingKey
type: object
AlertmanagertypesChannelSlackAction:
properties:
confirm:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackConfirmation'
name:
type: string
style:
type: string
text:
type: string
type:
type: string
url:
type: string
value:
type: string
required:
- type
- text
type: object
AlertmanagertypesChannelSlackConfig:
properties:
actions:
items:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackAction'
type: array
apiUrl:
format: password
type: string
channel:
type: string
color:
type: string
fallback:
type: string
fields:
items:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackField'
type: array
footer:
type: string
pretext:
type: string
sendResolved:
nullable: true
type: boolean
@@ -434,37 +398,9 @@ components:
type: string
title:
type: string
titleLink:
type: string
required:
- apiUrl
type: object
AlertmanagertypesChannelSlackConfirmation:
properties:
dismissText:
type: string
okText:
type: string
text:
type: string
title:
type: string
required:
- text
type: object
AlertmanagertypesChannelSlackField:
properties:
short:
nullable: true
type: boolean
title:
type: string
value:
type: string
required:
- title
- value
type: object
AlertmanagertypesChannelWebhookConfig:
properties:
bearerToken:
@@ -3616,6 +3552,79 @@ components:
hide:
type: boolean
type: object
DashboardtypesHeatmapAxes:
properties:
yScale:
$ref: '#/components/schemas/DashboardtypesHeatmapYScale'
type: object
DashboardtypesHeatmapChartAppearance:
properties:
colors:
$ref: '#/components/schemas/DashboardtypesHeatmapColors'
type: object
DashboardtypesHeatmapColorMode:
enum:
- palette
- opacity
type: string
DashboardtypesHeatmapColorScale:
enum:
- log
- sqrt
- linear
type: string
DashboardtypesHeatmapColors:
properties:
fill:
type: string
maxCount:
nullable: true
type: number
minCount:
nullable: true
type: number
mode:
$ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
palette:
$ref: '#/components/schemas/DashboardtypesHeatmapPalette'
scale:
$ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
steps:
type: integer
type: object
DashboardtypesHeatmapPalette:
enum:
- ice
- moss
- rust
- graphite
- ember
- lagoon
- orchid
- verdant
- lava
- beacon
type: string
DashboardtypesHeatmapPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesHeatmapAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesHeatmapChartAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesHeatmapYScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -3968,6 +3977,7 @@ components:
discriminator:
mapping:
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
@@ -3985,6 +3995,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -3996,6 +4007,7 @@ components:
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
@@ -4009,6 +4021,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
properties:
kind:
enum:
- signoz/HeatmapPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesHeatmapPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec:
properties:
kind:

View File

@@ -2,6 +2,8 @@
// Mock for uplot library used in tests
export interface MockUPlotInstance {
/** Consumers read `root.parentElement` to detect a re-mounted container. */
root: HTMLDivElement;
setData: jest.Mock;
setSize: jest.Mock;
destroy: jest.Mock;
@@ -17,13 +19,20 @@ export interface MockUPlotPaths {
}
// Create mock instance methods
const createMockUPlotInstance = (): MockUPlotInstance => ({
setData: jest.fn(),
setSize: jest.fn(),
destroy: jest.fn(),
redraw: jest.fn(),
setSeries: jest.fn(),
});
const createMockUPlotInstance = (target?: HTMLElement): MockUPlotInstance => {
const root = document.createElement('div');
// Real uPlot mounts its root inside the target; without it a re-render reads
// `root.parentElement` off undefined and throws.
target?.appendChild(root);
return {
root,
setData: jest.fn(),
setSize: jest.fn(),
destroy: jest.fn(),
redraw: jest.fn(),
setSeries: jest.fn(),
};
};
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
const createMockPathBuilder = (name: string): jest.Mock =>
@@ -53,14 +62,16 @@ const mockTzDate = jest.fn(
function MockUPlot(
_options: unknown,
_data: unknown,
_target: HTMLElement,
target: HTMLElement,
): MockUPlotInstance {
return createMockUPlotInstance();
return createMockUPlotInstance(target);
}
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
MockUPlot.pxRatio = 1;
// Export the constructor as default
export default MockUPlot;

View File

@@ -40,73 +40,7 @@ export interface AlertmanagertypesChannelDTO {
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind {
slack = 'slack',
}
export interface AlertmanagertypesChannelSlackConfirmationDTO {
/**
* @type string
*/
dismissText?: string;
/**
* @type string
*/
okText?: string;
/**
* @type string
*/
text: string;
/**
* @type string
*/
title?: string;
}
export interface AlertmanagertypesChannelSlackActionDTO {
confirm?: AlertmanagertypesChannelSlackConfirmationDTO;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
style?: string;
/**
* @type string
*/
text: string;
/**
* @type string
*/
type: string;
/**
* @type string
*/
url?: string;
/**
* @type string
*/
value?: string;
}
export interface AlertmanagertypesChannelSlackFieldDTO {
/**
* @type boolean,null
*/
short?: boolean | null;
/**
* @type string
*/
title: string;
/**
* @type string
*/
value: string;
}
export interface AlertmanagertypesChannelSlackConfigDTO {
/**
* @type array
*/
actions?: AlertmanagertypesChannelSlackActionDTO[];
/**
* @type string
* @format password
@@ -116,26 +50,6 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
* @type string
*/
channel?: string;
/**
* @type string
*/
color?: string;
/**
* @type string
*/
fallback?: string;
/**
* @type array
*/
fields?: AlertmanagertypesChannelSlackFieldDTO[];
/**
* @type string
*/
footer?: string;
/**
* @type string
*/
pretext?: string;
/**
* @type boolean,null
*/
@@ -148,10 +62,6 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
* @type string
*/
title?: string;
/**
* @type string
*/
titleLink?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO {
@@ -5162,6 +5072,83 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesTextPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind {
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
}
export enum DashboardtypesHeatmapYScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesHeatmapAxesDTO {
yScale?: DashboardtypesHeatmapYScaleDTO;
}
export enum DashboardtypesHeatmapColorModeDTO {
palette = 'palette',
opacity = 'opacity',
}
export enum DashboardtypesHeatmapPaletteDTO {
ice = 'ice',
moss = 'moss',
rust = 'rust',
graphite = 'graphite',
ember = 'ember',
lagoon = 'lagoon',
orchid = 'orchid',
verdant = 'verdant',
lava = 'lava',
beacon = 'beacon',
}
export enum DashboardtypesHeatmapColorScaleDTO {
log = 'log',
sqrt = 'sqrt',
linear = 'linear',
}
export interface DashboardtypesHeatmapColorsDTO {
/**
* @type string
*/
fill?: string;
/**
* @type number,null
*/
maxCount?: number | null;
/**
* @type number,null
*/
minCount?: number | null;
mode?: DashboardtypesHeatmapColorModeDTO;
palette?: DashboardtypesHeatmapPaletteDTO;
scale?: DashboardtypesHeatmapColorScaleDTO;
/**
* @type integer
*/
steps?: number;
}
export interface DashboardtypesHeatmapChartAppearanceDTO {
colors?: DashboardtypesHeatmapColorsDTO;
}
export interface DashboardtypesHeatmapPanelSpecDTO {
axes?: DashboardtypesHeatmapAxesDTO;
chartAppearance?: DashboardtypesHeatmapChartAppearanceDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO {
/**
* @enum signoz/HeatmapPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind;
spec: DashboardtypesHeatmapPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -5170,7 +5157,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6095,6 +6083,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
}
/**
* @nullable

View File

@@ -1,15 +1,21 @@
import type {
GetAIObservabilityFieldsKeys200,
GetAIObservabilityFieldsValues200,
GetAIObservabilityFieldsKeysParams,
GetAIObservabilityFieldsValuesParams,
GetFieldsKeys200,
GetFieldsKeysParams,
GetFieldsValues200,
GetFieldsValuesParams,
} from 'api/generated/services/sigNoz.schemas';
export type FieldKeysConfig = GetFieldsKeysParams;
export type FieldKeysConfig =
| GetFieldsKeysParams
| GetAIObservabilityFieldsKeysParams;
export type FieldValuesConfig = GetFieldsValuesParams;
export type FieldValuesConfig =
| GetFieldsValuesParams
| GetAIObservabilityFieldsValuesParams;
export type FieldKeysConfigProp = Omit<
FieldKeysConfig,

View File

@@ -198,6 +198,10 @@ function createBaseSpec(
: undefined,
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
having: normalizeHaving(queryData.having),
// Heatmap only. Every other request type rejects an axis, and
// `panelTypeDataSourceFormValuesMap` is what keeps one from being carried onto a
// query the panel type switched away from.
bucketOptions: queryData.bucketOptions,
functions: isEmpty(queryData.functions)
? undefined
: queryData.functions.map((func: QueryFunction): QueryFunction => {

View File

@@ -0,0 +1,144 @@
/**
* Borrows the query builder's control metrics rather than the component defaults the
* toggle group and number input ship with: 36px tall, 2px radius, and the
* `--query-builder-v2-*` surface the selects above this row already paint on.
*/
.bucketOptions {
--toggle-group-radius: var(--radius-1);
--toggle-group-item-size: 36px;
--toggle-group-item-font-size: 13px;
--toggle-group-item-padding-left: var(--spacing-6);
--toggle-group-item-padding-right: var(--spacing-6);
// Repeated from `.query-add-ons` rather than inherited: the section also renders on a
// formula, which has no add-ons ancestor to pick the toggle palette up from.
--toggle-group-secondary-bg: var(
--query-builder-v2-toggle-group-background-color,
var(--l1-background-hover)
);
--toggle-group-secondary-border: var(
--query-builder-v2-toggle-group-border-color,
var(--l2-border)
);
--toggle-group-secondary-active-bg: var(
--query-builder-v2-toggle-group-active-background-color,
var(--l1-background)
);
--toggle-group-secondary-bg-hover: var(
--query-builder-v2-toggle-group-background-color-hover,
var(--l2-background)
);
--input-height: 36px;
--input-font-size: 13px;
--input-border-radius: var(--radius-1);
--input-border-color: var(--query-builder-v2-border-color, var(--l2-border));
--input-background: var(
--query-builder-v2-background-color,
var(--l2-background)
);
--input-foreground: var(--query-builder-v2-color, var(--l2-foreground));
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-4);
box-sizing: border-box;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-radius: var(--radius-1);
background: var(--query-builder-v2-background-color, var(--l2-background));
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
/** One label paired with its control, at the same 10px rhythm as the aggregate rows. */
.field {
display: flex;
align-items: center;
gap: var(--spacing-5);
}
.bounds {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
}
.label {
color: var(--l3-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: var(--font-weight-medium);
line-height: 18px;
letter-spacing: 0.48px;
text-transform: uppercase;
white-space: nowrap;
}
/**
* The toggle group exposes a font-size token but no family, so each item's label carries
* the query builder's value type itself.
*/
.toggleLabel {
font-family: 'Geist Mono';
font-size: 13px;
font-weight: var(--font-weight-normal);
letter-spacing: -0.07px;
}
/** Surface and metrics come from the `--input-*` tokens above; only the family has none. */
.numberInput {
width: 104px;
font-family: 'Geist Mono';
letter-spacing: -0.07px;
}
/** Bounds read as a sequence of values, so they take the value type in a flat pill. */
.bound {
padding: var(--spacing-1) var(--spacing-3);
border-radius: var(--radius-1);
font-family: 'Geist Mono';
font-size: 13px;
line-height: 20px;
letter-spacing: -0.07px;
color: var(--query-builder-v2-color, var(--l2-foreground));
background: var(--l3-background);
}
.overflowBound {
background: transparent;
color: var(--l3-foreground);
}
.muted {
font-family: 'Geist Mono';
font-size: 13px;
line-height: 20px;
color: var(--l3-foreground);
}
.hint {
margin: 0;
max-width: 72ch;
font-family: 'Geist Mono';
font-size: 12px;
line-height: 18px;
color: var(--l3-foreground);
}
.closeBtn {
margin-left: auto;
width: 36px;
height: 36px;
}

View File

@@ -0,0 +1,268 @@
import { useCallback, useMemo, useState } from 'react';
import { Button } from 'antd';
import cx from 'classnames';
import { InputNumber } from '@signozhq/ui/input-number';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { ChevronUp } from '@signozhq/icons';
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
import {
BUCKET_KIND_HINTS,
BUCKET_KIND_OPTIONS,
DEFAULT_NUM_BUCKETS,
LOG_BANDS_OPTIONS,
MAX_NUM_BUCKETS,
} from './constants';
import {
bandsPerDoublingFromScale,
BucketKindOption,
formatUpperBound,
hasBoundsBeyondPreview,
isLinearBuckets,
kindOptionOf,
linearBuckets,
logBuckets,
logScaleOf,
previewUpperBounds,
scaleFromBandsPerDoubling,
} from './utils';
import styles from './BucketOptions.module.scss';
function BucketOptions({
bucketOptions,
unit,
onChange,
onClose,
}: {
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/** The panel's y-axis unit, so the previewed bounds read the way the axis will. */
unit?: string;
onChange: (next: Querybuildertypesv5BucketOptionsDTO | undefined) => void;
/** Omitted where the section isn't dismissable, as on a formula. */
onClose?: () => void;
}): JSX.Element {
// A linear axis has no bounds to describe until it has a max value, so the picked
// kind is held here rather than read back off the emitted options: it has to survive
// the gap between choosing Linear and filling the field in.
const [kind, setKind] = useState<BucketKindOption>(
kindOptionOf(bucketOptions),
);
const linearSpec =
bucketOptions && isLinearBuckets(bucketOptions)
? bucketOptions.spec
: undefined;
const [logScale, setLogScale] = useState<number>(logScaleOf(bucketOptions));
const [maxValue, setMaxValue] = useState<number | null>(
linearSpec?.maxValue ?? null,
);
const [numBuckets, setNumBuckets] = useState<number | null>(
linearSpec?.numBuckets ?? null,
);
const emitLinear = useCallback(
(nextMaxValue: number | null, nextNumBuckets: number | null): void => {
// An incomplete linear axis is sent as no axis at all rather than as a spec the
// request would reject.
if (nextMaxValue === null || nextMaxValue <= 0) {
onChange(undefined);
return;
}
onChange(linearBuckets(nextMaxValue, nextNumBuckets));
},
[onChange],
);
const handleKindChange = useCallback(
(value: string): void => {
// Radix clears the value when the active item is clicked again; a bucket axis is
// always one of the three, so keep the current pick instead.
if (!value) {
return;
}
const nextKind = value as BucketKindOption;
setKind(nextKind);
if (nextKind === 'auto') {
onChange(undefined);
} else if (nextKind === 'log') {
onChange(logBuckets(logScale));
} else {
emitLinear(maxValue, numBuckets);
}
},
[emitLinear, logScale, maxValue, numBuckets, onChange],
);
const handleBandsChange = useCallback(
(value: string): void => {
if (!value) {
return;
}
const nextScale = scaleFromBandsPerDoubling(Number(value));
setLogScale(nextScale);
onChange(logBuckets(nextScale));
},
[onChange],
);
const handleMaxValueChange = useCallback(
(value: number | string | null): void => {
const next = value === null || value === '' ? null : Number(value);
setMaxValue(next);
emitLinear(next, numBuckets);
},
[emitLinear, numBuckets],
);
const handleNumBucketsChange = useCallback(
(value: number | string | null): void => {
const next = value === null || value === '' ? null : Number(value);
setNumBuckets(next);
emitLinear(maxValue, next);
},
[emitLinear, maxValue],
);
// The toggle group takes a ReactNode label, which is how each item picks up the
// query builder's value type — the component exposes no font-family token.
const kindItems = useMemo(
() =>
BUCKET_KIND_OPTIONS.map(({ value, label }) => ({
value,
label: <span className={styles.toggleLabel}>{label}</span>,
'aria-label': label,
})),
[],
);
const bandItems = useMemo(
() =>
LOG_BANDS_OPTIONS.map(({ value, label }) => ({
value,
label: <span className={styles.toggleLabel}>{label}</span>,
'aria-label': label,
})),
[],
);
// The kind toggle can be ahead of what has been emitted, so the preview describes
// the picked kind rather than the emitted options.
const previewedOptions = useMemo(():
| Querybuildertypesv5BucketOptionsDTO
| undefined => {
if (kind === 'log') {
return logBuckets(logScale);
}
if (kind === 'linear' && maxValue !== null) {
return linearBuckets(maxValue, numBuckets);
}
return undefined;
}, [kind, logScale, maxValue, numBuckets]);
const bounds =
kind === 'linear' && !previewedOptions
? undefined
: previewUpperBounds(previewedOptions);
return (
<div className={styles.bucketOptions} data-testid="bucket-options">
<div className={styles.controls}>
<div className={styles.field}>
<span className={styles.label}>Bucket by</span>
<ToggleGroupSimple
type="single"
value={kind}
items={kindItems}
onChange={handleKindChange}
testId="bucket-options-kind"
/>
</div>
{kind === 'log' && (
<div className={styles.field}>
<span className={styles.label}>Bands per doubling</span>
<ToggleGroupSimple
type="single"
value={String(bandsPerDoublingFromScale(logScale))}
items={bandItems}
onChange={handleBandsChange}
testId="bucket-options-bands"
/>
</div>
)}
{kind === 'linear' && (
<>
<div className={styles.field}>
<span className={styles.label}>Max value</span>
<InputNumber
className={styles.numberInput}
min={0}
value={maxValue}
onChange={handleMaxValueChange}
placeholder="Required"
status={maxValue !== null && maxValue <= 0 ? 'error' : undefined}
data-testid="bucket-options-max-value"
/>
</div>
<div className={styles.field}>
<span className={styles.label}>Buckets</span>
<InputNumber
className={styles.numberInput}
min={1}
max={MAX_NUM_BUCKETS}
precision={0}
value={numBuckets}
onChange={handleNumBucketsChange}
placeholder={String(DEFAULT_NUM_BUCKETS)}
data-testid="bucket-options-num-buckets"
/>
</div>
</>
)}
{onClose && (
<Button
className={cx('periscope-btn', 'ghost', styles.closeBtn)}
icon={<ChevronUp size={16} />}
onClick={onClose}
data-testid="bucket-options-close"
/>
)}
</div>
<div className={styles.bounds} data-testid="bucket-options-bounds">
<span className={styles.label}>Bounds</span>
{bounds ? (
<>
{bounds.map((bound) => (
<span className={styles.bound} key={bound}>
{formatUpperBound(bound, unit)}
</span>
))}
{hasBoundsBeyondPreview(previewedOptions) && (
<span className={styles.muted}></span>
)}
<span className={cx(styles.bound, styles.overflowBound)}>+Inf</span>
</>
) : (
<span className={styles.muted}>Set a max value to see the bounds</span>
)}
</div>
<p className={styles.hint}>{BUCKET_KIND_HINTS[kind]}</p>
</div>
);
}
BucketOptions.defaultProps = {
bucketOptions: undefined,
unit: undefined,
onClose: undefined,
};
export default BucketOptions;

View File

@@ -0,0 +1,89 @@
import { MAX_LOG_SCALE } from '../constants';
import {
bandsPerDoublingFromScale,
formatUpperBound,
hasBoundsBeyondPreview,
kindOptionOf,
linearBuckets,
logBuckets,
previewUpperBounds,
scaleFromBandsPerDoubling,
} from '../utils';
describe('bucket option scales', () => {
it.each([
[0, 1],
[1, 2],
[2, 4],
[3, 8],
[4, 16],
])('scale %i is %i bands per doubling', (scale, bands) => {
expect(bandsPerDoublingFromScale(scale)).toBe(bands);
expect(scaleFromBandsPerDoubling(bands)).toBe(scale);
});
});
describe('kindOptionOf', () => {
it('reads no options as auto', () => {
expect(kindOptionOf(undefined)).toBe('auto');
});
it('reads the kind off the options', () => {
expect(kindOptionOf(logBuckets(MAX_LOG_SCALE))).toBe('log');
expect(kindOptionOf(linearBuckets(10, null))).toBe('linear');
});
});
describe('previewUpperBounds', () => {
it('doubles at one band per doubling', () => {
expect(previewUpperBounds(logBuckets(0))).toStrictEqual([
1, 2, 4, 8, 16, 32, 64, 128,
]);
});
it('falls back to the finest log axis when no options are set', () => {
const bounds = previewUpperBounds(undefined);
expect(bounds).toHaveLength(8);
// 16 bands per doubling: the eighth bound is 2^(7/16), still short of the first doubling.
expect(bounds?.[7]).toBeCloseTo(2 ** (7 / 16));
});
it('spaces a linear axis evenly by bucket width', () => {
expect(previewUpperBounds(linearBuckets(100, 10))).toStrictEqual(
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].slice(0, 8),
);
});
it('stops at the last bucket when the axis has fewer than the preview shows', () => {
expect(previewUpperBounds(linearBuckets(9, 3))).toStrictEqual([3, 6, 9]);
});
it('has no bounds to preview for a linear axis without a usable max value', () => {
expect(previewUpperBounds(linearBuckets(0, null))).toBeUndefined();
expect(previewUpperBounds(linearBuckets(-1, null))).toBeUndefined();
});
});
describe('hasBoundsBeyondPreview', () => {
it('is always true for a log axis, which has no top', () => {
expect(hasBoundsBeyondPreview(logBuckets(0))).toBe(true);
expect(hasBoundsBeyondPreview(undefined)).toBe(true);
});
it('tracks whether a linear axis runs past the preview', () => {
expect(hasBoundsBeyondPreview(linearBuckets(100, 4))).toBe(false);
expect(hasBoundsBeyondPreview(linearBuckets(100, 20))).toBe(true);
});
});
describe('formatUpperBound', () => {
it('reads a bound in the panel unit when one is set', () => {
expect(formatUpperBound(1, 'ms')).toContain('ms');
});
it('keeps three significant digits when unitless', () => {
expect(formatUpperBound(128, undefined)).toBe('128');
expect(formatUpperBound(2 ** (1 / 16), undefined)).toBe('1.04');
});
});

View File

@@ -0,0 +1,46 @@
/**
* Mirrors the limits `querybuildertypesv5` validates `bucketOptions` against. The
* builder keeps its own copy so an out-of-range axis is refused before the request
* rather than after it.
*/
/**
* A log axis spaces bounds at 2^scale bands per doubling. MaxLogScale is the
* resolution ClickHouse buckets at, so it is both the finest available and what an
* absent `bucketOptions` resolves to.
*/
export const MAX_LOG_SCALE = 4;
export const MAX_NUM_BUCKETS = 512;
export const DEFAULT_NUM_BUCKETS = 60;
/**
* The bands-per-doubling the toggle offers, coarsest first. Each is 2^scale for a
* scale in [0, MAX_LOG_SCALE]: a negative scale is a whole number of doublings per
* band instead, which has no bands-per-doubling label.
*/
export const LOG_BANDS_PER_DOUBLING = [1, 2, 4, 8, 16] as const;
/** How many leading upper bounds the bounds strip previews before eliding. */
export const PREVIEW_BOUND_COUNT = 8;
/** The kind toggle's options. `auto` sends no options and lets the server choose. */
export const BUCKET_KIND_OPTIONS = [
{ value: 'auto', label: 'Auto' },
{ value: 'log', label: 'Log' },
{ value: 'linear', label: 'Linear' },
];
export const LOG_BANDS_OPTIONS = LOG_BANDS_PER_DOUBLING.map((bands) => ({
value: String(bands),
label: String(bands),
}));
export const BUCKET_KIND_HINTS = {
auto:
'Bounds are picked for you: a log axis at 16 bands per doubling, the finest the query can return.',
log: 'Bounds are spaced evenly on a log axis, so every band is the same height on screen and the tail stays readable. Fewer bands per doubling means fewer, coarser bands.',
linear:
'Bounds are spaced evenly from 0 up to the max value, so a band covers the same width wherever it sits. Everything above the max value lands in a single overflow band.',
};

View File

@@ -0,0 +1,130 @@
import {
Querybuildertypesv5BucketOptionsDTO,
Querybuildertypesv5BucketOptionsLinearDTO,
Querybuildertypesv5BucketOptionsLinearDTOKind,
Querybuildertypesv5BucketOptionsLogDTO,
Querybuildertypesv5BucketOptionsLogDTOKind,
} from 'api/generated/services/sigNoz.schemas';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import {
DEFAULT_NUM_BUCKETS,
MAX_LOG_SCALE,
PREVIEW_BOUND_COUNT,
} from './constants';
/**
* The toggle's own vocabulary: the two kinds the request takes, plus `auto` for
* sending no options at all and letting the server pick the axis.
*/
export type BucketKindOption = 'auto' | 'log' | 'linear';
export const isLinearBuckets = (
bucketOptions: Querybuildertypesv5BucketOptionsDTO,
): bucketOptions is Querybuildertypesv5BucketOptionsLinearDTO =>
bucketOptions.kind === Querybuildertypesv5BucketOptionsLinearDTOKind.linear;
export const linearBuckets = (
maxValue: number,
numBuckets: number | null,
): Querybuildertypesv5BucketOptionsLinearDTO => ({
kind: Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
spec: { maxValue, ...(numBuckets ? { numBuckets } : {}) },
});
export const logBuckets = (
scale: number,
): Querybuildertypesv5BucketOptionsLogDTO => ({
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
spec: { scale },
});
/**
* The log spec's scale, defaulted the way the server defaults it. A linear axis has
* no scale, so it reads as the default too.
*/
export const logScaleOf = (
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): number =>
bucketOptions && !isLinearBuckets(bucketOptions)
? (bucketOptions.spec.scale ?? MAX_LOG_SCALE)
: MAX_LOG_SCALE;
export const bandsPerDoublingFromScale = (scale: number): number => 2 ** scale;
export const scaleFromBandsPerDoubling = (bands: number): number =>
Math.round(Math.log2(bands));
export const kindOptionOf = (
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): BucketKindOption => {
if (!bucketOptions) {
return 'auto';
}
return isLinearBuckets(bucketOptions) ? 'linear' : 'log';
};
/**
* The leading upper bounds the axis will carry. A log axis is anchored at 1 — band
* index 0's boundary — and a linear one at the top of its first band; both continue
* past what the strip shows, and everything above the last one lands in the overflow
* band the UI labels separately.
*
* `undefined` for a linear axis with no max value yet: without a top there is nothing
* to divide.
*/
export function previewUpperBounds(
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): number[] | undefined {
if (bucketOptions && isLinearBuckets(bucketOptions)) {
const { maxValue, numBuckets = DEFAULT_NUM_BUCKETS } = bucketOptions.spec;
if (!Number.isFinite(maxValue) || maxValue <= 0 || numBuckets <= 0) {
return undefined;
}
const width = maxValue / numBuckets;
return Array.from(
{ length: Math.min(numBuckets, PREVIEW_BOUND_COUNT) },
(_, index) => (index + 1) * width,
);
}
const bands = bandsPerDoublingFromScale(logScaleOf(bucketOptions));
return Array.from(
{ length: PREVIEW_BOUND_COUNT },
(_, index) => 2 ** (index / bands),
);
}
/**
* Whether the strip elides bounds after the ones it shows. A linear axis with no more
* buckets than the strip holds ends where the strip does.
*/
export function hasBoundsBeyondPreview(
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): boolean {
if (!bucketOptions || !isLinearBuckets(bucketOptions)) {
return true;
}
return (
(bucketOptions.spec.numBuckets ?? DEFAULT_NUM_BUCKETS) > PREVIEW_BOUND_COUNT
);
}
/**
* A bound is a value on the panel's own axis, so it reads in the panel's unit when one
* is set. Unitless, three significant digits keep the tightly spaced bounds of a fine
* log axis distinguishable without printing the float in full.
*/
export function formatUpperBound(bound: number, unit?: string): string {
if (unit) {
return getYAxisFormattedValue(String(bound), unit);
}
return Number(bound.toPrecision(3)).toString();
}

View File

@@ -3,14 +3,21 @@ import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { get, isEmpty } from 'lodash-es';
import { BarChart, ChevronUp, ExternalLink, ScrollText } from '@signozhq/icons';
import {
BarChart,
ChevronUp,
ExternalLink,
Grid3X3,
ScrollText,
} from '@signozhq/icons';
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -25,6 +32,7 @@ import {
resolveQueryBuilderFields,
} from '../../queryBuilderFields.utils';
import BucketOptions from './BucketOptions/BucketOptions';
import HavingFilter from './HavingFilter/HavingFilter';
import { buildDefaultLegendFromGroupBy } from './utils';
@@ -57,6 +65,7 @@ const ADD_ONS_KEYS_TO_QUERY_PATH: Omit<
[QueryBuilderField.Limit]: 'limit',
[QueryBuilderField.Legend]: 'legend',
[QueryBuilderField.ReduceTo]: 'reduceTo',
[QueryBuilderField.BucketOptions]: 'bucketOptions',
};
const ADD_ONS: AddOn[] = [
@@ -116,6 +125,22 @@ const REDUCE_TO: AddOn = {
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
};
// 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.
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
ATTRIBUTE_TYPES.HISTOGRAM,
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
]);
const BUCKET_OPTIONS: AddOn = {
icon: <Grid3X3 size={14} />,
label: 'Bucket by',
key: QueryBuilderField.BucketOptions,
description:
'Choose how the bucket axis is spaced — logarithmically, so every band is the same height and the tail stays readable, or linearly up to a max value. Left to Auto, the query picks the finest log axis it can return.',
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/',
};
const hasValue = (value: unknown): boolean =>
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);
@@ -196,7 +221,7 @@ function QueryAddOns({
isForTraceOperator,
});
const { handleSetQueryData } = useQueryBuilder();
const { handleSetQueryData, currentQuery } = useQueryBuilder();
const supportedAddOns = useMemo((): AddOn[] => {
let addOns: AddOn[];
@@ -210,8 +235,25 @@ function QueryAddOns({
addOns = [...ADD_ONS];
}
return showReduceTo ? [...addOns, REDUCE_TO] : addOns;
}, [panelType, query.dataSource, showReduceTo]);
if (showReduceTo) {
addOns = [...addOns, REDUCE_TO];
}
if (
panelType === PANEL_TYPES.HEATMAP &&
query.dataSource === DataSource.METRICS &&
!HISTOGRAM_ATTRIBUTE_TYPES.has(query.aggregateAttribute?.type ?? '')
) {
addOns = [...addOns, BUCKET_OPTIONS];
}
return addOns;
}, [
panelType,
query.dataSource,
query.aggregateAttribute?.type,
showReduceTo,
]);
const resolvedFields = useMemo(
() =>
@@ -392,6 +434,13 @@ function QueryAddOns({
[handleChangeQueryData],
);
const handleChangeBucketOptions = useCallback(
(value: Querybuildertypesv5BucketOptionsDTO | undefined) => {
handleChangeQueryData('bucketOptions', value);
},
[handleChangeQueryData],
);
return (
<div className="query-add-ons" data-testid="query-add-ons">
{selectedViews.length > 0 && (
@@ -559,6 +608,19 @@ function QueryAddOns({
/>
</div>
)}
{selectedViews.find(
(view) => view.key === QueryBuilderField.BucketOptions,
) && (
<div className="add-on-content" data-testid="bucket-options-content">
<BucketOptions
bucketOptions={query.bucketOptions}
unit={currentQuery.unit}
onChange={handleChangeBucketOptions}
onClose={(): void => handleRemoveView(QueryBuilderField.BucketOptions)}
/>
</div>
)}
</div>
)}

View File

@@ -1,4 +1,4 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import {
fireEvent,
render,
@@ -26,8 +26,10 @@ jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
handleSetQueryData: typeof mockHandleSetQueryData;
currentQuery: { unit: string | undefined };
} => ({
handleSetQueryData: mockHandleSetQueryData,
currentQuery: { unit: undefined },
}),
}));
@@ -330,4 +332,158 @@ describe('QueryAddOns', () => {
expect.anything(),
);
});
describe('bucket options', () => {
function renderHeatmap(overrides: Partial<any> = {}): void {
render(
<QueryAddOns
query={baseQuery({ dataSource: DataSource.METRICS, ...overrides })}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.HEATMAP}
index={0}
isForTraceOperator={false}
/>,
);
}
it('is offered on a metrics heatmap only', () => {
renderHeatmap();
expect(
screen.getByTestId('query-add-on-bucket_options'),
).toBeInTheDocument();
});
it('is not offered on other panel types', () => {
render(
<QueryAddOns
query={baseQuery({ dataSource: DataSource.METRICS })}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
expect(
screen.queryByTestId('query-add-on-bucket_options'),
).not.toBeInTheDocument();
});
it('is not offered on a heatmap over another signal', () => {
render(
<QueryAddOns
query={baseQuery({ dataSource: DataSource.LOGS })}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.HEATMAP}
index={0}
isForTraceOperator={false}
/>,
);
expect(
screen.queryByTestId('query-add-on-bucket_options'),
).not.toBeInTheDocument();
});
it.each([ATTRIBUTE_TYPES.HISTOGRAM, ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM])(
'is not offered for a %s metric, which carries its own buckets',
(type) => {
renderHeatmap({
aggregateAttribute: { key: 'http_duration_bucket', type },
});
expect(
screen.queryByTestId('query-add-on-bucket_options'),
).not.toBeInTheDocument();
},
);
it("auto-opens on the query's own kind", () => {
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
expect(screen.getByTestId('bucket-options-content')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
expect(screen.getByRole('radio', { name: '1' })).toBeChecked();
});
it('sends no options for Auto', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByRole('radio', { name: 'Auto' }));
expect(mockHandleChangeQueryData).toHaveBeenCalledWith(
'bucketOptions',
undefined,
);
});
it('sends the scale the picked bands per doubling resolve to', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 4 } } });
await user.click(screen.getByRole('radio', { name: '1' }));
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
kind: 'log',
spec: { scale: 0 },
});
});
it('previews the bounds the picked axis will carry', () => {
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
const bounds = within(screen.getByTestId('bucket-options-bounds'));
['1', '2', '4', '8', '16', '32', '64', '128', '+Inf'].forEach((bound) => {
expect(bounds.getByText(bound)).toBeInTheDocument();
});
});
it('sends nothing for a linear axis until it has a max value', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByRole('radio', { name: 'Linear' }));
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
'bucketOptions',
undefined,
);
expect(
screen.getByText('Set a max value to see the bounds'),
).toBeInTheDocument();
});
it('sends the linear axis once a max value is filled in', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByRole('radio', { name: 'Linear' }));
await user.type(screen.getByTestId('bucket-options-max-value'), '500');
await waitFor(() => {
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
'bucketOptions',
{ kind: 'linear', spec: { maxValue: 500 } },
);
});
});
it('closes back to the toggle bar', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByTestId('bucket-options-close'));
expect(
screen.queryByTestId('bucket-options-content'),
).not.toBeInTheDocument();
});
});
});

View File

@@ -16,6 +16,7 @@ export enum QueryBuilderField {
Limit = 'limit',
Legend = 'legend_format',
ReduceTo = 'reduce_to',
BucketOptions = 'bucket_options',
// Builder level
Formula = 'formula',
AdditionalQueries = 'additional_queries',

View File

@@ -76,6 +76,7 @@ export const RAW_QUERY_FIELDS: Omit<
[QueryBuilderField.Limit]: { state: 'hidden' },
[QueryBuilderField.Legend]: { state: 'hidden' },
[QueryBuilderField.ReduceTo]: { state: 'hidden' },
[QueryBuilderField.BucketOptions]: { state: 'hidden' },
[QueryBuilderField.Formula]: { state: 'hidden' },
[QueryBuilderField.OrderBy]: { state: 'pinned' },
};

View File

@@ -15,7 +15,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import CheckboxFilterHeader from './CheckboxFilterHeader';
import CheckboxValueRow from './CheckboxValueRow';
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
import useActiveQueryIndex from './useActiveQueryIndex';
import useCheckboxDisclosure from './useCheckboxDisclosure';
import useCheckboxFilterActions from './useCheckboxFilterActions';
import useCheckboxFilterState from './useCheckboxFilterState';

View File

@@ -15,21 +15,13 @@ function useActiveQueryIndex(source: QuickFiltersSource): number {
const isListView = panelType === PANEL_TYPES.LIST;
return useMemo(() => {
// AI observability builds a single query in the row-level views, so its
// filters always drive the first one there.
if (source === QuickFiltersSource.AI_OBSERVABILITY) {
return isListView || panelType === PANEL_TYPES.TRACE
? 0
: lastUsedQuery || 0;
}
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, panelType, source, lastUsedQuery]);
}, [isListView, source, lastUsedQuery]);
}
export default useActiveQueryIndex;

View File

@@ -56,57 +56,6 @@ export function mockFieldsValuesAPI(response: {
);
}
/**
* Records every request the AI observability values endpoint receives, so a test
* can assert both the routing and the query params it was called with.
*/
export function mockAIObservabilityFieldsValuesAPI(response: {
relatedValues?: (string | null)[];
stringValues?: (string | null)[];
numberValues?: (number | null)[];
}): { requests: URLSearchParams[] } {
const requests: URLSearchParams[] = [];
server.use(
rest.get(
'http://localhost/api/v1/ai_observability/fields/values',
(req, res, ctx) => {
requests.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: response.relatedValues ?? [],
stringValues: response.stringValues ?? [],
numberValues: response.numberValues ?? [],
},
},
}),
);
},
),
);
return { requests };
}
/** Fails the test if the signal-wide values endpoint is hit at all. */
export function forbidFieldsValuesAPI(): { called: boolean } {
const state = { called: false };
server.use(
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) => {
state.called = true;
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
return state;
}
export function mockFieldsValuesAPILoading(): void {
server.use(
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>

View File

@@ -16,7 +16,7 @@ import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
import useActiveQueryIndex from '../useActiveQueryIndex';
import useCheckboxDisclosure from '../useCheckboxDisclosure';
import useCheckboxFilterActions from '../useCheckboxFilterActions';
import useCheckboxFilterState from '../useCheckboxFilterState';

View File

@@ -1,81 +0,0 @@
import { screen, waitFor } from '@testing-library/react';
import { render } from 'tests/test-utils';
import { QuickFiltersSource } from '../../../../types';
import CheckboxFilterV2 from '../CheckboxFilterV2';
import {
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
forbidFieldsValuesAPI,
mockAIObservabilityFieldsValuesAPI,
mockFieldsValuesAPI,
setupServer,
} from '../CheckboxFilterV2.testUtils';
setupServer();
describe('CheckboxFilterV2 - AI observability routing', () => {
it('reads values from the AI observability endpoint and never the signal-wide one', async () => {
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
stringValues: ['openai', 'anthropic'],
});
const fieldsEndpoint = forbidFieldsValuesAPI();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.AI_OBSERVABILITY}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await expect(screen.findByText('openai')).resolves.toBeInTheDocument();
expect(screen.getByText('anthropic')).toBeInTheDocument();
expect(fieldsEndpoint.called).toBe(false);
expect(aiEndpoint.requests).toHaveLength(1);
});
it('forwards the filter key and the time range to the AI observability endpoint', async () => {
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
stringValues: ['openai'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.AI_OBSERVABILITY}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByText('openai');
const params = aiEndpoint.requests[0];
expect(params.get('name')).toBe(DEFAULT_FILTER.attributeKey.key);
expect(params.get('startUnixMilli')).toBe(
String(DEFAULT_USE_FIELD_APIS.startUnixMilli),
);
expect(params.get('endUnixMilli')).toBe(
String(DEFAULT_USE_FIELD_APIS.endUnixMilli),
);
});
it('keeps non-AI sources on the signal-wide endpoint', async () => {
mockFieldsValuesAPI({ stringValues: ['production'] });
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
stringValues: ['should-not-be-used'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await expect(screen.findByText('production')).resolves.toBeInTheDocument();
await waitFor(() => expect(aiEndpoint.requests).toHaveLength(0));
});
});

View File

@@ -1,12 +1,11 @@
import { useMemo } from 'react';
import { useGetFieldsValues } from 'api/generated/services/fields';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { FieldValuesConfig } from 'api/querySuggestions/types';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { useFieldValuesSuggestion } from 'hooks/querySuggestions/useFieldValuesSuggestion';
import { BuilderQueryType } from 'types/api/v5/queryRange';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
import { DATA_SOURCE_TO_SIGNAL } from 'types/common/queryBuilder';
interface UseFieldValuesProps {
@@ -43,43 +42,32 @@ export function useFieldValues({
endUnixMilli,
enabled,
}: UseFieldValuesProps): UseFieldValuesReturn {
const isAIObservability = source === QuickFiltersSource.AI_OBSERVABILITY;
const builderQueryType: BuilderQueryType | undefined = isAIObservability
? 'builder_ai_query'
: undefined;
// The AI values endpoint is already gen_ai-scoped: no signal, no source.
const fieldValuesConfig: FieldValuesConfig = isAIObservability
? {
name: filter.attributeKey.key,
searchText,
existingQuery,
startUnixMilli,
endUnixMilli,
}
: {
signal: filter.dataSource
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
: undefined,
name: filter.attributeKey.key,
searchText,
existingQuery,
metricNamespace,
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
startUnixMilli,
// This field does not affect the backend but I wanted to keep it here
// in case we add the support in the future
endUnixMilli,
};
const {
data: values,
isLoading,
isFetching,
} = useFieldValuesSuggestion(fieldValuesConfig, builderQueryType, { enabled });
const { data, isLoading, isFetching } = useGetFieldsValues(
{
signal: filter.dataSource
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
: undefined,
name: filter.attributeKey.key,
searchText,
existingQuery,
metricNamespace,
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
startUnixMilli,
// This field does not affect the backend but I wanted to keep it here
// in case we add the support in the future
endUnixMilli,
},
{
query: {
enabled,
cacheTime: FIELD_API_CACHE_TIME,
keepPreviousData: true,
},
},
);
const relatedValues: string[] = useMemo(() => {
const values = data?.data?.values;
if (!values) {
return [];
}
@@ -90,9 +78,10 @@ export function useFieldValues({
value !== null && value !== undefined && value !== '',
) || []
);
}, [values]);
}, [data]);
const allValues: string[] = useMemo(() => {
const values = data?.data?.values;
if (!values) {
return [];
}
@@ -112,7 +101,7 @@ export function useFieldValues({
.map((value) => value.toString()) || [];
return [...stringValues, ...numberValues, ...boolValues];
}, [values]);
}, [data]);
return { relatedValues, allValues, isLoading, isFetching };
}

View File

@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Collapse } from 'antd';
import { Undo2 } from '@signozhq/icons';
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -39,7 +39,7 @@ function Duration({
}: {
filter: IQuickFiltersConfig;
onFilterChange?: (query: Query) => void;
source: QuickFiltersSource;
source?: QuickFiltersSource;
}): JSX.Element {
const [selectedFilters, setSelectedFilters] =
useState<
@@ -52,11 +52,26 @@ function Duration({
filter.defaultOpen ? 'durationNano' : '',
]);
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const {
currentQuery,
redirectWithQueryBuilderData,
lastUsedQuery,
panelType,
} = useQueryBuilder();
const compositeQuery = useGetCompositeQueryParam();
const activeQueryIndex = useActiveQueryIndex(source);
const isListView = panelType === PANEL_TYPES.LIST;
// In ListView mode, use index 0 for most sources; for TRACES_EXPLORER, use lastUsedQuery
// Otherwise use lastUsedQuery for non-ListView modes
const activeQueryIndex = useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
// eslint-disable-next-line sonarjs/cognitive-complexity
const syncSelectedFilters = useMemo((): FilterType => {

View File

@@ -35,7 +35,6 @@ import { isFunction } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
import useActiveQueryIndex from './hooks/useActiveQueryIndex';
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
import Duration from './FilterRenderers/Duration/Duration';
import Slider from './FilterRenderers/Slider/Slider';
@@ -114,13 +113,14 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const shouldShowDropdownInListView =
isListView && source === QuickFiltersSource.TRACES_EXPLORER;
// AI observability builds a single query in the row-level views, so there is
// no query for the selector to switch between.
const isAIObservabilityRowView =
source === QuickFiltersSource.AI_OBSERVABILITY &&
(isListView || panelType === PANEL_TYPES.TRACE);
const activeQueryIndex = useActiveQueryIndex(source);
const activeQueryIndex = useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
// clear all the filters for the query which is in sync with filters
const handleReset = (): void => {
@@ -167,10 +167,9 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName;
// In ListView, always show the 0th query's name; otherwise use the active query's name
const displayedQueryName =
isListView || isAIObservabilityRowView
? showQueryName && currentQuery.builder.queryData?.[0]?.queryName
: lastQueryName;
const displayedQueryName = isListView
? showQueryName && currentQuery.builder.queryData?.[0]?.queryName
: lastQueryName;
const handleQueryChange = (value: number): void => {
setLastUsedQuery(value);
@@ -183,9 +182,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
<Typography.Text className="text">
{displayedQueryName ? 'Filters for' : 'Filters'}
</Typography.Text>
{queryOptions.length > 1 &&
!isAIObservabilityRowView &&
(!isListView || shouldShowDropdownInListView) ? (
{queryOptions.length > 1 && (!isListView || shouldShowDropdownInListView) ? (
<Combobox open={open} onOpenChange={setOpen}>
<ComboboxTrigger
placeholder="Select a query"
@@ -321,7 +318,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
return (
<Duration
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>

View File

@@ -1,14 +1,12 @@
import { useMemo } from 'react';
import { Button, Skeleton } from 'antd';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { FieldKeysConfig } from 'api/querySuggestions/types';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import {
BuilderQueryType,
FieldContext,
FieldDataType,
TelemetryFieldKey,
@@ -43,31 +41,23 @@ function OtherFilters({
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
}): JSX.Element {
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
const isAIObservability = signal === SignalType.AI_OBSERVABILITY;
const builderQueryType: BuilderQueryType | undefined = isAIObservability
? 'builder_ai_query'
: undefined;
const fieldKeysConfig: FieldKeysConfig = isAIObservability
? { searchText: inputValue }
: {
searchText: inputValue,
signal: signal
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
: undefined,
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
};
const { data: fetchedKeys, isFetching } = useFieldKeysSuggestion(
fieldKeysConfig,
builderQueryType,
const { data, isFetching } = useGetFieldsKeys(
{
searchText: inputValue,
signal: signal
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
: undefined,
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
},
{ query: { enabled: !!signal } },
);
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
const rawSuggestions = Object.values(data?.data?.keys ?? {}).flat();
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
// add, render) can trust it.
const suggestions: TelemetryFieldKey[] = (fetchedKeys ?? []).map((attr) => ({
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
name: attr.name,
signal: attr.signal as TelemetryFieldKey['signal'],
fieldContext: attr.fieldContext as FieldContext,
@@ -81,7 +71,7 @@ function OtherFilters({
),
);
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
}, [fetchedKeys, addedFilters]);
}, [data, addedFilters]);
const handleAddFilter = (filter: TelemetryFieldKey): void => {
setAddedFilters((prev) => [...prev, filter]);

View File

@@ -1,81 +0,0 @@
import { screen, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render } from 'tests/test-utils';
import { SignalType } from '../../types';
import OtherFilters from '../OtherFilters';
const BASE_URL = ENVIRONMENT.baseURL;
const FIELDS_KEYS_URL = `${BASE_URL}/api/v1/fields/keys`;
const AI_KEYS_URL = `${BASE_URL}/api/v1/ai_observability/fields/keys`;
function keysResponse(name: string): Record<string, unknown> {
return {
status: 'success',
data: {
complete: true,
keys: {
[name]: [{ name, fieldContext: 'attribute', fieldDataType: 'string' }],
},
},
};
}
describe('OtherFilters - AI observability keys', () => {
let fieldsKeysCalled: boolean;
let aiKeysParams: URLSearchParams | undefined;
beforeEach(() => {
fieldsKeysCalled = false;
aiKeysParams = undefined;
server.use(
rest.get(FIELDS_KEYS_URL, (_, res, ctx) => {
fieldsKeysCalled = true;
return res(ctx.status(200), ctx.json(keysResponse('http.route')));
}),
rest.get(AI_KEYS_URL, (req, res, ctx) => {
aiKeysParams = req.url.searchParams;
return res(ctx.status(200), ctx.json(keysResponse('gen_ai.request.model')));
}),
);
});
function renderOtherFilters(signal: SignalType): void {
render(
<OtherFilters
signal={signal}
inputValue=""
addedFilters={[]}
setAddedFilters={jest.fn()}
/>,
);
}
it('reads AI observability keys from their own endpoint', async () => {
renderOtherFilters(SignalType.AI_OBSERVABILITY);
await expect(
screen.findByText('gen_ai.request.model'),
).resolves.toBeInTheDocument();
expect(fieldsKeysCalled).toBe(false);
});
it('does not narrow the AI keys by fieldContext', async () => {
renderOtherFilters(SignalType.AI_OBSERVABILITY);
// A `trace` context would return only the computed per-trace aggregates,
// which cannot be filtered on.
await waitFor(() => expect(aiKeysParams).toBeDefined());
expect(aiKeysParams?.get('fieldContext')).toBeNull();
});
it('keeps other signals on the signal-wide keys endpoint', async () => {
renderOtherFilters(SignalType.TRACES);
await expect(screen.findByText('http.route')).resolves.toBeInTheDocument();
await waitFor(() => expect(aiKeysParams).toBeUndefined());
});
});

View File

@@ -7,5 +7,4 @@ export const SIGNAL_DATA_SOURCE_MAP = {
[SignalType.EXCEPTIONS]: DataSource.TRACES,
[SignalType.API_MONITORING]: DataSource.TRACES,
[SignalType.METER_EXPLORER]: DataSource.METRICS,
[SignalType.AI_OBSERVABILITY]: DataSource.TRACES,
};

View File

@@ -1,81 +0,0 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { QuickFiltersSource } from '../../types';
import useActiveQueryIndex from '../useActiveQueryIndex';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
const LAST_USED_QUERY = 2;
function mockQueryBuilder(panelType: PANEL_TYPES): void {
(useQueryBuilder as jest.Mock).mockReturnValue({
lastUsedQuery: LAST_USED_QUERY,
panelType,
});
}
describe('useActiveQueryIndex', () => {
describe('AI observability builds a single query in the row-level views', () => {
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
'drives the first query in %s',
(panelType) => {
mockQueryBuilder(panelType);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.AI_OBSERVABILITY),
);
expect(result.current).toBe(0);
},
);
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
'follows the last used query in %s',
(panelType) => {
mockQueryBuilder(panelType);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.AI_OBSERVABILITY),
);
expect(result.current).toBe(LAST_USED_QUERY);
},
);
});
describe('other sources are unchanged', () => {
it('lets the traces explorer track the last used query in list view', () => {
mockQueryBuilder(PANEL_TYPES.LIST);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.TRACES_EXPLORER),
);
expect(result.current).toBe(LAST_USED_QUERY);
});
it('pins single-query sources to the first query in list view', () => {
mockQueryBuilder(PANEL_TYPES.LIST);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.INFRA_MONITORING),
);
expect(result.current).toBe(0);
});
it('tracks the last used query outside list view', () => {
mockQueryBuilder(PANEL_TYPES.TIME_SERIES);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.LOGS_EXPLORER),
);
expect(result.current).toBe(LAST_USED_QUERY);
});
});
});

View File

@@ -24,7 +24,6 @@ export enum SignalType {
API_MONITORING = 'api_monitoring',
EXCEPTIONS = 'exceptions',
METER_EXPLORER = 'meter',
AI_OBSERVABILITY = 'ai_observability',
}
/**
@@ -70,7 +69,6 @@ export enum QuickFiltersSource {
API_MONITORING = 'api-monitoring',
EXCEPTIONS = 'exceptions',
METER_EXPLORER = 'meter',
AI_OBSERVABILITY = 'ai-observability',
}
/**

View File

@@ -31,6 +31,8 @@ export const getComponentForPanelType = (
[PANEL_TYPES.BAR]: Uplot,
[PANEL_TYPES.PIE]: null,
[PANEL_TYPES.HISTOGRAM]: Uplot,
// V2-only kind; it renders through the V2 panel registry.
[PANEL_TYPES.HEATMAP]: null,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,

View File

@@ -338,6 +338,7 @@ export enum PANEL_TYPES {
BAR = 'bar',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

View File

@@ -527,6 +527,21 @@ export const metricsHistogramSpaceAggregateOperatorOptions: SelectOption<
},
];
/**
* A heatmap's Y axis is the `le` labels themselves, so every percentile draws the grid a
* count already draws. Sum is also what the statement builder forces on a histogram
* heatmap whatever is asked for, so it is the only honest option to offer.
*/
export const metricsHeatmapHistogramSpaceAggregateOperatorOptions: SelectOption<
string,
string
>[] = [
{
value: MetricAggregateOperator.COUNT,
label: 'Count',
},
];
export const metricsEmptyTimeAggregateOperatorOptions: SelectOption<
string,
string

View File

@@ -109,9 +109,6 @@ export const REACT_QUERY_KEY = {
// Field Keys Suggestion Query Keys
FIELD_KEYS_SUGGESTION: 'FIELD_KEYS_SUGGESTION',
// Field Values Suggestion Query Keys
FIELD_VALUES_SUGGESTION: 'FIELD_VALUES_SUGGESTION',
// AI Assistant Query Keys
AI_ASSISTANT_EMPTY_STATE_CHIPS: 'AI_ASSISTANT_EMPTY_STATE_CHIPS',
} as const;

View File

@@ -467,6 +467,7 @@ describe('Footer utils', () => {
timeAggregation: 'avg',
},
],
bucketOptions: undefined,
disabled: false,
filter: {
expression: '',

View File

@@ -8,13 +8,18 @@ import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { initialQueryAIWithType } from 'constants/queryBuilder';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { useOptionsMenu } from 'container/OptionsMenu';
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import Toolbar from 'container/Toolbar/Toolbar';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
@@ -23,6 +28,7 @@ import {
useHandleExplorerTabChange,
} from 'hooks/useHandleExplorerTabChange';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isEmpty } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
@@ -31,7 +37,7 @@ import {
tracesChangeViewAction,
tracesRunQueryAction,
tracesSaveViewAction,
} from './aiActions';
} from 'pages/TracesExplorer/aiActions';
import { Warning } from 'types/api';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -39,10 +45,12 @@ import {
explorerViewToPanelType,
getExplorerViewFromUrl,
} from 'utils/explorerUtils';
import { v4 } from 'uuid';
import LeftToolbarActions from '../ToolbarActions/LeftToolbarActions';
import { DEFAULT_PANEL_TYPE, TOOLBAR_VIEWS } from './constants';
import { TOOLBAR_VIEWS } from './constants';
import { getExportQueryData, getQueryByPanelType } from './explorerUtils';
import ListView from './ListView/ListView';
import { defaultSelectedColumns } from './ListView/configs';
import QuerySection from './QuerySection/QuerySection';
import TableView from './TableView/TableView';
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
@@ -52,6 +60,7 @@ import './Explorer.styles.scss';
function Explorer(): JSX.Element {
const {
panelType,
updateAllQueriesOperators,
handleRunQuery,
stagedQuery,
@@ -63,12 +72,20 @@ function Explorer(): JSX.Element {
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const { options } = useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const listQueryKeyRef = useRef<any>();
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
const [isCancelled, setIsCancelled] = useState(false);
@@ -95,24 +112,19 @@ function Explorer(): JSX.Element {
const [warning, setWarning] = useState<Warning | undefined>();
const [isOpen, setOpen] = useState<boolean>(true);
const { startUnixMilli, endUnixMilli } = useSignalFieldApis();
// existingQuery is left unset so related values auto-extract from the current query
const quickFiltersFieldApis = useMemo(
() => ({ startUnixMilli, endUnixMilli }),
[startUnixMilli, endUnixMilli],
);
const defaultQuery = useMemo(
(): Query =>
updateAllQueriesOperators(
initialQueryAIWithType,
DEFAULT_PANEL_TYPE,
PANEL_TYPES.LIST,
DataSource.TRACES,
),
[updateAllQueriesOperators],
);
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const handleChangeSelectedView = useCallback(
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
@@ -127,7 +139,7 @@ function Explorer(): JSX.Element {
},
[handleExplorerTabChange, handleSetConfig],
);
//TODO: check if we need to enable AI Assistant page actions on LLM o11y
// ─── AI Assistant page actions (only when license feature is on) ───────────
const aiActions = useMemo(
() =>
@@ -167,6 +179,59 @@ function Explorer(): JSX.Element {
usePageActions('traces-explorer', aiActions);
// ───────────────────────────────────────────────────────────────────────────
const exportDefaultQuery = useMemo(
() =>
getQueryByPanelType(
stagedQuery || initialQueryAIWithType,
panelType || PANEL_TYPES.LIST,
),
[stagedQuery, panelType],
);
const handleExport = useCallback(
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
if (!dashboard || !panelType) {
return;
}
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
? panelType
: PANEL_TYPES.TIME_SERIES;
const widgetId = v4();
const query = getExportQueryData(
exportDefaultQuery,
panelTypeParam,
options,
);
logEvent('Traces Explorer: Add to dashboard successful', {
panelType,
isNewDashboard,
dashboardName: dashboard?.title,
});
const dashboardEditView = getExportToDashboardLink({
query,
panelType: panelTypeParam,
dashboardId: dashboard.id,
widgetId,
});
if (dashboardEditView) {
safeNavigate(dashboardEditView);
}
},
[
exportDefaultQuery,
panelType,
safeNavigate,
options,
getExportToDashboardLink,
],
);
useShareBuilderUrl({ defaultValue: defaultQuery });
const logEventCalledRef = useRef(false);
@@ -195,9 +260,8 @@ function Explorer(): JSX.Element {
<Card className="filter" hidden={!isOpen}>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.AI_OBSERVABILITY}
signal={SignalType.AI_OBSERVABILITY}
useFieldApis={quickFiltersFieldApis}
source={QuickFiltersSource.TRACES_EXPLORER}
signal={SignalType.TRACES}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
@@ -290,6 +354,14 @@ function Explorer(): JSX.Element {
</div>
)}
</div>
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}
sourcepage={DataSource.TRACES}
onExport={handleExport}
handleChangeSelectedView={handleChangeSelectedView}
/>
</div>
</div>
</Sentry.ErrorBoundary>

View File

@@ -12,17 +12,25 @@ import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import { getTraceLink, transformSpanRows } from './utils';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import {
getTraceLink,
transformSpanRows,
} from 'container/TracesExplorer/ListView/utils';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
@@ -34,7 +42,6 @@ import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import TraceExplorerControls from '../Controls';
import { getListViewQuery } from '../explorerUtils';
import {
defaultSelectedColumns,
@@ -72,6 +79,14 @@ function ListView({
loading: timeRangeUpdateLoading,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { options, config } = useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
@@ -83,6 +98,19 @@ function ListView({
[stagedQuery, orderBy],
);
// Stable sorted-name signature for the queryKey.
// - Drag updates selectColumns; raw queryKey would churn on reorder.
// - Trace API fetches only listed columns → add/remove must refetch.
// - Sorted-name signature: stable on reorder, changes on add/remove.
const selectColumnsSignature = useMemo(
() =>
(options?.selectColumns ?? [])
.map((c) => c.name)
.sort()
.join(','),
[options?.selectColumns],
);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
@@ -92,6 +120,7 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
orderBy,
],
[
@@ -99,6 +128,7 @@ function ListView({
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
orderBy,
@@ -120,7 +150,7 @@ function ListView({
},
tableParams: {
pagination: paginationConfig,
selectColumns: defaultSelectedColumns,
selectColumns: options?.selectColumns,
},
},
ENTITY_VERSION_V5,
@@ -128,7 +158,10 @@ function ListView({
queryKey,
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
!timeRangeUpdateLoading &&
!!stagedQuery &&
panelType === PANEL_TYPES.LIST &&
!!options?.selectColumns?.length,
},
);
@@ -153,20 +186,28 @@ function ListView({
[queryTableDataResult],
);
// TODO(ai-explorer): static columns until the preferences framework lands.
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(
() =>
[TIMESTAMP_FIELD, ...defaultSelectedColumns].map((field) =>
getFieldColumn(field),
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
const fields = [
TIMESTAMP_FIELD,
...(options?.selectColumns ?? []).filter(
(field) => field.name !== TIMESTAMP_FIELD.name,
),
[],
);
];
return fields.map((field) => getFieldColumn(field));
}, [options?.selectColumns]);
const rows = useMemo(
() => transformSpanRows(queryTableData),
[queryTableData],
);
const handleColumnOrderChange = useCallback(
(reordered: TableColumnDef<TracesTableRow>[]): void => {
config?.addColumn?.onReorder(reordered.map((column) => column.id));
},
[config],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
@@ -194,9 +235,15 @@ function ListView({
/>
</div>
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
selectedColumns={options?.selectColumns}
/>
<TraceExplorerControls
isLoading={isFetching}
totalCount={rows.length}
config={config}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
@@ -204,8 +251,6 @@ function ListView({
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
respectColumnOrder
panelType="LIST"
getRowHref={getTraceLink}
isLoading={isLoading}
@@ -213,6 +258,8 @@ function ListView({
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
/>
</div>
);

View File

@@ -1,41 +1,19 @@
import type { TelemetryFieldKey } from 'api/v5/v5';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const defaultSelectedColumns: string[] = [
'service.name',
'name',
'duration_nano',
'http_method',
'response_status_code',
'timestamp',
];
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
// Pinned timestamp column
// The list query returns timestamp, trace_id and span_id whether or not they are selected.
export const TIMESTAMP_FIELD = {
name: 'timestamp',
fieldContext: 'span',
} as TelemetryFieldKey;
export const defaultSelectedColumns: TelemetryFieldKey[] = [
{
name: 'service.name',
signal: 'traces',
fieldContext: 'resource',
fieldDataType: 'string',
},
{
name: 'name',
signal: 'traces',
fieldContext: 'span',
fieldDataType: 'string',
},
{
name: 'duration_nano',
signal: 'traces',
fieldContext: 'span',
},
{
name: 'http_method',
signal: 'traces',
fieldContext: 'span',
},
{
name: 'response_status_code',
signal: 'traces',
fieldContext: 'span',
},
];
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];

View File

@@ -1,8 +1,47 @@
import { Link } from 'react-router-dom';
import type { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { formUrlParams } from 'container/TraceDetail/utils';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { ILog } from 'types/api/logs/log';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
export function BlockLink({
children,
to,
openInNewTab,
}: {
children: React.ReactNode;
to: string;
openInNewTab: boolean;
}): any {
// Display block to make the whole cell clickable
return (
<Link
to={to}
style={{ display: 'block' }}
target={openInNewTab ? '_blank' : '_self'}
>
{children}
</Link>
);
}
export const transformDataWithDate = (
data: QueryDataV3[],
): Omit<ILog, 'timestamp'>[] =>
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
[];
export const getTraceLink = (record: Record<string, unknown>): string => {
function readId(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') {
@@ -21,6 +60,95 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
})}`;
};
export const getListColumns = (
selectedColumns: TelemetryFieldKey[],
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string | number,
): ColumnsType<RowData> => {
const initialColumns: ColumnsType<RowData> = [
{
dataIndex: 'date',
key: 'date',
title: 'Timestamp',
width: 145,
render: (value, item): JSX.Element => {
const date =
typeof value === 'string'
? formatTimezoneAdjustedTimestamp(
value,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
)
: formatTimezoneAdjustedTimestamp(
value / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography.Text>{date}</Typography.Text>
</BlockLink>
);
},
},
];
const columns: ColumnsType<RowData> =
selectedColumns.map((props) => {
const name = props?.name || (props as any)?.key;
const fieldContext = props?.fieldContext || (props as any)?.type;
return {
title: name,
dataIndex: name,
key: buildCompositeKey(name, fieldContext),
width: 145,
render: (value, item): JSX.Element => {
if (value === '') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>N/A</Typography>
</BlockLink>
);
}
if (
name === 'httpMethod' ||
name === 'responseStatusCode' ||
name === 'response_status_code' ||
name === 'http_method'
) {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Badge data-testid={name} color="sakura" variant="outline">
{value}
</Badge>
</BlockLink>
);
}
if (name === 'durationNano' || name === 'duration_nano') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>{getMs(value)}ms</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>
<LineClampedText text={value} lines={3} />
</Typography>
</BlockLink>
);
},
responsive: ['md'],
};
}) || [];
return [...initialColumns, ...columns];
};
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
// positional ids; `timestamp` is lifted from the wrapping ListItem.

View File

@@ -4,10 +4,8 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
import { DEFAULT_PANEL_TYPE } from '../constants';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const isRawQuery = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,

View File

@@ -107,7 +107,7 @@ function TableView({
dataSource={DataSource.TRACES}
data={data}
query={stagedQuery || initialQueriesMap.traces}
fileName="ai-traces-table"
fileName="traces-table"
/>
</div>
)}

View File

@@ -126,7 +126,6 @@ function TimeSeriesViewContainer({
dataSource={dataSource}
setWarning={setWarning}
allowExport
exportFileName="ai-traces-timeseries"
/>
</div>
);

View File

@@ -55,9 +55,6 @@ function TracesTable({
const isDataAbsent =
!isLoading && !isFetching && !isError && data.length === 0;
// Rows can land before the field keys, and mounting then renders a partial column set.
const canMountTable = !isError && !isLoading && data.length !== 0;
const handleRowClick = useCallback(
(row: TracesTableRow): void => {
history.push(getRowHref(row));
@@ -86,7 +83,7 @@ function TracesTable({
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
)}
{canMountTable && (
{!isError && data.length !== 0 && (
<div className={styles.tableWrapper}>
<TanStackTable<TracesTableRow>
data={data}

View File

@@ -1,72 +0,0 @@
import { useState } from 'react';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { render, screen, userEvent } from 'tests/test-utils';
import { buildTraceViewColumns } from '../../TracesView/configs';
import TracesTable from '../TracesTable';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
const COLUMNS = buildTraceViewColumns([
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'start_time' },
]);
function RaceHarness(): JSX.Element {
const [columnsReady, setColumnsReady] = useState(false);
return (
<>
<button type="button" onClick={(): void => setColumnsReady(true)}>
columns-ready
</button>
<TracesTable
data={ROWS}
columns={columnsReady ? COLUMNS : []}
columnStorageKey={STORAGE_KEY}
respectColumnOrder
panelType="TRACE"
getRowHref={(): string => '/trace/abc'}
isLoading={!columnsReady}
isFetching={false}
isError={false}
error={null}
isFilterApplied={false}
/>
</>
);
}
const persistedState = (): { hiddenColumnIds: string[] } | null => {
const raw = localStorage.getItem(PERSISTED_KEY);
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
};
describe('TracesTable column-init race', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
});
it('does not persist empty defaults when rows land before columns', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<RaceHarness />);
expect(screen.getByText(/pending_data_placeholder/i)).toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(persistedState()).toBeNull();
await user.click(screen.getByRole('button', { name: 'columns-ready' }));
await expect(screen.findByRole('table')).resolves.toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.queryByText('start_time')).not.toBeInTheDocument();
expect(persistedState()?.hiddenColumnIds).toStrictEqual(['start_time']);
});
});

View File

@@ -1,13 +1,6 @@
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
// camelCase and snake_case variants are listed because the API has shipped both.
// start/end/last_activity_time come from the per-trace query, unlike span timestamp.
export const TIMESTAMP_FIELD_NAMES = new Set([
'timestamp',
'start_time',
'end_time',
'last_activity_time',
]);
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
export const STATUS_FIELD_NAMES = new Set([
'httpMethod',
@@ -20,12 +13,6 @@ export const STATUS_FIELD_NAMES = new Set([
'http.response.status_code',
]);
// trace_/max_llm_duration_nano are trace-level durations the per-trace query computes.
export const DURATION_FIELD_NAMES = new Set([
'durationNano',
'duration_nano',
'trace_duration_nano',
'max_llm_duration_nano',
]);
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);

View File

@@ -67,7 +67,6 @@ function TracesView({
onFieldsChange,
requiredFields,
isLoading: isColumnsLoading,
canPersistColumns,
} = useTraceViewColumns();
const {
@@ -169,22 +168,9 @@ function TracesView({
setOrderBy(value);
}, []);
// Without the full column set there is no pool to pick from, so the control is dropped.
const fieldsSelectorConfig = useMemo(
() =>
canPersistColumns
? { fieldsSelector: { value: selectedFields, onFieldsChange } }
: null,
[canPersistColumns, selectedFields, onFieldsChange],
);
// Rendering the pool unfiltered would surface columns the defaults keep hidden.
const tableColumns = useMemo(
() =>
canPersistColumns
? columns
: columns.filter((column) => column.defaultVisibility !== false),
[canPersistColumns, columns],
() => ({ fieldsSelector: { value: selectedFields, onFieldsChange } }),
[selectedFields, onFieldsChange],
);
return (
@@ -221,12 +207,8 @@ function TracesView({
<TracesTable
data={rows}
columns={tableColumns}
columnStorageKey={
canPersistColumns
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
: undefined
}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
respectColumnOrder
panelType="TRACE"
getRowHref={getTraceLink}

View File

@@ -1,190 +0,0 @@
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import TracesView from '../TracesView';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
const FIELD_KEYS_URL = `${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`;
const OPTIONS_TRIGGER = 'options_menu.options';
const ROWS = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
'service.name': 'checkout',
root_span_name: 'HTTP GET',
trace_duration_nano: 55306000,
span_count: 8,
trace_id: '0000000000000000344ded1387b08a7e',
},
},
];
const mockRows = (): void => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: {
type: 'trace',
data: { results: [{ queryName: 'A', rows: ROWS }] },
},
}),
),
),
);
};
const mockFieldKeys = (names: string[]): void => {
server.use(
rest.get(FIELD_KEYS_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
names.map((name) => [
name,
[
{
name,
fieldContext: TelemetrytypesFieldContextDTO.trace,
fieldDataType: TelemetrytypesFieldDataTypeDTO.float64,
},
],
]),
),
},
}),
),
),
);
};
const mockFieldKeysFailure = (): void => {
server.use(
rest.get(FIELD_KEYS_URL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
};
const persistedState = (): { hiddenColumnIds: string[] } | null => {
const raw = localStorage.getItem(PERSISTED_KEY);
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
};
const renderTracesView = (): ReturnType<typeof render> =>
render(
<TracesView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
/>,
{},
{
initialRoute: '/llm-observability/traces',
queryBuilderOverrides: {
panelType: PANEL_TYPES.TRACE,
stagedQuery: initialQueryAIWithType,
currentQuery: initialQueryAIWithType,
} as never,
},
);
describe('TracesView column persistence', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
mockRows();
});
afterEach(() => {
server.resetHandlers();
});
// Rows are virtualised, so a mounted table stands in for "rows arrived".
const findTable = (): Promise<HTMLElement> => screen.findByRole('table');
it('seeds the persisted defaults once the field keys arrive', async () => {
mockFieldKeys(['llm_call_count', 'tool_call_count']);
renderTracesView();
await findTable();
await waitFor(() => {
expect(persistedState()?.hiddenColumnIds).toStrictEqual([
'start_time',
'end_time',
'error_count',
'input',
'output',
'trace:tool_call_count:float64',
]);
});
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
it('persists nothing when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(persistedState()).toBeNull();
});
it('drops the column picker when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
});
it('renders only the default-visible columns when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(screen.getByText('root_span_name')).toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.queryByText('input')).not.toBeInTheDocument();
expect(screen.queryByText('output')).not.toBeInTheDocument();
});
it('leaves an existing selection untouched while the field keys fail', async () => {
const existing = {
hiddenColumnIds: ['trace:tool_call_count:float64', 'input', 'output'],
columnOrder: ['trace_id', 'resource:service.name'],
columnSizing: {},
};
localStorage.setItem(PERSISTED_KEY, JSON.stringify(existing));
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(persistedState()).toStrictEqual(existing);
});
});

View File

@@ -149,62 +149,6 @@ describe('useTraceViewColumns', () => {
);
});
describe('when the keys fetch fails', () => {
beforeEach(() => {
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(_req, res, ctx) => res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
});
it('does not persist defaults', async () => {
await renderColumns();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(
localStorage.getItem(`@signoz/table-columns/${STORAGE_KEY}`),
).toBeNull();
});
it('reports the column state as not persistable', async () => {
const { result } = await renderColumns();
expect(result.current.canPersistColumns).toBe(false);
});
it('ignores a selection change instead of persisting a partial set', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }]);
});
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
});
it('seeds the defaults once a later fetch succeeds', async () => {
const { unmount } = await renderColumns();
unmount();
mockAggregateKeys(AGGREGATE_KEYS);
const { result } = await renderColumns();
expect(result.current.canPersistColumns).toBe(true);
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'service.name',
'root_span_name',
'trace_duration_nano',
'span_count',
'trace_id',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
]);
});
});
it('hides the columns dropped from the selection', async () => {
const { result } = await renderColumns();

View File

@@ -8,7 +8,7 @@ export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
/** Always visible: it is the row's link to the trace. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
/** Everything else starts hidden; only applied at first init, since the store persists hidden ids. */
/** Everything else starts hidden, including any aggregate the endpoint adds later. */
const DEFAULT_VISIBLE_FIELDS = new Set([
'service.name',
'root_span_name',

View File

@@ -35,17 +35,11 @@ interface UseTraceViewColumns {
onFieldsChange: (next: TelemetryFieldKey[]) => void;
requiredFields: readonly string[];
isLoading: boolean;
/** False until the keys fetch lands; a partial set must not reach the persisted store. */
canPersistColumns: boolean;
}
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
export function useTraceViewColumns(): UseTraceViewColumns {
const {
data: fetchedFields = [],
isFetched,
isSuccess,
} = useFieldKeysSuggestion(
const { data: fetchedFields = [], isFetched } = useFieldKeysSuggestion(
{
...TRACE_VIEW_FIELD_KEYS,
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
@@ -66,10 +60,10 @@ export function useTraceViewColumns(): UseTraceViewColumns {
// Defaults from a partial column set would persist as the user's own choice.
useEffect(() => {
if (isSuccess) {
if (isFetched) {
initializeFromDefaults(STORAGE_KEY, columns);
}
}, [isSuccess, columns]);
}, [isFetched, columns]);
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
const columnOrder = useColumnOrder(STORAGE_KEY);
@@ -89,10 +83,6 @@ export function useTraceViewColumns(): UseTraceViewColumns {
const onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => {
if (!isSuccess) {
return;
}
const keptIds = new Set(next.map(columnIdOf));
columns.forEach((column) => {
@@ -106,7 +96,7 @@ export function useTraceViewColumns(): UseTraceViewColumns {
// Columns missing from the order sort last, so the visible ones suffice.
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
},
[columns, isSuccess],
[columns],
);
return {
@@ -115,6 +105,5 @@ export function useTraceViewColumns(): UseTraceViewColumns {
onFieldsChange,
requiredFields: [TRACE_ID_COLUMN_ID],
isLoading: !isFetched,
canPersistColumns: isSuccess,
};
}

View File

@@ -1,17 +1,7 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
export const TOOLBAR_VIEWS = {
trace: {
name: 'trace',
label: 'Trace',
disabled: false,
show: true,
key: 'trace',
},
list: {
name: 'list',
label: 'List',
@@ -25,6 +15,13 @@ export const TOOLBAR_VIEWS = {
show: true,
key: 'timeseries',
},
trace: {
name: 'trace',
label: 'Trace',
disabled: false,
show: true,
key: 'trace',
},
table: {
name: 'table',
label: 'Table',

View File

@@ -1,5 +1,6 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { cloneDeep } from 'lodash-es';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { cloneDeep, set } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
export const getListViewQuery = (
@@ -30,3 +31,31 @@ export const getListViewQuery = (
return query;
};
export const getQueryByPanelType = (
stagedQuery: Query,
panelType: PANEL_TYPES,
): Query => {
if (panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE) {
return getListViewQuery(stagedQuery);
}
return stagedQuery;
};
export const getExportQueryData = (
query: Query,
panelType: PANEL_TYPES,
options: OptionsQuery,
): Query => {
if (panelType === PANEL_TYPES.LIST) {
const updatedQuery = cloneDeep(query);
set(
updatedQuery,
'builder.queryData[0].selectColumns',
options.selectColumns,
);
return updatedQuery;
}
return query;
};

View File

@@ -1,21 +1,19 @@
import { ArrowUpToLine, Filter } from '@signozhq/icons';
import {
ArrowUpToLine,
Atom,
Filter,
SquareMousePointer,
Terminal,
Binoculars,
} from '@signozhq/icons';
import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { TOOLBAR_VIEW_CONFIG } from './toolbarViewsConfig';
import './ToolbarActions.styles.scss';
interface ToolbarViewItem {
name: string;
key: string;
show?: boolean;
disabled?: boolean;
}
interface LeftToolbarActionsProps {
items: Record<string, ToolbarViewItem>;
items: any;
selectedView: string;
onChangeSelectedView: (view: ExplorerViews) => void;
showFilter: boolean;
@@ -31,6 +29,8 @@ export default function LeftToolbarActions({
showFilter,
handleFilterVisibilityChange,
}: LeftToolbarActionsProps): JSX.Element {
const { clickhouse, list, timeseries, table, trace } = items;
return (
<div className="left-toolbar">
{!showFilter && (
@@ -41,34 +41,91 @@ export default function LeftToolbarActions({
</Button>
</Tooltip>
)}
{/* Buttons render in the order the caller declares its views. */}
<div className="left-toolbar-query-actions">
{Object.values(items).map((item) => {
const config = TOOLBAR_VIEW_CONFIG[item?.key];
{list?.show && (
<Tooltip title="List View">
<Button
disabled={list.disabled}
className={cx(
'list-view-tab',
'explorer-view-option',
selectedView === list.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(list.key)}
>
<SquareMousePointer size={14} data-testid="search-view" />
List View
</Button>
</Tooltip>
)}
if (!item?.show || !config) {
return null;
}
{trace?.show && (
<Tooltip title="Trace View">
<Button
disabled={trace.disabled}
className={cx(
'trace-view-tab',
'explorer-view-option',
selectedView === trace.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(trace.key)}
>
<SquareMousePointer size={14} data-testid="trace-view" />
Trace View
</Button>
</Tooltip>
)}
const { icon: Icon, label, className, testId } = config;
{timeseries?.show && (
<Tooltip title="Time Series">
<Button
disabled={timeseries.disabled}
className={cx(
'timeseries-view-tab',
'explorer-view-option',
selectedView === timeseries.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(timeseries.key)}
>
<Atom size={14} data-testid="query-builder-view" />
Time Series
</Button>
</Tooltip>
)}
return (
<Tooltip key={item.key} title={label}>
<Button
disabled={item.disabled}
className={cx(
className,
'explorer-view-option',
selectedView === item.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(item.key as ExplorerViews)}
>
<Icon size={14} data-testid={testId} />
{label}
</Button>
</Tooltip>
);
})}
{clickhouse?.show && (
<Tooltip title="Clickhouse">
<Button
disabled={clickhouse.disabled}
className={cx(
'clickhouse-view-tab',
'explorer-view-option',
selectedView === clickhouse.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(clickhouse.key)}
>
<Terminal size={14} data-testid="clickhouse-view" />
Clickhouse
</Button>
</Tooltip>
)}
{table?.show && (
<Tooltip title="Table">
<Button
disabled={table.disabled}
className={cx(
'table-view-tab',
'explorer-view-option',
selectedView === table.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(table.key)}
>
<Binoculars size={14} data-testid="query-builder-view-v2" />
Table
</Button>
</Tooltip>
)}
</div>
</div>
);

View File

@@ -1,47 +0,0 @@
import {
Atom,
Binoculars,
SquareMousePointer,
Terminal,
} from '@signozhq/icons';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
export interface ToolbarViewConfig {
icon: typeof Atom;
label: string;
className: string;
testId: string;
}
export const TOOLBAR_VIEW_CONFIG: Record<string, ToolbarViewConfig> = {
[ExplorerViews.LIST]: {
icon: SquareMousePointer,
label: 'List View',
className: 'list-view-tab',
testId: 'search-view',
},
[ExplorerViews.TRACE]: {
icon: SquareMousePointer,
label: 'Trace View',
className: 'trace-view-tab',
testId: 'trace-view',
},
[ExplorerViews.TIMESERIES]: {
icon: Atom,
label: 'Time Series',
className: 'timeseries-view-tab',
testId: 'query-builder-view',
},
[ExplorerViews.CLICKHOUSE]: {
icon: Terminal,
label: 'Clickhouse',
className: 'clickhouse-view-tab',
testId: 'clickhouse-view',
},
[ExplorerViews.TABLE]: {
icon: Binoculars,
label: 'Table',
className: 'table-view-tab',
testId: 'query-builder-view-v2',
},
};

View File

@@ -0,0 +1,79 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { render, screen, userEvent } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import { DataSourceDropdown } from '../DataSourceDropdown';
const TEST_ID = 'query-data-source-selector';
async function openDropdown(): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId(TEST_ID);
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
}
describe('DataSourceDropdown', () => {
// antd's virtual list renders only the first couple of options into jsdom, so
// each case asserts what the restriction admits and excludes, not the full list.
it('offers the signals beyond the current one when nothing restricts it', async () => {
render(
<DataSourceDropdown
data-testid={TEST_ID}
value={DataSource.METRICS}
onChange={jest.fn()}
/>,
);
await openDropdown();
await expect(
screen.findByRole('option', { name: 'Logs' }),
).resolves.toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Metrics' })).toBeInTheDocument();
});
it('offers only the signals the caller can visualize', async () => {
render(
<DataSourceDropdown
data-testid={TEST_ID}
value={DataSource.METRICS}
allowedDataSources={[TelemetrytypesSignalDTO.metrics]}
onChange={jest.fn()}
/>,
);
await openDropdown();
await expect(
screen.findByRole('option', { name: 'Metrics' }),
).resolves.toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Logs' }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Traces' }),
).not.toBeInTheDocument();
});
it('drops a signal that is not a data source a query can be built against', async () => {
render(
<DataSourceDropdown
data-testid={TEST_ID}
value={DataSource.LOGS}
allowedDataSources={[
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
TelemetrytypesSignalDTO[''],
]}
onChange={jest.fn()}
/>,
);
await openDropdown();
await expect(
screen.findByRole('option', { name: 'Logs' }),
).resolves.toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Traces' })).toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Metrics' }),
).not.toBeInTheDocument();
});
});

View File

@@ -1,7 +1,9 @@
import { ChangeEvent, useCallback, useMemo, useState } from 'react';
import { Col, Input, Row, Select } from 'antd';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import BucketOptions from 'components/QueryBuilderV2/QueryV2/QueryAddOns/BucketOptions/BucketOptions';
import { LEGEND } from 'constants/global';
import { PANEL_TYPES } from 'constants/queryBuilder';
// ** Hooks
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
@@ -24,8 +26,12 @@ export function Formula({
query,
isQBV2,
}: FormulaProps): JSX.Element {
const { removeQueryBuilderEntityByIndex, handleSetFormulaData } =
useQueryBuilder();
const {
removeQueryBuilderEntityByIndex,
handleSetFormulaData,
panelType,
currentQuery,
} = useQueryBuilder();
const { handleChangeFormulaData } = useQueryOperations({
index,
@@ -73,6 +79,13 @@ export function Formula({
[handleChangeFormulaData],
);
const handleChangeBucketOptions = useCallback(
(value: IBuilderFormula['bucketOptions']) => {
handleChangeFormulaData('bucketOptions', value);
},
[handleChangeFormulaData],
);
const handleQBV2OrderByChange = useCallback(
(value: string) => {
const [columnName, order] = value.split(' ');
@@ -163,6 +176,17 @@ 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 && (
<Col span={24}>
<BucketOptions
bucketOptions={formula.bucketOptions}
unit={currentQuery.unit}
onChange={handleChangeBucketOptions}
/>
</Col>
)}
</Row>
)}
</Row>

View File

@@ -0,0 +1,91 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { render, screen, userEvent } from 'tests/test-utils';
import type { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
import { Formula } from '../Formula';
const mockHandleChangeFormulaData = jest.fn();
let mockPanelType: PANEL_TYPES = PANEL_TYPES.HEATMAP;
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): Record<string, unknown> => ({
removeQueryBuilderEntityByIndex: jest.fn(),
handleSetFormulaData: jest.fn(),
panelType: mockPanelType,
currentQuery: { unit: undefined },
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
useQueryOperations: (): Record<string, unknown> => ({
handleChangeFormulaData: mockHandleChangeFormulaData,
}),
}));
jest.mock('../../QBEntityOptions/QBEntityOptions', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="qb-entity-options" />,
}));
function formula(overrides: Partial<IBuilderFormula> = {}): IBuilderFormula {
return {
queryName: 'F1',
expression: 'A',
legend: '',
disabled: false,
...overrides,
};
}
function renderFormula(overrides: Partial<IBuilderFormula> = {}): void {
render(
<Formula index={0} formula={formula(overrides)} query={{} as never} isQBV2 />,
);
}
describe('Formula bucket options', () => {
beforeEach(() => {
jest.clearAllMocks();
mockPanelType = PANEL_TYPES.HEATMAP;
});
it('offers a bucket axis on a heatmap', () => {
renderFormula();
expect(screen.getByTestId('bucket-options')).toBeInTheDocument();
});
it('is not dismissable — a formula has no add-on toggle bar to collapse into', () => {
renderFormula();
expect(screen.queryByTestId('bucket-options-close')).not.toBeInTheDocument();
});
it('offers no bucket axis on other panel types', () => {
mockPanelType = PANEL_TYPES.TIME_SERIES;
renderFormula();
expect(screen.queryByTestId('bucket-options')).not.toBeInTheDocument();
});
it('opens on the axis the formula already carries', () => {
renderFormula({
bucketOptions: { kind: 'log', spec: { scale: 0 } } as never,
});
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
expect(screen.getByRole('radio', { name: '1' })).toBeChecked();
});
it('writes the picked axis onto the formula', async () => {
const user = userEvent.setup();
renderFormula();
await user.click(screen.getByRole('radio', { name: 'Log' }));
expect(mockHandleChangeFormulaData).toHaveBeenCalledWith('bucketOptions', {
kind: 'log',
spec: { scale: 4 },
});
});
});

View File

@@ -64,7 +64,6 @@ function TimeSeriesView({
panelType = PANEL_TYPES.TIME_SERIES,
stackBarChart = false,
allowExport = false,
exportFileName,
onYAxisUnitChange,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -271,7 +270,7 @@ function TimeSeriesView({
yAxisUnit={yAxisUnit}
data={data}
query={currentQuery}
fileName={exportFileName ?? `${dataSource}-timeseries`}
fileName={`${dataSource}-timeseries`}
/>
)}
</div>
@@ -340,7 +339,6 @@ interface TimeSeriesViewProps {
stackBarChart?: boolean;
// Opt-in: render the client-side export menu (Logs explorer for now).
allowExport?: boolean;
exportFileName?: string;
// Opt-in: render the y-axis unit selector in the header (views without their
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
onYAxisUnitChange?: (value: string) => void;
@@ -353,7 +351,6 @@ TimeSeriesView.defaultProps = {
setWarning: undefined,
panelType: PANEL_TYPES.TIME_SERIES,
stackBarChart: false,
exportFileName: undefined,
};
export default TimeSeriesView;

View File

@@ -29,6 +29,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
BAR: true,
PIE: false,
HISTOGRAM: false,
HEATMAP: false,
TEXT: false,
EMPTY_WIDGET: false,
};

View File

@@ -20,4 +20,6 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
// V2-only kind; it renders through the V2 panel registry.
[PANEL_TYPES.HEATMAP]: null,
};

View File

@@ -62,14 +62,14 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
legend: '',
};
const setupMockQueryBuilder = (): void => {
const setupMockQueryBuilder = (panelType = 'time_series'): void => {
(useQueryBuilder as jest.Mock).mockReturnValue({
handleSetQueryData: mockHandleSetQueryData,
handleSetFormulaData: mockHandleSetFormulaData,
removeQueryBuilderEntityByIndex: mockRemoveQueryBuilderEntityByIndex,
setLastUsedQuery: mockSetLastUsedQuery,
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
panelType: 'time_series',
panelType,
currentQuery: {
builder: {
queryData: [defaultMockQuery, defaultMockQuery],
@@ -332,4 +332,105 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
);
});
});
describe('spaceAggregationOptions for a histogram metric', () => {
const histogramQuery: IBuilderQuery = {
...defaultMockQuery,
aggregateAttribute: {
key: 'signoz_latency',
dataType: DataTypes.Float64,
type: ATTRIBUTE_TYPES.HISTOGRAM,
} as BaseAutocompleteData,
};
it('offers the percentiles on a time series panel', () => {
const result = renderHookWithProps({ query: histogramQuery });
expect(
result.current.spaceAggregationOptions.map((o) => o.value),
).toStrictEqual([
MetricAggregateOperator.P50,
MetricAggregateOperator.P75,
MetricAggregateOperator.P90,
MetricAggregateOperator.P95,
MetricAggregateOperator.P99,
]);
});
it('offers count alone on a heatmap panel, whose Y axis is the `le` labels', () => {
setupMockQueryBuilder('heatmap');
const result = renderHookWithProps({ query: histogramQuery });
expect(
result.current.spaceAggregationOptions.map((o) => o.value),
).toStrictEqual([MetricAggregateOperator.COUNT]);
});
});
describe('picking a histogram metric', () => {
const histogramAttribute: BaseAutocompleteData = {
key: 'http.client.duration.bucket',
dataType: DataTypes.Float64,
type: ATTRIBUTE_TYPES.HISTOGRAM,
};
it('defaults the spatial aggregation to p90 on a time series panel', () => {
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
act(() => {
result.current.handleChangeAggregatorAttribute(histogramAttribute);
});
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
0,
expect.objectContaining({
aggregations: [
expect.objectContaining({
spaceAggregation: MetricAggregateOperator.P90,
}),
],
}),
);
});
it('defaults it to count on a heatmap panel, which offers nothing else', () => {
setupMockQueryBuilder('heatmap');
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
act(() => {
result.current.handleChangeAggregatorAttribute(histogramAttribute);
});
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
0,
expect.objectContaining({
aggregations: [
expect.objectContaining({
spaceAggregation: MetricAggregateOperator.COUNT,
}),
],
}),
);
});
it('drops the bucket axis a gauge had chosen, since `le` is the axis now', () => {
setupMockQueryBuilder('heatmap');
const result = renderHookWithProps({
entityVersion: ENTITY_VERSION_V5,
query: {
...defaultMockQuery,
bucketOptions: { kind: 'log', spec: { scale: 2 } },
},
});
act(() => {
result.current.handleChangeAggregatorAttribute(histogramAttribute);
});
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
0,
expect.objectContaining({ bucketOptions: undefined }),
);
});
});
});

View File

@@ -18,6 +18,7 @@ import {
} from 'constants/queryBuilder';
import {
metricsGaugeSpaceAggregateOperatorOptions,
metricsHeatmapHistogramSpaceAggregateOperatorOptions,
metricsHistogramSpaceAggregateOperatorOptions,
metricsSumSpaceAggregateOperatorOptions,
metricsUnknownSpaceAggregateOperatorOptions,
@@ -176,6 +177,11 @@ export const useQueryOperations: UseQueryOperations = ({
(aggregateAttribute?.type as ATTRIBUTE_TYPES) || ATTRIBUTE_TYPES.GAUGE,
});
const histogramSpaceAggregationOptions =
panelType === PANEL_TYPES.HEATMAP
? metricsHeatmapHistogramSpaceAggregateOperatorOptions
: metricsHistogramSpaceAggregateOperatorOptions;
switch (aggregateAttribute?.type) {
case ATTRIBUTE_TYPES.SUM:
setSpaceAggregationOptions(metricsSumSpaceAggregateOperatorOptions);
@@ -185,11 +191,11 @@ export const useQueryOperations: UseQueryOperations = ({
break;
case ATTRIBUTE_TYPES.HISTOGRAM:
setSpaceAggregationOptions(metricsHistogramSpaceAggregateOperatorOptions);
setSpaceAggregationOptions(histogramSpaceAggregationOptions);
break;
case ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM:
setSpaceAggregationOptions(metricsHistogramSpaceAggregateOperatorOptions);
setSpaceAggregationOptions(histogramSpaceAggregationOptions);
break;
default:
setSpaceAggregationOptions(metricsUnknownSpaceAggregateOperatorOptions);
@@ -298,10 +304,17 @@ export const useQueryOperations: UseQueryOperations = ({
timeAggregation: '',
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.P90,
// A heatmap cell holds a count of observations per `le` band, which is
// the one option the kind offers — a percentile default would sit in the
// selector with nothing behind it.
spaceAggregation:
panelType === PANEL_TYPES.HEATMAP
? MetricAggregateOperator.COUNT
: MetricAggregateOperator.P90,
reduceTo: ReduceOperators.AVG,
},
];
newQuery.bucketOptions = undefined;
} else {
newQuery.aggregations = [
{
@@ -388,6 +401,7 @@ export const useQueryOperations: UseQueryOperations = ({
index,
handleMetricAggregateAtributeTypes,
previousMetricInfo,
panelType,
],
);

View File

@@ -1,61 +0,0 @@
import {
QueryKey,
useQuery,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import { ErrorType } from 'api/generatedAPIInstance';
import {
RenderErrorResponseDTO,
TelemetrytypesTelemetryFieldValuesDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getFieldValueSuggestions } from 'api/querySuggestions/getFieldValueSuggestions';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
FieldValuesConfig,
FieldValuesResponse,
} from 'api/querySuggestions/types';
import { BuilderQueryType } from 'types/api/v5/queryRange';
export type FieldValuesQueryOptions = UseQueryOptions<
FieldValuesResponse,
ErrorType<RenderErrorResponseDTO>,
TelemetrytypesTelemetryFieldValuesDTO
> & { queryKey: QueryKey };
const EMPTY_FIELD_VALUES: TelemetrytypesTelemetryFieldValuesDTO = {};
export const toFieldValues = (
res: FieldValuesResponse | undefined,
): TelemetrytypesTelemetryFieldValuesDTO =>
res?.data?.values ?? EMPTY_FIELD_VALUES;
export const getFieldValuesQueryOptions = (
fieldValuesConfig: FieldValuesConfig,
builderQueryType?: BuilderQueryType,
): FieldValuesQueryOptions => ({
queryKey: [
REACT_QUERY_KEY.FIELD_VALUES_SUGGESTION,
builderQueryType,
fieldValuesConfig,
],
queryFn: ({ signal }): Promise<FieldValuesResponse> =>
getFieldValueSuggestions(fieldValuesConfig, builderQueryType, signal),
select: toFieldValues,
cacheTime: FIELD_API_CACHE_TIME,
keepPreviousData: true,
});
export const useFieldValuesSuggestion = (
fieldValuesConfig: FieldValuesConfig,
builderQueryType?: BuilderQueryType,
options?: Pick<FieldValuesQueryOptions, 'enabled'>,
): UseQueryResult<
TelemetrytypesTelemetryFieldValuesDTO,
ErrorType<RenderErrorResponseDTO>
> =>
useQuery({
...getFieldValuesQueryOptions(fieldValuesConfig, builderQueryType),
...options,
});

View File

@@ -222,6 +222,31 @@ describe('useGetYAxisUnit', () => {
expect(result.current.isError).toBe(false);
});
it('resolves the unit on the first render, without a settling pass', () => {
// The real `useGetMetrics` rebuilds its array on every render; a hook that
// stored the unit would need an extra render to settle, and would schedule one
// after every render of the panel editor.
mockUseGetMetrics.mockImplementation(() => ({
isLoading: false,
isError: false,
metrics: [MOCK_METRIC_1],
}));
let renderCount = 0;
const { result, rerender } = renderHook(() => {
renderCount += 1;
return useGetYAxisUnit();
});
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
expect(renderCount).toBe(1);
rerender();
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
expect(renderCount).toBe(2);
});
it('should return undefined when metrics have different units', async () => {
mockUseGetMetrics.mockReturnValueOnce({
isLoading: false,

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo } from 'react';
import {
getMetricUnits,
useGetMetrics,
@@ -46,7 +46,6 @@ function useGetYAxisUnit(
},
): UseGetYAxisUnitResult {
const { stagedQuery } = useQueryBuilder();
const [yAxisUnit, setYAxisUnit] = useState<string | undefined>();
const metricNames: string[] | null = useMemo(() => {
// If the query type is not QUERY_BUILDER, return null
@@ -95,27 +94,16 @@ function useGetYAxisUnit(
[units],
);
useEffect(() => {
// If there are no metrics, set the y-axis unit to undefined
if (units.length === 0) {
setYAxisUnit(undefined);
// If there is one metric and it has a non-empty unit, set the y-axis unit to it
} else if (units.length === 1 && units[0] !== '') {
setYAxisUnit(units[0]);
// If all metrics have the same non-empty unit, set the y-axis unit to it
} else if (areAllMetricUnitsSame) {
if (units[0] !== '') {
setYAxisUnit(units[0]);
} else {
setYAxisUnit(undefined);
}
// If there is more than one metric and they have different units, set the y-axis unit to undefined
} else if (units.length > 1 && !areAllMetricUnitsSame) {
setYAxisUnit(undefined);
// If there is one metric and it has an empty unit, set the y-axis unit to undefined
} else if (units.length === 1 && units[0] === '') {
setYAxisUnit(undefined);
// Derived, not stored: `useGetMetrics` rebuilds its array on every render, so a
// state-and-effect version schedules an update after every render — the shape
// React reports as "Maximum update depth exceeded".
const yAxisUnit = useMemo(() => {
// A single shared unit is the only thing a single axis can carry; metrics that
// disagree, or that carry no unit at all, leave the axis unitless.
if (units.length === 0 || !areAllMetricUnitsSame) {
return undefined;
}
return units[0] || undefined;
}, [units, areAllMetricUnitsSame]);
return { yAxisUnit, isLoading, isError };

View File

@@ -0,0 +1,78 @@
import { Querybuildertypesv5BucketOptionsLogDTOKind } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import type {
IBuilderFormula,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { handleQueryChange } from '../panelQuery';
const bucketOptions = {
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
spec: { scale: 0 },
};
function queryWithFormulaAxis(): Query {
const base = initialQueriesMap[DataSource.METRICS];
const formula: IBuilderFormula = {
queryName: 'F1',
expression: 'A',
legend: '',
disabled: false,
bucketOptions,
};
return {
...base,
builder: {
...base.builder,
queryData: [{ ...base.builder.queryData[0], bucketOptions }],
queryFormulas: [formula],
},
};
}
describe('handleQueryChange bucket options', () => {
it('keeps both axes when the panel stays a heatmap', () => {
const result = handleQueryChange(
PANEL_TYPES.HEATMAP,
queryWithFormulaAxis(),
PANEL_TYPES.HEATMAP,
);
expect(result.builder.queryData[0].bucketOptions).toStrictEqual(
bucketOptions,
);
expect(result.builder.queryFormulas[0].bucketOptions).toStrictEqual(
bucketOptions,
);
});
// The request rejects an axis on any type but heatmap, so a switch has to shed it
// from the query (via the field allowlist) and from the formula (by hand).
it('drops both axes when switching to another panel type', () => {
const result = handleQueryChange(
PANEL_TYPES.TIME_SERIES,
queryWithFormulaAxis(),
PANEL_TYPES.HEATMAP,
);
expect(result.builder.queryData[0].bucketOptions).toBeUndefined();
expect(result.builder.queryFormulas[0].bucketOptions).toBeUndefined();
});
it('leaves the rest of the formula intact', () => {
const result = handleQueryChange(
PANEL_TYPES.TIME_SERIES,
queryWithFormulaAxis(),
PANEL_TYPES.HEATMAP,
);
expect(result.builder.queryFormulas[0]).toMatchObject({
queryName: 'F1',
expression: 'A',
disabled: false,
});
});
});

View File

@@ -8,7 +8,11 @@ import {
PANEL_TYPES,
} from 'constants/queryBuilder';
import { cloneDeep, isEqual, set, unset } from 'lodash-es';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import {
IBuilderFormula,
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
// Asks "would saving the current panel change the persisted widget spec?".
@@ -99,6 +103,7 @@ export type PartialPanelTypes = {
[PANEL_TYPES.VALUE]: 'value';
[PANEL_TYPES.PIE]: 'pie';
[PANEL_TYPES.HISTOGRAM]: 'histogram';
[PANEL_TYPES.HEATMAP]: 'heatmap';
};
export const panelTypeDataSourceFormValuesMap: Record<
@@ -306,6 +311,75 @@ export const panelTypeDataSourceFormValuesMap: Record<
},
},
},
// `functions` and `having` are dropped rather than carried: the heatmap request
// rejects both. Every signal is listed because the map is keyed by the query's
// own, which a switch can still be holding.
[PANEL_TYPES.HEATMAP]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'expression',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'expression',
'aggregations',
'bucketOptions',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'expression',
'aggregations',
],
},
},
},
[PANEL_TYPES.TABLE]: {
[DataSource.LOGS]: {
builder: {
@@ -538,6 +612,24 @@ export const panelTypeDataSourceFormValuesMap: Record<
},
};
/**
* 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.
*/
const withoutNonHeatmapBucketOptions = (
formulas: IBuilderFormula[],
newPanelType: keyof PartialPanelTypes,
): IBuilderFormula[] => {
if (newPanelType === PANEL_TYPES.HEATMAP) {
return formulas;
}
return (formulas ?? []).map(
({ bucketOptions: _bucketOptions, ...rest }) => rest,
);
};
export function handleQueryChange(
newPanelType: keyof PartialPanelTypes,
supersetQuery: Query,
@@ -581,6 +673,10 @@ export function handleQueryChange(
return tempQuery;
}),
queryFormulas: withoutNonHeatmapBucketOptions(
supersetQuery.builder.queryFormulas,
newPanelType,
),
queryTraceOperator:
newPanelType === PANEL_TYPES.LIST
? []

View File

@@ -0,0 +1,78 @@
.container {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 4px 12px 8px;
box-sizing: border-box;
}
.label {
flex: 0 0 auto;
font-size: 11px;
line-height: 16px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
.track {
position: relative;
flex: 1 1 auto;
height: 8px;
border-radius: 2px;
border: 1px solid var(--l2-border);
}
.marker {
position: absolute;
top: -3px;
bottom: -3px;
width: 2px;
transform: translateX(-1px);
// Reads against the panel through the 3px it overhangs the track at either end,
// which is what carries it where the ramp happens to match it.
background: var(--popover-foreground);
border-radius: 1px;
}
.caption {
flex: 0 0 auto;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.keys {
display: flex;
flex: 0 0 auto;
gap: 12px;
align-items: center;
}
.key {
display: flex;
gap: 5px;
align-items: center;
font-size: 11px;
color: var(--muted-foreground);
}
.swatch,
.hatchSwatch {
width: 11px;
height: 11px;
border-radius: 2px;
border: 1px solid var(--l2-border);
box-sizing: border-box;
}
// Approximates the canvas hatch painted over null cells, which `createHatchPattern`
// strokes in the theme's own direction — light on dark, dark on light.
.hatchSwatch {
background-image: repeating-linear-gradient(
45deg,
transparent 0 2px,
var(--muted-foreground) 2px 3px
);
}

View File

@@ -0,0 +1,81 @@
import { useMemo } from 'react';
import Styles from './ColorBar.module.scss';
export interface ColorBarProps {
/** Low to high, drawn as hard-edged segments so the bar shows the same set of
* colours as the cells. */
ramp: string[];
minLabel: string;
maxLabel: string;
/** 0..1. `null` hides the marker. */
markerPosition?: number | null;
/** What the colour encodes, e.g. "count". */
label?: string;
/** Keys for the two states a ramp cannot express: a hatched data gap, and a
* genuine zero at the bottom. Without them the difference is guesswork. */
showStateKeys?: boolean;
'data-testid'?: string;
}
/** What a colour means, plus a marker for the value under the cursor. */
export default function ColorBar({
ramp,
minLabel,
maxLabel,
markerPosition = null,
label,
showStateKeys = true,
'data-testid': testId = 'color-bar',
}: ColorBarProps): JSX.Element | null {
const gradient = useMemo(() => {
if (ramp.length === 0) {
return undefined;
}
if (ramp.length === 1) {
return ramp[0];
}
const stops = ramp.flatMap((color, index) => {
const from = (index / ramp.length) * 100;
const to = ((index + 1) / ramp.length) * 100;
return [`${color} ${from}%`, `${color} ${to}%`];
});
return `linear-gradient(to right, ${stops.join(', ')})`;
}, [ramp]);
if (gradient === undefined) {
return null;
}
const clampedMarker =
markerPosition === null ? null : Math.min(Math.max(markerPosition, 0), 1);
return (
<div className={Styles.container} data-testid={testId}>
{label && <span className={Styles.caption}>{label}</span>}
<span className={Styles.label}>{minLabel}</span>
<div className={Styles.track} style={{ background: gradient }}>
{clampedMarker !== null && (
<span
className={Styles.marker}
style={{ left: `${clampedMarker * 100}%` }}
data-testid={`${testId}-marker`}
/>
)}
</div>
<span className={Styles.label}>{maxLabel}</span>
{showStateKeys && (
<div className={Styles.keys} data-testid={`${testId}-state-keys`}>
<span className={Styles.key}>
<span className={Styles.hatchSwatch} />
no data
</span>
<span className={Styles.key}>
<span className={Styles.swatch} style={{ background: ramp[0] }} />
count 0
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { render, screen } from '@testing-library/react';
import ColorBar from '../ColorBar';
const RAMP = ['#111111', '#555555', '#999999', '#dddddd'];
describe('ColorBar', () => {
it('renders the domain labels', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="1,204" />);
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('1,204')).toBeInTheDocument();
});
it('renders nothing without a ramp', () => {
const { container } = render(
<ColorBar ramp={[]} minLabel="0" maxLabel="0" />,
);
expect(container).toBeEmptyDOMElement();
});
it('hides the marker when nothing is hovered', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
expect(screen.queryByTestId('color-bar-marker')).not.toBeInTheDocument();
});
it('positions the marker at the hovered value', () => {
render(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={0.25} />,
);
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '25%' });
});
it('clamps a marker outside the ramp to its ends', () => {
const { rerender } = render(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={-2} />,
);
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '0%' });
rerender(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={4} />,
);
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '100%' });
});
it('keys the two states a colour ramp cannot express', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
expect(screen.getByText('no data')).toBeInTheDocument();
expect(screen.getByText('count 0')).toBeInTheDocument();
});
it('draws the count-0 key with the bottom of the ramp', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
expect(screen.getByText('count 0').firstChild).toHaveStyle({
background: RAMP[0],
});
});
it('hides the state keys when asked', () => {
render(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" showStateKeys={false} />,
);
expect(screen.queryByText('no data')).not.toBeInTheDocument();
});
it('captions what the colour encodes', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" label="count" />);
expect(screen.getByText('count')).toBeInTheDocument();
});
it('renders hard-edged segments so the bar matches the drawn cells', () => {
render(
<ColorBar
ramp={['#111111', '#dddddd']}
minLabel="0"
maxLabel="10"
data-testid="scale"
/>,
);
const track = screen.getByTestId('scale').querySelector('div');
expect(track).toHaveStyle({
background:
'linear-gradient(to right, #111111 0%, #111111 50%, #dddddd 50%, #dddddd 100%)',
});
});
});

View File

@@ -0,0 +1,30 @@
import cx from 'classnames';
import { formatCount, HeatmapBucketRow } from './heatmapTooltipContent';
import Styles from './HeatmapTooltip.module.scss';
/** The buckets either side of the hovered one, so a mode reads as a shape rather
* than a single number. */
export default function HeatmapBucketList({
rows,
}: {
rows: HeatmapBucketRow[];
}): JSX.Element {
return (
<div className={Styles.rows} data-testid="heatmap-tooltip-buckets">
{rows.map((bucket) => (
<div
key={bucket.row}
className={cx(Styles.row, { [Styles.rowHovered]: bucket.isHovered })}
data-hovered={bucket.isHovered}
data-testid="heatmap-tooltip-bucket-row"
>
<span className={Styles.rowLabel}>{bucket.label}</span>
<span className={Styles.rowSeparator} />
<span className={Styles.rowValue}>{formatCount(bucket.count)}</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { formatCount, HeatmapContributionRow } from './heatmapTooltipContent';
import Styles from './HeatmapTooltip.module.scss';
/** Only shown when the cell sums more than one group. */
export default function HeatmapContributionList({
rows,
}: {
rows: HeatmapContributionRow[];
}): JSX.Element {
return (
<div className={Styles.rows} data-testid="heatmap-tooltip-contribution">
{rows.map((row) => (
<div
key={row.label}
className={Styles.row}
data-testid="heatmap-tooltip-contribution-row"
>
<span
className={Styles.marker}
style={{ borderColor: row.color, backgroundColor: row.color }}
data-is-legend-marker={true}
/>
<span className={Styles.rowLabel}>{row.label}</span>
<span className={Styles.rowSeparator} style={{ borderColor: row.color }} />
<span className={Styles.rowValue}>{formatCount(row.count)}</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,173 @@
@use '../../../../../../styles/scrollbar' as *;
// Surface matches the shared Tooltip exactly — same tokens, same radius, no
// shadow (the plugin's portal wrapper is transparent and paints nothing). Text
// follows the theme through the popover/muted pair; the fixed vanilla ramp reads
// as white-on-white in light mode.
//
// Padding lives on the sections rather than here, also matching the shared
// tooltip: TooltipFooter draws its own dashed top border, background and bottom
// corner radius, so it has to reach the container edges.
.container {
// The list pays this less each row's own inset, so its text lands on it too.
--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: 220px;
&.pinned {
border-color: var(--ring);
}
}
// Separates the cell identity from whichever question the second block answers.
.divider {
display: block;
width: 100%;
height: 1px;
background-color: var(--l2-border);
}
.identity {
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
padding: var(--spacing-8) var(--gutter) 0;
font-size: var(--font-size-sm);
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.filter {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
padding: var(--spacing-3) var(--gutter) 0;
}
// Names the group the grid is under, as the legend draws a shown series; it is
// not a colour key.
.filterMarker {
width: 12px;
height: 12px;
border-radius: var(--radius);
border: 1.5px solid currentColor;
background: currentColor;
box-sizing: border-box;
flex-shrink: 0;
}
.filterLabel {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: var(--spacing-4) 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);
}
.titleCount {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-4) calc(var(--gutter) - var(--row-inset))
var(--spacing-6);
max-height: 320px;
overflow-y: auto;
@include custom-scrollbar;
}
.row {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--row-inset);
border-radius: var(--radius);
font-weight: 400;
font-variant-numeric: tabular-nums;
}
// The hovered bucket is the one the cursor is on; lift it out of the neighbours.
.rowHovered {
background: var(--l3-background);
font-weight: 700;
}
.rowLabel,
.rowValue {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
}
.rowLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowValue {
flex: 0 0 auto;
text-align: right;
white-space: nowrap;
}
.marker {
width: 12px;
height: 12px;
border-radius: var(--radius);
border-style: solid;
border-width: 1.5px;
box-sizing: border-box;
flex-shrink: 0;
}
.rowSeparator,
.titleSeparator {
flex: 1;
border-width: 0.5px;
border-style: dashed;
border-color: currentColor;
min-width: 24px;
opacity: 0.5;
}

View File

@@ -0,0 +1,180 @@
import { useMemo } from 'react';
import cx from 'classnames';
import {
resolveColumnIndex,
resolveRowIndex,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { useTimezone } from 'providers/Timezone';
import { HeatmapTooltipProps } from '../../../types';
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
import HeatmapBucketList from './HeatmapBucketList';
import HeatmapContributionList from './HeatmapContributionList';
import {
buildBucketRows,
buildContributionRows,
formatBucketLabel,
formatColumnRange,
formatCount,
HeatmapTooltipBody,
resolveTooltipBody,
} from './heatmapTooltipContent';
import Styles from './HeatmapTooltip.module.scss';
/**
* The cell identity is the same in every state; the second block answers whichever
* question the panel state leaves open (see `resolveTooltipBody`). Purpose-built
* rather than composed from the shared `Tooltip`, which renders a flat list of
* series values — none of these states is that shape.
*
* The cell comes from the live cursor, not a prop: uPlot's `cursor.idx` snaps to
* the nearest timestamp, so half of every column would report its neighbour.
*/
export default function HeatmapTooltip({
uPlotInstance,
yAxis,
step,
series,
visibleGroups,
groupColor,
yAxisUnit,
decimalPrecision,
timezone,
isPinned,
dismiss,
renderTooltipFooter,
}: HeatmapTooltipProps): JSX.Element | null {
const { timezone: userTimezone } = useTimezone();
const resolvedTimezone = timezone?.value ?? userTimezone.value;
// Read outside the memo: uPlot mutates the same instance on every move, so
// keying off the instance alone would freeze the cell.
const { left = -10, top = -10 } = uPlotInstance.cursor;
const cell = useMemo(() => {
if (left < 0 || top < 0) {
return null;
}
const timestamps = uPlotInstance.data[0] as ArrayLike<number>;
const column = resolveColumnIndex(
timestamps,
uPlotInstance.posToVal(left, 'x'),
step,
);
const row = resolveRowIndex(yAxis.edges, uPlotInstance.posToVal(top, 'y'));
if (column === null || row === null) {
return null;
}
return {
row,
column,
timestamp: timestamps[column],
count:
(uPlotInstance.data[row + 1] as Array<number | null> | undefined)?.[
column
] ?? null,
};
}, [left, top, uPlotInstance, yAxis, step]);
// The cell sums the enabled groups, so those are what a breakdown must cover.
const visible = useMemo(
() => series.filter((entry) => visibleGroups.includes(entry.label)),
[series, visibleGroups],
);
const body = resolveTooltipBody(visible.length);
const bucketRows = useMemo(() => {
if (!cell || body !== HeatmapTooltipBody.Buckets) {
return [];
}
return buildBucketRows({
counts: uPlotInstance.data.slice(1) as Array<
ArrayLike<number | null> | undefined
>,
yAxis,
row: cell.row,
column: cell.column,
yAxisUnit,
decimalPrecision,
});
}, [cell, body, uPlotInstance, yAxis, yAxisUnit, decimalPrecision]);
const contributionRows = useMemo(() => {
if (!cell || body !== HeatmapTooltipBody.Contribution) {
return [];
}
return buildContributionRows({
series: visible,
timestamp: cell.timestamp,
row: cell.row,
color: groupColor,
});
}, [cell, body, visible, groupColor]);
if (!cell) {
return null;
}
// A single enabled group out of several means the legend has isolated it.
const isolated =
series.length > 1 && visible.length === 1 ? visible[0] : undefined;
const filterLabel = isolated?.label ?? '';
return (
<div
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
data-pinned={isPinned}
data-testid="heatmap-tooltip"
>
<div className={Styles.identity}>
<div className={Styles.header}>
<span data-testid="heatmap-tooltip-range">
{formatColumnRange({
start: cell.timestamp,
step,
timezone: resolvedTimezone,
})}
</span>
{isPinned && <TooltipPinnedBadge />}
</div>
{filterLabel && (
<div
className={Styles.filter}
style={{ color: groupColor }}
data-testid="heatmap-tooltip-filter"
>
<span className={Styles.filterMarker} />
<span className={Styles.filterLabel}>{filterLabel}</span>
</div>
)}
<div className={Styles.title}>
<span className={Styles.titleBucket} data-testid="heatmap-tooltip-bucket">
{formatBucketLabel({
yAxis,
row: cell.row,
yAxisUnit,
decimalPrecision,
})}
</span>
<span className={Styles.titleSeparator} />
<span className={Styles.titleCount} data-testid="heatmap-tooltip-count">
{formatCount(cell.count)}
</span>
</div>
</div>
<span className={Styles.divider} data-testid="heatmap-tooltip-divider" />
{body === HeatmapTooltipBody.Contribution ? (
<HeatmapContributionList rows={contributionRows} />
) : (
<HeatmapBucketList rows={bucketRows} />
)}
{renderTooltipFooter?.({ isPinned, dismiss })}
</div>
);
}

View File

@@ -0,0 +1,284 @@
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import {
HeatmapAxisScale,
HeatmapSeries,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import { render, RenderResult, screen } from 'tests/test-utils';
import type uPlot from 'uplot';
import HeatmapTooltip from '../HeatmapTooltip';
const BOUNDS = [100, 500, 1000, 2500];
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
const TIMESTAMPS = [1_700_000_000, 1_700_000_300];
const STEP = 300;
const PLOT_SIZE = 500;
const ROW_COUNT = BOUNDS.length + 1;
/** Row 2 is the 500ms1s bucket the design mock hovers. */
const HOVERED_ROW = 2;
function seriesFor(
group: string,
countsAtHoveredRow: [number, number],
): HeatmapSeries {
return {
label: `{service.name="${group}"}`,
points: TIMESTAMPS.map((timestamp, column) => ({
timestamp,
counts: Array.from({ length: ROW_COUNT }, (_, row) =>
row === HOVERED_ROW ? countsAtHoveredRow[column] : row * 10,
),
})),
};
}
const GROUPED: HeatmapSeries[] = [
seriesFor('checkout', [355, 300]),
seriesFor('frontend', [86, 80]),
seriesFor('cart', [14, 10]),
seriesFor('payments', [0, 0]),
];
/** Grid counts, matching what the renderer would have been handed. */
function gridData(rowTotals: number[]): uPlot.AlignedData {
return [
TIMESTAMPS,
...Array.from({ length: ROW_COUNT }, (_, row) => [
rowTotals[row] ?? row * 40,
rowTotals[row] ?? row * 40,
]),
] as unknown as uPlot.AlignedData;
}
// Totals chosen to match the mock: 2 / 92 / 455 / 269 / 10 bottom-up.
const ROW_TOTALS = [10, 269, 455, 92, 2];
function createFakePlot(): uPlot {
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
const ySpan = Y_AXIS.max - Y_AXIS.min;
// Aim the cursor at the middle of the hovered row, first column.
const rowMid = (Y_AXIS.edges[HOVERED_ROW] + Y_AXIS.edges[HOVERED_ROW + 1]) / 2;
const top = PLOT_SIZE * (1 - (rowMid - Y_AXIS.min) / ySpan);
return {
data: gridData(ROW_TOTALS),
cursor: { left: PLOT_SIZE * 0.25, top },
posToVal: (pos: number, scaleKey: string): number =>
scaleKey === 'x'
? TIMESTAMPS[0] + (pos / PLOT_SIZE) * xSpan
: Y_AXIS.min + ((PLOT_SIZE - pos) / PLOT_SIZE) * ySpan,
} as unknown as uPlot;
}
function renderTooltip(
overrides: Partial<React.ComponentProps<typeof HeatmapTooltip>> = {},
): RenderResult {
return render(
<HeatmapTooltip
id="panel-1"
uPlotInstance={createFakePlot()}
dataIndexes={[]}
seriesIndex={null}
isPinned={false}
dismiss={jest.fn()}
viaSync={false}
yAxis={Y_AXIS}
step={STEP}
series={GROUPED}
visibleGroups={GROUPED.map((entry) => entry.label)}
groupColor="#fcfdbf"
yAxisUnit="ms"
{...overrides}
/>,
);
}
describe('HeatmapTooltip — cell identity', () => {
it('heads with the time span the column covers, not a single instant', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip-range').textContent).toMatch(
/^\d{2}\/\d{2} \d{2}:\d{2} → \d{2}:\d{2}$/,
);
});
it('names the hovered bucket and its count', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip-bucket')).toHaveTextContent(
'500 ms 1 s',
);
expect(screen.getByTestId('heatmap-tooltip-count')).toHaveTextContent('455');
});
it('marks the surface as pinned so the border picks up the ring', () => {
renderTooltip({ isPinned: true });
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
'data-pinned',
'true',
);
});
it('names a pinned tooltip in its header, as the shared tooltip does', () => {
renderTooltip({ isPinned: true });
expect(screen.getByTestId('uplot-tooltip-status')).toBeInTheDocument();
});
it('leaves the header unbadged while unpinned', () => {
renderTooltip();
expect(screen.queryByTestId('uplot-tooltip-status')).not.toBeInTheDocument();
});
it('is unpinned by default', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
'data-pinned',
'false',
);
});
it('separates the cell identity from the block below it', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip-divider')).toBeInTheDocument();
});
it('renders a footer when the panel supplies one', () => {
renderTooltip({
renderTooltipFooter: ({ isPinned }): JSX.Element => (
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
),
});
expect(screen.getByTestId('footer')).toHaveTextContent('press P');
});
it('tells the footer when the tooltip is pinned', () => {
renderTooltip({
isPinned: true,
renderTooltipFooter: ({ isPinned }): JSX.Element => (
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
),
});
expect(screen.getByTestId('footer')).toHaveTextContent('pinned');
});
it('renders nothing when the cursor is off the plot', () => {
const plot = createFakePlot();
(plot as { cursor: unknown }).cursor = { left: -10, top: -10 };
const { container } = renderTooltip({ uPlotInstance: plot });
expect(container).toBeEmptyDOMElement();
});
});
describe('HeatmapTooltip — grouped, nothing selected', () => {
it('breaks the cell down by group instead of showing neighbours', () => {
renderTooltip();
expect(
screen.getByTestId('heatmap-tooltip-contribution'),
).toBeInTheDocument();
expect(
screen.queryByTestId('heatmap-tooltip-buckets'),
).not.toBeInTheDocument();
});
it('names each row as the legend does and orders by contribution', () => {
renderTooltip();
const rows = screen
.getAllByTestId('heatmap-tooltip-contribution-row')
.map((row) => row.textContent);
expect(rows[0]).toContain('checkout');
expect(rows[0]).toContain('355');
expect(rows[1]).toContain('frontend');
expect(rows[2]).toContain('cart');
});
it('still lists a group that contributed nothing', () => {
renderTooltip();
const rows = screen.getAllByTestId('heatmap-tooltip-contribution-row');
expect(rows).toHaveLength(GROUPED.length);
expect(rows[3]).toHaveTextContent('payments');
expect(rows[3]).toHaveTextContent('0');
});
it('does not name a filter when every group is enabled', () => {
renderTooltip();
expect(
screen.queryByTestId('heatmap-tooltip-filter'),
).not.toBeInTheDocument();
});
});
describe('HeatmapTooltip — grouped, one enabled', () => {
const selected = { visibleGroups: ['{service.name="checkout"}'] };
it('returns to neighbouring buckets, since contribution is already answered', () => {
renderTooltip(selected);
expect(screen.getByTestId('heatmap-tooltip-buckets')).toBeInTheDocument();
expect(
screen.queryByTestId('heatmap-tooltip-contribution'),
).not.toBeInTheDocument();
});
it('names the active filter', () => {
renderTooltip(selected);
expect(screen.getByTestId('heatmap-tooltip-filter')).toHaveTextContent(
'{service.name="checkout"}',
);
});
});
describe('HeatmapTooltip — no grouping', () => {
const ungrouped = {
series: [{ label: '', points: GROUPED[0].points }],
visibleGroups: [''],
};
it('shows neighbouring buckets, highest first', () => {
renderTooltip(ungrouped);
const rows = screen
.getAllByTestId('heatmap-tooltip-bucket-row')
.map((row) => row.textContent);
// Two buckets either side of 500ms 1s, reading down the y axis.
expect(rows).toHaveLength(5);
expect(rows[0]).toContain('> 2.5 s');
expect(rows[2]).toContain('500 ms 1 s');
expect(rows[4]).toContain('≤ 100 ms');
});
it('marks the hovered bucket among its neighbours', () => {
renderTooltip(ungrouped);
const hovered = screen
.getAllByTestId('heatmap-tooltip-bucket-row')
.filter((row) => row.dataset.hovered === 'true');
expect(hovered).toHaveLength(1);
expect(hovered[0]).toHaveTextContent('500 ms 1 s');
});
it('never breaks down a single series', () => {
renderTooltip(ungrouped);
expect(
screen.queryByTestId('heatmap-tooltip-contribution'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,65 @@
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { HeatmapAxisScale } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import { buildBucketRows, formatColumnRange } from '../heatmapTooltipContent';
const TIMEZONE = 'UTC';
/** 2026-09-02T05:30:00Z. */
const START = 1_788_327_000;
/** Two decimals round every one of these to `0.06 ms` or `0.07 ms`. */
const CLOSE_BOUNDS = [
0.05731275270029195, 0.059850205043660856, 0.0625, 0.06526711140171336,
0.0681567332915786,
];
const CLOSE_Y_AXIS = resolveHeatmapYAxis(CLOSE_BOUNDS, HeatmapAxisScale.Log);
const COUNTS = CLOSE_Y_AXIS.rows.map((_, row) => [row]);
describe('formatColumnRange', () => {
it('dates the column once while it stays inside a day', () => {
expect(
formatColumnRange({ start: START, step: 9 * 3600, timezone: TIMEZONE }),
).toBe('09/02 05:30 → 14:30');
});
it('carries the date across a column that spans days', () => {
expect(
formatColumnRange({ start: START, step: 2 * 86_400, timezone: TIMEZONE }),
).toBe('09/02 05:30 → 09/04 05:30');
});
it('adds seconds for a sub-minute column, which times alone cannot separate', () => {
expect(
formatColumnRange({ start: START, step: 30, timezone: TIMEZONE }),
).toBe('09/02 05:30:00 → 05:30:30');
});
it('reads the day in the panel timezone, not UTC', () => {
// 05:30Z is the previous evening in Los Angeles, so the same column reads as
// crossing a date boundary there and not in UTC.
expect(
formatColumnRange({
start: START,
step: 9 * 3600,
timezone: 'America/Los_Angeles',
}),
).toBe('09/01 22:30 → 09/02 07:30');
});
});
describe('buildBucketRows', () => {
it('identifies a row by its place on the axis, which its label cannot', () => {
const rows = buildBucketRows({
counts: COUNTS,
yAxis: CLOSE_Y_AXIS,
row: 3,
column: 0,
yAxisUnit: 'ms',
decimalPrecision: 2,
});
expect(new Set(rows.map((row) => row.label)).size).toBeLessThan(rows.length);
expect(rows.map((row) => row.row)).toStrictEqual([5, 4, 3, 2, 1]);
expect(rows.map((row) => row.count)).toStrictEqual([5, 4, 3, 2, 1]);
});
});

View File

@@ -0,0 +1,172 @@
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import timezonePlugin from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import { formatRowLabel } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import {
HeatmapSeries,
HeatmapYAxis,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
dayjs.extend(utc);
dayjs.extend(timezonePlugin);
/** Rows shown either side of the hovered one. */
const NEIGHBOUR_SPAN = 2;
/** Below this, the header needs seconds to distinguish columns. */
const SUB_MINUTE_STEP = 60;
export const NO_DATA_LABEL = 'no data';
/**
* Which question the second block answers. A cell summed across several groups begs
* "which group?"; a cell that is already one series begs "how does this bucket
* compare with its neighbours?".
*/
export enum HeatmapTooltipBody {
Buckets = 'buckets',
Contribution = 'contribution',
}
export interface HeatmapBucketRow {
/** The bucket's row on the y axis. Labels are not unique — two boundaries can
* round to the same text — so this is what identifies a row. */
row: number;
label: string;
count: number | null;
isHovered: boolean;
}
export interface HeatmapContributionRow {
label: string;
color: string;
count: number;
}
export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
// One enabled group contributes the whole cell, so there is nothing to break
// down — whether the query is ungrouped or the legend has isolated a group.
return visibleCount > 1
? HeatmapTooltipBody.Contribution
: HeatmapTooltipBody.Buckets;
}
/** A cell is an interval, so a single instant would misreport what it contains.
* The start carries the date — the x axis prints one only where the day turns
* over — and the end repeats it only across midnight. */
export function formatColumnRange({
start,
step,
timezone,
}: {
/** Column start, in seconds. */
start: number;
/** Column width, in seconds. */
step: number;
timezone: string;
}): string {
const time =
step < SUB_MINUTE_STEP
? DATE_TIME_FORMATS.TIME_SECONDS
: DATE_TIME_FORMATS.TIME;
const dated = `${DATE_TIME_FORMATS.DATE_SHORT} ${time}`;
const from = dayjs(start * 1000).tz(timezone);
const to = dayjs((start + step) * 1000).tz(timezone);
const toFormat = to.isSame(from, 'day') ? time : dated;
return `${from.format(dated)}${to.format(toFormat)}`;
}
/** Formatted with the panel's unit. */
export function formatBucketLabel({
yAxis,
row,
yAxisUnit,
decimalPrecision,
}: {
yAxis: HeatmapYAxis;
row: number;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}): string {
const bucket = yAxis.rows[row];
if (!bucket) {
return '';
}
return formatRowLabel(bucket, (value) =>
getToolTipValue(String(value), yAxisUnit, decimalPrecision),
);
}
export function formatCount(count: number | null): string {
return count === null ? NO_DATA_LABEL : count.toLocaleString();
}
/** Highest first, so the list reads in the same direction as the y axis. */
export function buildBucketRows({
counts,
yAxis,
row,
column,
yAxisUnit,
decimalPrecision,
}: {
/** Row-major, as the renderer draws them. */
counts: Array<ArrayLike<number | null> | undefined>;
yAxis: HeatmapYAxis;
row: number;
column: number;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}): HeatmapBucketRow[] {
const formatBucketValue = (value: number): string =>
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
const rows: HeatmapBucketRow[] = [];
for (let offset = NEIGHBOUR_SPAN; offset >= -NEIGHBOUR_SPAN; offset -= 1) {
const index = row + offset;
const bucket = yAxis.rows[index];
if (!bucket) {
continue;
}
rows.push({
row: index,
label: formatRowLabel(bucket, formatBucketValue),
count: counts[index]?.[column] ?? null,
isHovered: offset === 0,
});
}
return rows;
}
/**
* Largest first. Groups that contributed nothing are still listed — that is an
* answer, and dropping the row makes the list look truncated.
*/
export function buildContributionRows({
series,
timestamp,
row,
color,
}: {
/** Only the groups the legend has enabled — they are what the cell sums. */
series: HeatmapSeries[];
/** Column start, in seconds. */
timestamp: number;
row: number;
/** The grid's one colour; there is no per-series hue. */
color: string;
}): HeatmapContributionRow[] {
return series
.map((entry) => {
const point = entry.points.find((item) => item.timestamp === timestamp);
return {
label: entry.label,
color,
// Absent or null contributed nothing to the sum this breaks down.
count: point?.counts[row] ?? 0,
};
})
.sort((a, b) => b.count - a.count);
}

View File

@@ -16,15 +16,3 @@
.pinnedItem {
padding: var(--spacing-4);
}
.status {
display: flex;
align-items: center;
gap: var(--spacing-1);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--callout-primary-title);
flex-shrink: 0;
}

View File

@@ -3,12 +3,12 @@ import cx from 'classnames';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { Pin } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import { TooltipContentItem } from '../../../types';
import TooltipItem from '../TooltipItem/TooltipItem';
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
import Styles from './TooltipHeader.module.scss';
@@ -65,14 +65,7 @@ export default function TooltipHeader({
{showTooltipHeader && headerTitle && (
<div className={cx(Styles.headerRow, headerRowClassName)}>
<span>{headerTitle}</span>
{isPinned && (
<div className={cx(Styles.status)} data-testid="uplot-tooltip-status">
<>
<Pin size={12} />
<span>Pinned</span>
</>
</div>
)}
{isPinned && <TooltipPinnedBadge />}
</div>
)}

View File

@@ -0,0 +1,11 @@
.status {
display: flex;
align-items: center;
gap: var(--spacing-1);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--callout-primary-title);
flex-shrink: 0;
}

View File

@@ -0,0 +1,12 @@
import { Pin } from '@signozhq/icons';
import Styles from './TooltipPinnedBadge.module.scss';
export default function TooltipPinnedBadge(): JSX.Element {
return (
<div className={Styles.status} data-testid="uplot-tooltip-status">
<Pin size={12} />
<span>Pinned</span>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { LegendItem } from '../config/types';
import { HeatmapSeries, HeatmapYAxis } from '../plugins/HeatmapPlugin/types';
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
/**
@@ -103,6 +104,21 @@ export interface BarTooltipProps extends BaseTooltipProps, TooltipRenderArgs {
export interface HistogramTooltipProps
extends BaseTooltipProps, TooltipRenderArgs {}
/** Not part of `TooltipProps`: it renders its own container, since none of its
* states is the flat series list the shared `Tooltip` draws. */
export interface HeatmapTooltipProps
extends BaseTooltipProps, TooltipRenderArgs {
yAxis: HeatmapYAxis;
/** Column width in seconds. */
step: number;
/** Needed to break a summed cell down by contribution. */
series: HeatmapSeries[];
/** Groups the legend has enabled; the cell sums exactly these. */
visibleGroups: string[];
/** Same colour the legend and the densest cells use. */
groupColor: string;
}
export type TooltipProps =
| TimeSeriesTooltipProps
| BarTooltipProps

View File

@@ -148,6 +148,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
show = true,
side = 2, // bottom by default
space,
splits,
gap = 5, // default gap is 5
} = this.props;
@@ -179,6 +180,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
if (values) {
axisConfig.values = values;
}
if (splits) {
axisConfig.splits = splits;
}
if (gap !== undefined) {
axisConfig.gap = gap;
}

View File

@@ -46,6 +46,13 @@ export class UPlotScaleBuilder extends ConfigBuilder<
// Special handling for time scales (X axis)
if (time) {
// An explicit range wins: the alignment below trims the tail of the window
// to whole minutes, which is right for point-based series but drops the
// final column of any chart whose marks span an interval.
if (range) {
return { [scaleKey]: { time: true, auto: false, range } };
}
let minTime = this.min ?? 0;
let maxTime = this.max ?? 0;

View File

@@ -78,6 +78,8 @@ export interface AxisProps {
};
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
values?: uPlot.Axis.Values;
/** Explicit axis splits, overriding the default tick calculation. */
splits?: uPlot.Axis.Splits;
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
gap?: number;
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */

View File

@@ -0,0 +1,268 @@
import {
clampColorSteps,
createHeatmapColorResolver,
DEFAULT_COLOR_STEPS,
DEFAULT_HEATMAP_COLORS,
getMaxCount,
getSmallestPositiveCount,
MAX_COLOR_STEPS,
MIN_OPACITY_ALPHA,
normalizeCount,
resolveCountDomain,
} from '../colorScale';
import { HeatmapColorMode, HeatmapColorScale } from '../types';
const SERIES_COLOR = '#4e74f8';
describe('getMaxCount', () => {
it('ignores null cells', () => {
expect(
getMaxCount([
[1, null, 9],
[null, 4],
]),
).toBe(9);
});
it('returns 0 for an empty or all-null grid', () => {
expect(getMaxCount([])).toBe(0);
expect(getMaxCount([[null, null]])).toBe(0);
});
it('ignores non-finite counts', () => {
expect(getMaxCount([[3, Number.POSITIVE_INFINITY, Number.NaN]])).toBe(3);
});
});
describe('getSmallestPositiveCount', () => {
it('ignores nulls, zeros and non-finite counts', () => {
expect(
getSmallestPositiveCount([
[0, null, 4],
[Number.NaN, 2, -3],
]),
).toBe(2);
});
it('returns null when nothing is above zero', () => {
expect(getSmallestPositiveCount([[0, null]])).toBeNull();
});
});
describe('resolveCountDomain', () => {
it('floors at 0 on auto so a zero count sits at the bottom of the scale', () => {
expect(
resolveCountDomain({ minCount: null, maxCount: null }, [[5, 20]]),
).toStrictEqual({
min: 0,
max: 20,
logFloor: 5,
});
});
it('honours explicit clamps', () => {
expect(
resolveCountDomain({ minCount: 10, maxCount: 100 }, [[5, 20]]),
).toStrictEqual({
min: 10,
max: 100,
logFloor: 5,
});
});
it('collapses a max at or below min', () => {
expect(
resolveCountDomain({ minCount: 50, maxCount: 10 }, [[5]]),
).toStrictEqual({
min: 50,
max: 50,
logFloor: 5,
});
});
it('takes the log floor from the smallest positive count, below 1 included', () => {
expect(
resolveCountDomain({ minCount: null, maxCount: null }, [[0, 0.02, 0.8]])
.logFloor,
).toBeCloseTo(0.02, 6);
});
it('keeps the log floor within MAX_LOG_DECADES of the max', () => {
expect(
resolveCountDomain({ minCount: null, maxCount: null }, [[1, 1e9]]).logFloor,
).toBe(1e3);
});
it('falls back to a floor of 1 for a grid without a positive count', () => {
expect(
resolveCountDomain({ minCount: null, maxCount: null }, [[null, 0]]).logFloor,
).toBe(1);
});
});
describe('normalizeCount', () => {
const domain = { min: 0, max: 1000, logFloor: 1 };
it('spreads low counts on a log scale where a linear one washes them out', () => {
const log = (count: number): number =>
normalizeCount({ count, domain, scale: HeatmapColorScale.Log });
expect(log(10)).toBeCloseTo(1 / 3, 5);
expect(log(20)).toBeCloseTo(Math.log10(20) / 3, 5);
expect(
normalizeCount({ count: 10, domain, scale: HeatmapColorScale.Linear }),
).toBeCloseTo(0.01, 5);
});
it('puts 0 and 1 at the bottom of a log scale', () => {
expect(
normalizeCount({ count: 0, domain, scale: HeatmapColorScale.Log }),
).toBe(0);
expect(
normalizeCount({ count: 1, domain, scale: HeatmapColorScale.Log }),
).toBe(0);
});
it('reaches the top of the scale at max on every scale', () => {
[
HeatmapColorScale.Log,
HeatmapColorScale.Sqrt,
HeatmapColorScale.Linear,
].forEach((scale) => {
expect(normalizeCount({ count: 1000, domain, scale })).toBeCloseTo(1, 6);
});
});
it('takes the square root of the linear position on a sqrt scale', () => {
expect(
normalizeCount({
count: 250,
domain: { min: 0, max: 1000, logFloor: 1 },
scale: HeatmapColorScale.Sqrt,
}),
).toBeCloseTo(0.5, 6);
});
it('clamps counts outside the domain', () => {
const scale = HeatmapColorScale.Linear;
expect(normalizeCount({ count: -5, domain, scale })).toBe(0);
expect(normalizeCount({ count: 5000, domain, scale })).toBe(1);
});
it('returns the bottom of the scale when min equals max', () => {
expect(
normalizeCount({
count: 7,
domain: { min: 7, max: 7, logFloor: 1 },
scale: HeatmapColorScale.Log,
}),
).toBe(0);
});
it('spreads a log domain that sits entirely below a count of 1', () => {
const fractional = { min: 0, max: 1, logFloor: 0.001 };
const log = (count: number): number =>
normalizeCount({
count,
domain: fractional,
scale: HeatmapColorScale.Log,
});
expect(log(0.001)).toBe(0);
expect(log(0.1)).toBeCloseTo(2 / 3, 5);
expect(log(1)).toBeCloseTo(1, 6);
});
it('reads a log scale linearly when the floor reaches the top of the domain', () => {
const domainAtFloor = { min: 0, max: 1, logFloor: 1 };
expect(
normalizeCount({
count: 1,
domain: domainAtFloor,
scale: HeatmapColorScale.Log,
}),
).toBe(1);
expect(
normalizeCount({
count: 0.5,
domain: domainAtFloor,
scale: HeatmapColorScale.Log,
}),
).toBe(0.5);
});
});
describe('clampColorSteps', () => {
it('clamps to the supported range', () => {
expect(clampColorSteps(1)).toBe(2);
expect(clampColorSteps(500)).toBe(MAX_COLOR_STEPS);
expect(clampColorSteps(32)).toBe(32);
});
it('falls back to the default for a non-finite value', () => {
expect(clampColorSteps(Number.NaN)).toBe(DEFAULT_COLOR_STEPS);
});
});
describe('createHeatmapColorResolver', () => {
const build = (
overrides: Partial<typeof DEFAULT_HEATMAP_COLORS> = {},
isDarkMode = true,
): ReturnType<typeof createHeatmapColorResolver> =>
createHeatmapColorResolver({
options: { ...DEFAULT_HEATMAP_COLORS, ...overrides },
domain: { min: 0, max: 1000, logFloor: 1 },
isDarkMode,
seriesColor: SERIES_COLOR,
});
it('leaves null cells uncoloured so they can be hatched', () => {
const resolver = build();
expect(resolver.colorFor(null)).toBeNull();
expect(resolver.positionOf(null)).toBeNull();
});
it('gives a zero count the bottom colour, not the null treatment', () => {
const resolver = build();
expect(resolver.colorFor(0)).toBe(resolver.ramp[0]);
});
it('emits one ramp entry per step', () => {
expect(build({ steps: 8 }).ramp).toHaveLength(8);
});
it('maps the max count to the top of the ramp', () => {
const resolver = build({ steps: 8 });
expect(resolver.colorFor(1000)).toBe(resolver.ramp[7]);
});
it('picks different stops per theme so low counts stay near the surface', () => {
expect(build({}, true).ramp[0]).not.toBe(build({}, false).ramp[0]);
});
it('varies alpha in opacity mode, never below the visibility floor', () => {
const resolver = build({ mode: HeatmapColorMode.Opacity, steps: 4 });
expect(resolver.ramp[0]).toBe(`rgba(78, 116, 248, ${MIN_OPACITY_ALPHA})`);
// `color` drops the alpha channel from the string once it reaches 1.
expect(resolver.ramp[3]).toBe('rgb(78, 116, 248)');
});
it('prefers an explicit opacity fill over the series colour', () => {
const resolver = build({
mode: HeatmapColorMode.Opacity,
fill: '#e5484d',
steps: 2,
});
expect(resolver.ramp[1]).toBe('rgb(229, 72, 77)');
});
it('reports the domain it applied', () => {
expect(build().domain).toStrictEqual({ min: 0, max: 1000, logFloor: 1 });
});
});

View File

@@ -0,0 +1,649 @@
import {
canUseLogAxis,
decimateAxisSplits,
formatRowLabel,
resolveColumnAlignedSplits,
resolveColumnIndex,
resolveHeatmapYAxis,
resolveRowIndex,
} from '../geometry';
import { HeatmapAxisScale } from '../types';
const BOUNDS = [128, 256, 1024, 4096];
describe('canUseLogAxis', () => {
it('accepts strictly positive bounds', () => {
expect(canUseLogAxis(BOUNDS)).toBe(true);
});
it('rejects a zero or negative bound', () => {
expect(canUseLogAxis([0, 128])).toBe(false);
expect(canUseLogAxis([-1, 128])).toBe(false);
});
it('rejects empty bounds', () => {
expect(canUseLogAxis([])).toBe(false);
});
});
describe('resolveHeatmapYAxis', () => {
it('turns N bounds into N+1 rows with underflow and overflow at the ends', () => {
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
expect(rows).toHaveLength(BOUNDS.length + 1);
expect(rows[0]).toMatchObject({
upper: 128,
isUnderflow: true,
isOverflow: false,
});
expect(rows[1]).toMatchObject({ lower: 128, upper: 256 });
expect(rows[4]).toMatchObject({
lower: 4096,
isOverflow: true,
isUnderflow: false,
});
});
it('exposes one edge per row boundary, ascending', () => {
const { rows, edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
expect(edges).toHaveLength(rows.length + 1);
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
});
it('places bounds in log space so row heights are log-proportional', () => {
const { splits, min, max } = resolveHeatmapYAxis(
BOUNDS,
HeatmapAxisScale.Log,
);
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
// Outer edges extend by the geometric mean ratio, (4096/128)^(1/3) = 3.174…
expect(10 ** min).toBeCloseTo(128 / (4096 / 128) ** (1 / 3), 6);
expect(10 ** max).toBeCloseTo(4096 * (4096 / 128) ** (1 / 3), 6);
});
it('keeps bounds in value space on a linear axis', () => {
const { splits, min } = resolveHeatmapYAxis(
[10, 20, 30],
HeatmapAxisScale.Linear,
);
expect(splits).toStrictEqual([10, 20, 30]);
// Mean gap is 10, and the underflow edge never crosses zero.
expect(min).toBe(0);
});
it('sorts and de-duplicates bounds', () => {
const { rows, splits } = resolveHeatmapYAxis(
[256, 128, 256, Number.NaN],
HeatmapAxisScale.Log,
);
expect(splits).toStrictEqual([Math.log10(128), Math.log10(256)]);
expect(rows).toHaveLength(3);
});
it('gives a single bound an underflow and an overflow row', () => {
const { rows, edges } = resolveHeatmapYAxis([100], HeatmapAxisScale.Log);
expect(rows).toHaveLength(2);
expect(rows[0].isUnderflow).toBe(true);
expect(rows[1].isOverflow).toBe(true);
expect(edges).toHaveLength(3);
});
it('degrades to an empty axis with no bounds', () => {
expect(resolveHeatmapYAxis([], HeatmapAxisScale.Log).rows).toStrictEqual([]);
});
it('puts the overflow label on the row"s upper edge, clear of the last boundary', () => {
const { overflowSplit, edges } = resolveHeatmapYAxis(
BOUNDS,
HeatmapAxisScale.Log,
);
// A full row above the last boundary tick, so the two labels cannot collide.
expect(overflowSplit).toBe(edges[edges.length - 1]);
});
});
describe('resolveRowIndex', () => {
const { edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
it('finds the row containing a value', () => {
expect(resolveRowIndex(edges, 200)).toBe(1);
expect(resolveRowIndex(edges, 2000)).toBe(3);
});
it('assigns a boundary to the row it opens', () => {
expect(resolveRowIndex(edges, 256)).toBe(2);
});
it('returns the last row on the top edge', () => {
expect(resolveRowIndex(edges, edges[edges.length - 1])).toBe(
edges.length - 2,
);
});
it('returns null outside the grid', () => {
expect(resolveRowIndex(edges, edges[0] - 1)).toBeNull();
expect(resolveRowIndex(edges, edges[edges.length - 1] + 1)).toBeNull();
});
it('returns null without at least one row', () => {
expect(resolveRowIndex([5], 5)).toBeNull();
});
});
describe('resolveColumnIndex', () => {
const timestamps = [100, 160, 220, 280];
const step = 60;
it('resolves by containment, not proximity', () => {
// 155 is nearer to 160, but the observations at 155 belong to column 0.
expect(resolveColumnIndex(timestamps, 155, step)).toBe(0);
expect(resolveColumnIndex(timestamps, 160, step)).toBe(1);
});
it('includes the column start and excludes its end', () => {
expect(resolveColumnIndex(timestamps, 100, step)).toBe(0);
expect(resolveColumnIndex(timestamps, 159.9, step)).toBe(0);
});
it('covers the trailing column using the step, not the next timestamp', () => {
expect(resolveColumnIndex(timestamps, 330, step)).toBe(3);
expect(resolveColumnIndex(timestamps, 340, step)).toBeNull();
});
it('returns null before the first column', () => {
expect(resolveColumnIndex(timestamps, 99, step)).toBeNull();
});
it('returns null with no columns', () => {
expect(resolveColumnIndex([], 100, step)).toBeNull();
});
it('leaves the last column open when the step is unknown', () => {
expect(resolveColumnIndex(timestamps, 10_000, 0)).toBe(3);
});
});
describe('formatRowLabel', () => {
const format = (value: number): string => `${value}ms`;
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
it('labels the underflow row by its only real bound', () => {
expect(formatRowLabel(rows[0], format)).toBe('≤ 128ms');
});
it('labels the overflow row by its only real bound', () => {
expect(formatRowLabel(rows[rows.length - 1], format)).toBe('> 4096ms');
});
it('labels an interior row as a range', () => {
expect(formatRowLabel(rows[1], format)).toBe('128ms 256ms');
});
});
describe('decimateAxisSplits', () => {
const splits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const domain = { min: 0, max: 10 };
it('keeps every tick when they all fit', () => {
expect(
decimateAxisSplits({ ...domain, splits, plotHeight: 400, minGapPx: 18 }),
).toStrictEqual(splits);
});
it('thins to whatever fits at the available height', () => {
// 11 ticks over 100px is 10px apart; an 18px floor keeps every other one.
expect(
decimateAxisSplits({ ...domain, splits, plotHeight: 100, minGapPx: 18 }),
).toStrictEqual([0, 2, 4, 6, 8, 10]);
});
it('always keeps the topmost tick, so the overflow edge survives thinning', () => {
const thinned = decimateAxisSplits({
...domain,
splits,
plotHeight: 40,
minGapPx: 18,
});
expect(thinned[thinned.length - 1]).toBe(10);
});
it('returns ascending positions', () => {
const thinned = decimateAxisSplits({
...domain,
splits,
plotHeight: 60,
minGapPx: 18,
});
expect([...thinned].sort((a, b) => a - b)).toStrictEqual(thinned);
});
it('thins by pixel distance, not index, so uneven rows are handled', () => {
// Three boundaries bunched at the bottom of a wide linear domain: only the
// first and the far-away last are far enough apart to both get labels.
expect(
decimateAxisSplits({
splits: [1, 2, 3, 1000],
min: 0,
max: 1000,
plotHeight: 200,
minGapPx: 18,
}),
).toStrictEqual([3, 1000]);
});
it('leaves the tick set alone when it cannot measure', () => {
expect(
decimateAxisSplits({ ...domain, splits, plotHeight: 0, minGapPx: 18 }),
).toStrictEqual(splits);
expect(
decimateAxisSplits({
splits,
min: 5,
max: 5,
plotHeight: 400,
minGapPx: 18,
}),
).toStrictEqual(splits);
});
});
describe('resolveHeatmapYAxis — the scale auto picks', () => {
// The OTel SDK default explicit bucket boundaries, which start at zero.
const OTEL = [
0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000,
];
// Clock skew in ms — a logs/traces field that straddles zero.
const SKEW = [-1000, -100, -10, -1, 0, 1, 10, 100, 1000];
const PLOT_HEIGHT = 250;
/** Row heights in axis units, which map linearly to pixels. */
function rowHeights(bounds: number[]): number[] {
const { edges } = resolveHeatmapYAxis(bounds, HeatmapAxisScale.Auto);
return edges.slice(1).map((edge, index) => edge - edges[index]);
}
/** Shortest row, in pixels, for a plot of `PLOT_HEIGHT`. */
function shortestRowPx(bounds: number[], scale: HeatmapAxisScale): number {
const { edges } = resolveHeatmapYAxis(bounds, scale);
const span = edges[edges.length - 1] - edges[0];
const heights = edges
.slice(1)
.map((edge, index) => ((edge - edges[index]) / span) * PLOT_HEIGHT);
return Math.min(...heights);
}
it('keeps a zero boundary on a log axis instead of giving up to linear', () => {
const { splits } = resolveHeatmapYAxis([0, 5, 10], HeatmapAxisScale.Auto);
// A linear fallback would leave the boundaries untransformed.
expect(splits).not.toStrictEqual([0, 5, 10]);
});
it('is what an all-positive layout does NOT get — that stays a plain log', () => {
const { splits } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Auto);
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
});
it('gives every row a usable height for the OTel default boundaries', () => {
// Linear squeezes the 0100ms buckets — where the data is — under a pixel.
expect(shortestRowPx(OTEL, HeatmapAxisScale.Linear)).toBeLessThan(1);
expect(shortestRowPx(OTEL, HeatmapAxisScale.Auto)).toBeGreaterThan(4);
});
it('gives the row above zero one bucket of height, not a whole decade', () => {
const heights = rowHeights(OTEL);
const { rows } = resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Auto);
const nearZero = rows.findIndex((row) => row.lower === 0 && row.upper === 5);
const positive = OTEL.filter((bound) => bound > 0);
const bucket =
(Math.log10(positive[positive.length - 1]) - Math.log10(positive[0])) /
(positive.length - 1);
expect(heights[nearZero]).toBeCloseTo(bucket, 6);
});
it('reads a lone zero boundary as a log layout, not a zero-crossing one', () => {
expect(resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Auto).edges).toStrictEqual(
resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Log).edges,
);
});
it('turns to symlog once a boundary reaches below zero', () => {
expect(resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Auto).edges).toStrictEqual(
resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Symlog).edges,
);
});
it('places boundaries either side of zero symmetrically', () => {
const heights = rowHeights(SKEW);
expect(Math.max(...heights) - Math.min(...heights)).toBeCloseTo(0, 6);
});
it('keeps negative boundaries ascending', () => {
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Auto);
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
});
it('round-trips a boundary back to its bucket value', () => {
const { splits, toBucketValue } = resolveHeatmapYAxis(
SKEW,
HeatmapAxisScale.Auto,
);
expect(
splits.map((split) => Math.round(toBucketValue(split) * 1e6) / 1e6),
).toStrictEqual(SKEW);
});
it('derives the linear threshold from the smallest non-zero boundary', () => {
// Threshold 10 puts -10 at -1 and 0 at 0 in axis space.
const { edges, rows } = resolveHeatmapYAxis(
[-100, -10, 0, 10, 100],
HeatmapAxisScale.Auto,
);
const crossing = rows.findIndex(
(row) => row.lower === -10 && row.upper === 0,
);
expect(edges[crossing]).toBeCloseTo(-1, 6);
expect(edges[crossing + 1]).toBeCloseTo(0, 6);
});
it('leaves an all-positive layout on a plain log axis', () => {
const { splits } = resolveHeatmapYAxis(
[128, 256, 1024],
HeatmapAxisScale.Auto,
);
expect(splits).toStrictEqual([128, 256, 1024].map((b) => Math.log10(b)));
});
it('falls back to linear when every boundary is zero', () => {
const { splits } = resolveHeatmapYAxis([0], HeatmapAxisScale.Auto);
expect(splits).toStrictEqual([0]);
});
});
describe('resolveHeatmapYAxis — an explicitly chosen scale', () => {
// The low end of the OTel SDK defaults: a zero bucket, then positive bounds.
const ZERO_HEAD = [0, 5, 10, 25];
// Clock skew in ms — a field that straddles zero.
const SKEW = [-100, -10, 0, 10, 100];
/** Row heights in axis units, which map linearly to pixels. */
function rowHeights(bounds: number[], scale: HeatmapAxisScale): number[] {
const { edges } = resolveHeatmapYAxis(bounds, scale);
return edges.slice(1).map((edge, index) => edge - edges[index]);
}
it('log stays a plain log10 for a positive layout', () => {
const { splits } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
});
it('log keeps its own answer for a zero bucket rather than becoming a symlog', () => {
const { splits } = resolveHeatmapYAxis(ZERO_HEAD, HeatmapAxisScale.Log);
// One bucket below the smallest positive bound, where a symlog would put it
// a whole decade below.
const gap = (Math.log10(25) - Math.log10(5)) / 2;
expect(splits[0]).toBeCloseTo(Math.log10(5) - gap, 6);
expect(splits.slice(1)).toStrictEqual([5, 10, 25].map((b) => Math.log10(b)));
});
it('log spends a bucket on the zero-crossing row where symlog spends a decade', () => {
const gap = (Math.log10(25) - Math.log10(5)) / 2;
// Row 1 is (0, 5] — the row above the zero bucket.
expect(rowHeights(ZERO_HEAD, HeatmapAxisScale.Log)[1]).toBeCloseTo(gap, 6);
expect(rowHeights(ZERO_HEAD, HeatmapAxisScale.Symlog)[1]).toBeCloseTo(1, 6);
});
it('log leaves every edge ascending with a zero bucket in the layout', () => {
const { edges } = resolveHeatmapYAxis(ZERO_HEAD, HeatmapAxisScale.Log);
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
});
it('log squashes several non-positive boundaries onto one edge — what symlog is for', () => {
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Log);
// -100, -10 and 0 have no logarithm and share the floor.
expect(edges[1]).toBe(edges[2]);
expect(edges[2]).toBe(edges[3]);
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
});
it('log has nothing to compress without a positive boundary and stays linear', () => {
const { splits } = resolveHeatmapYAxis([-5, 0], HeatmapAxisScale.Log);
expect(splits).toStrictEqual([-5, 0]);
});
it('symlog is a choice for a positive layout too, and is not the log axis', () => {
const positive = [1, 10, 100];
expect(
resolveHeatmapYAxis(positive, HeatmapAxisScale.Log).splits,
).toStrictEqual([0, 1, 2]);
// Threshold 1: the boundaries sit a decade apart, one unit above the linear band.
expect(
resolveHeatmapYAxis(positive, HeatmapAxisScale.Symlog).splits,
).toStrictEqual([1, 2, 3]);
});
it('symlog places boundaries either side of zero symmetrically', () => {
const heights = rowHeights(SKEW, HeatmapAxisScale.Symlog);
expect(Math.max(...heights) - Math.min(...heights)).toBeCloseTo(0, 6);
});
it('symlog has no magnitude to scale against when every boundary is zero', () => {
const { splits } = resolveHeatmapYAxis([0], HeatmapAxisScale.Symlog);
expect(splits).toStrictEqual([0]);
});
});
describe('resolveColumnAlignedSplits', () => {
const MINUTE = 60;
const HOUR = 3600;
const DAY = 86400;
/** uPlot's `tzDate` for a fixed-offset zone: the returned date's local fields
* read as that zone's wall clock, whatever the machine's own zone is. */
const zoneAt =
(offsetSeconds: number) =>
(timestamp: number): Date => {
const browserOffset =
-new Date(timestamp * 1e3).getTimezoneOffset() * MINUTE;
return new Date((timestamp + offsetSeconds - browserOffset) * 1e3);
};
const UTC = zoneAt(0);
/** IST, whose half-hour offset is what pulls ticks off round local times. */
const IST = zoneAt(5.5 * HOUR);
/** 2024-03-11T00:00:00Z, a Monday. */
const MIDNIGHT_UTC = 1_710_115_200;
it('lands every tick on a column edge', () => {
const step = 90;
const anchor = 1_700_000_010;
const splits = resolveColumnAlignedSplits({
anchor,
step,
incr: 5 * MINUTE,
min: anchor,
max: anchor + 40 * step,
});
expect(splits.length).toBeGreaterThan(1);
splits.forEach((split) => {
expect((split - anchor) % step).toBe(0);
});
});
it('rounds the increment up to a whole number of columns', () => {
const splits = resolveColumnAlignedSplits({
anchor: 0,
step: 90,
incr: 5 * MINUTE,
min: 0,
max: HOUR,
toDate: UTC,
});
// 300s asked for, 360s is the next multiple of the 90s column.
expect(splits[1] - splits[0]).toBe(360);
});
it('covers the visible range without overshooting it', () => {
const splits = resolveColumnAlignedSplits({
anchor: 1000,
step: 100,
incr: 200,
min: 1050,
max: 1650,
toDate: UTC,
});
expect(splits[0]).toBeGreaterThanOrEqual(1050);
expect(splits[splits.length - 1]).toBeLessThanOrEqual(1650);
expect(splits[0] - 200).toBeLessThan(1050);
});
it("starts hourly ticks on the timezone's own hour, not the epoch's", () => {
const args = {
anchor: MIDNIGHT_UTC,
step: 5 * MINUTE,
incr: HOUR,
min: MIDNIGHT_UTC,
max: MIDNIGHT_UTC + 3 * HOUR,
};
// 05:30 IST is midnight UTC, so the two disagree by the half hour.
expect(resolveColumnAlignedSplits({ ...args, toDate: UTC })).toStrictEqual([
MIDNIGHT_UTC,
MIDNIGHT_UTC + HOUR,
MIDNIGHT_UTC + 2 * HOUR,
MIDNIGHT_UTC + 3 * HOUR,
]);
expect(resolveColumnAlignedSplits({ ...args, toDate: IST })).toStrictEqual([
MIDNIGHT_UTC + 0.5 * HOUR,
MIDNIGHT_UTC + 1.5 * HOUR,
MIDNIGHT_UTC + 2.5 * HOUR,
]);
});
it('gives up the round local time when no column edge carries one', () => {
// Hour-wide columns start on the UTC hour, so 00:00 IST is mid-cell and
// the nearest edge — 00:30 IST — is as close as the grid gets.
const splits = resolveColumnAlignedSplits({
anchor: MIDNIGHT_UTC,
step: HOUR,
incr: HOUR,
min: MIDNIGHT_UTC,
max: MIDNIGHT_UTC + 2 * HOUR,
toDate: IST,
});
expect(splits).toStrictEqual([
MIDNIGHT_UTC,
MIDNIGHT_UTC + HOUR,
MIDNIGHT_UTC + 2 * HOUR,
]);
});
it('puts a daily tick on the local day boundary', () => {
const splits = resolveColumnAlignedSplits({
anchor: MIDNIGHT_UTC,
step: 15 * MINUTE,
incr: DAY,
min: MIDNIGHT_UTC,
max: MIDNIGHT_UTC + 3 * DAY,
toDate: IST,
});
// 18:30 UTC the previous day is IST midnight, and 15m columns carry it.
expect(splits).toHaveLength(3);
splits.forEach((split) => {
expect(IST(split).getHours()).toBe(0);
expect(IST(split).getMinutes()).toBe(0);
});
});
it('walks month ticks as calendar dates, snapped to column edges', () => {
const splits = resolveColumnAlignedSplits({
anchor: MIDNIGHT_UTC,
step: DAY,
incr: 28 * DAY,
min: MIDNIGHT_UTC,
max: MIDNIGHT_UTC + 120 * DAY,
toDate: UTC,
});
// Month starts, not a drifting 28-day cadence that repeats a month name.
expect(
splits.map((split) => new Date(split * 1e3).toISOString()),
).toStrictEqual([
'2024-04-01T00:00:00.000Z',
'2024-05-01T00:00:00.000Z',
'2024-06-01T00:00:00.000Z',
'2024-07-01T00:00:00.000Z',
]);
});
it("falls back to uPlot's own increment without a column width", () => {
expect(
resolveColumnAlignedSplits({
anchor: MIDNIGHT_UTC,
step: 0,
incr: 5 * MINUTE,
min: MIDNIGHT_UTC,
max: MIDNIGHT_UTC + 15 * MINUTE,
toDate: UTC,
}),
).toStrictEqual([
MIDNIGHT_UTC,
MIDNIGHT_UTC + 5 * MINUTE,
MIDNIGHT_UTC + 10 * MINUTE,
MIDNIGHT_UTC + 15 * MINUTE,
]);
});
it('has nothing to place on an empty or inverted range', () => {
expect(
resolveColumnAlignedSplits({
anchor: 0,
step: MINUTE,
incr: MINUTE,
min: 10,
max: 10,
}),
).toStrictEqual([]);
expect(
resolveColumnAlignedSplits({
anchor: 0,
step: MINUTE,
incr: 0,
min: 0,
max: 100,
}),
).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,176 @@
import { resolveHeatmapGrid } from '../grid';
import { HeatmapSeries } from '../types';
const BUCKETS = [10, 20];
const STEP = 60;
/** Two groups over two columns, each missing a value the other reports. */
const TWO_GROUPS: HeatmapSeries[] = [
{
label: 'cart',
points: [
{ timestamp: 60, counts: [1, 2, 3] },
{ timestamp: 120, counts: [null, 5, 6] },
],
},
{
label: 'checkout',
points: [
{ timestamp: 60, counts: [10, 20, 30] },
{ timestamp: 120, counts: [40, null, 60] },
],
},
];
function resolve(
overrides: Partial<Parameters<typeof resolveHeatmapGrid>[0]> = {},
): ReturnType<typeof resolveHeatmapGrid> {
return resolveHeatmapGrid({
buckets: BUCKETS,
step: STEP,
series: TWO_GROUPS,
...overrides,
});
}
describe('resolveHeatmapGrid', () => {
it('pivots per-timestamp count arrays into one row per bucket', () => {
const { counts } = resolve({ series: [TWO_GROUPS[0]] });
// 2 boundaries describe 3 rows; each row spans both columns.
expect(counts).toStrictEqual([
[1, null],
[2, 5],
[3, 6],
]);
});
it('carries the bounds and step through untouched', () => {
const { bounds, step } = resolve();
expect(bounds).toStrictEqual(BUCKETS);
expect(step).toBe(STEP);
});
it('sums every group for the combined view', () => {
const { counts } = resolve();
expect(counts[0]).toStrictEqual([11, 40]);
expect(counts[2]).toStrictEqual([33, 66]);
});
it('keeps one group"s count where the other has no data', () => {
const { counts } = resolve();
// cart is null at 120 in row 0 while checkout reports 40.
expect(counts[0][1]).toBe(40);
// checkout is null at 120 in row 1 while cart reports 5.
expect(counts[1][1]).toBe(5);
});
it('reports a cell as no-data only when every group is missing it', () => {
const { counts } = resolve({
buckets: [10],
series: [
{ label: 'a', points: [{ timestamp: 60, counts: [null, null] }] },
{ label: 'b', points: [{ timestamp: 60, counts: [null, null] }] },
],
});
expect(counts).toStrictEqual([[null], [null]]);
});
it('distinguishes a zero count from no data', () => {
const { counts } = resolve({
buckets: [10],
series: [{ label: 'a', points: [{ timestamp: 60, counts: [0, null] }] }],
});
expect(counts[0][0]).toBe(0);
expect(counts[1][0]).toBeNull();
});
it('sums only the groups the legend has enabled', () => {
const { counts } = resolve({ visibleGroups: ['cart'] });
expect(counts[0]).toStrictEqual([1, null]);
expect(counts[2]).toStrictEqual([3, 6]);
});
it('sums every group when the legend passes nothing', () => {
const { counts } = resolve({ visibleGroups: undefined });
expect(counts[0]).toStrictEqual([11, 40]);
});
it('ignores an enabled label that left the result', () => {
const { counts } = resolve({ visibleGroups: ['cart', 'gone'] });
expect(counts[0]).toStrictEqual([1, null]);
});
it('empties the grid when every group is excluded', () => {
const { timestamps, counts } = resolve({ visibleGroups: [] });
expect(timestamps).toStrictEqual([]);
expect(counts.every((row) => row.length === 0)).toBe(true);
});
it('unions timestamps when groups do not align', () => {
const { timestamps, counts } = resolve({
buckets: [10],
series: [
{ label: 'a', points: [{ timestamp: 60, counts: [1, 2] }] },
{ label: 'b', points: [{ timestamp: 180, counts: [3, 4] }] },
],
});
expect(timestamps).toStrictEqual([60, 180]);
expect(counts[0]).toStrictEqual([1, 3]);
});
it('sorts columns ascending regardless of response order', () => {
const { timestamps } = resolve({
buckets: [10],
series: [
{
label: 'a',
points: [
{ timestamp: 180, counts: [1, 2] },
{ timestamp: 60, counts: [3, 4] },
],
},
],
});
expect(timestamps).toStrictEqual([60, 180]);
});
it('pads rows the response left short', () => {
const { counts } = resolve({
buckets: [10, 20, 30],
series: [{ label: 'a', points: [{ timestamp: 60, counts: [1, 2] }] }],
});
expect(counts).toStrictEqual([[1], [2], [null], [null]]);
});
it('ignores counts beyond the bucket rows', () => {
const { counts } = resolve({
buckets: [10],
series: [{ label: 'a', points: [{ timestamp: 60, counts: [1, 2, 99] }] }],
});
expect(counts).toStrictEqual([[1], [2]]);
});
it('degrades to an empty grid with no buckets or no series', () => {
expect(resolve({ buckets: [] })).toStrictEqual({
bounds: [],
timestamps: [],
step: 0,
counts: [],
});
expect(resolve({ series: [] }).counts).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,304 @@
import type uPlot from 'uplot';
import { DEFAULT_HEATMAP_COLORS } from '../colorScale';
import { resolveHeatmapYAxis } from '../geometry';
import { createHeatmapHooks } from '../heatmapPlugin';
import { HeatmapAxisScale, HeatmapCell } from '../types';
const BOUNDS = [100, 1000];
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
const TIMESTAMPS = [1000, 1060, 1120];
const STEP = 60;
const PLOT_WIDTH = 300;
const PLOT_HEIGHT = 300;
// Three rows for two bounds, three columns; row 1 column 1 is a data gap.
const DATA = [
TIMESTAMPS,
[1, 2, 3],
[4, null, 6],
[7, 8, 9],
] as unknown as uPlot.AlignedData;
interface FakeContext {
fillRect: jest.Mock;
fills: string[];
}
interface FakePlot {
plot: uPlot;
context: FakeContext;
setSeries: jest.Mock;
over: HTMLDivElement;
}
function createFakePlot(cursor: { left: number; top: number }): FakePlot {
const over = document.createElement('div');
Object.defineProperty(over, 'clientWidth', { value: PLOT_WIDTH });
Object.defineProperty(over, 'clientHeight', { value: PLOT_HEIGHT });
const fills: string[] = [];
const fillRect = jest.fn();
const context = { fills, fillRect };
const setSeries = jest.fn();
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
const ySpan = Y_AXIS.max - Y_AXIS.min;
const ctx = {
save: jest.fn(),
restore: jest.fn(),
beginPath: jest.fn(),
rect: jest.fn(),
clip: jest.fn(),
moveTo: jest.fn(),
lineTo: jest.fn(),
stroke: jest.fn(),
setLineDash: jest.fn(),
createPattern: jest.fn(() => null),
set fillStyle(value: string) {
fills.push(value);
},
fillRect: (...args: number[]): void => {
fillRect(...args);
},
};
const plot = {
data: DATA,
cursor,
over,
setSeries,
ctx,
bbox: { left: 0, top: 0, width: PLOT_WIDTH, height: PLOT_HEIGHT },
scales: { x: { min: TIMESTAMPS[0], max: TIMESTAMPS[2] + STEP } },
// x grows left to right; y is inverted, so the highest bucket is at the top.
valToPos: (value: number, scaleKey: string): number =>
scaleKey === 'x'
? ((value - TIMESTAMPS[0]) / xSpan) * PLOT_WIDTH
: PLOT_HEIGHT - ((value - Y_AXIS.min) / ySpan) * PLOT_HEIGHT,
posToVal: (pos: number, scaleKey: string): number =>
scaleKey === 'x'
? TIMESTAMPS[0] + (pos / PLOT_WIDTH) * xSpan
: Y_AXIS.min + ((PLOT_HEIGHT - pos) / PLOT_HEIGHT) * ySpan,
};
return { plot: plot as unknown as uPlot, context, setSeries, over };
}
function createHooks(
onHoverChange?: (cell: HeatmapCell | null) => void,
dimOnHover = true,
): ReturnType<typeof createHeatmapHooks> {
return createHeatmapHooks({
yAxis: Y_AXIS,
step: STEP,
colors: DEFAULT_HEATMAP_COLORS,
isDarkMode: true,
seriesColor: '#4e74f8',
dimOnHover,
onHoverChange,
});
}
describe('heatmap renderer — lifecycle', () => {
it('mounts the hover overlay into the plot overlay and tears it down', () => {
const hooks = createHooks();
const { plot, over } = createFakePlot({ left: -10, top: -10 });
hooks.init(plot);
expect(
over.querySelector('[data-testid="heatmap-hover-overlay"]'),
).not.toBeNull();
hooks.destroy(plot);
expect(
over.querySelector('[data-testid="heatmap-hover-overlay"]'),
).toBeNull();
});
});
describe('heatmap renderer — draw', () => {
it('paints every cell of every visible column', () => {
const hooks = createHooks();
const { plot, context } = createFakePlot({ left: -10, top: -10 });
hooks.init(plot);
hooks.draw(plot);
// 3 rows x 3 columns, less the one null cell that has no hatch pattern
// available under jsdom.
expect(context.fillRect).toHaveBeenCalledTimes(8);
});
it('gives a zero count the bottom-of-scale fill rather than skipping it', () => {
const hooks = createHooks();
const zeroed = [TIMESTAMPS, [0, 0, 0], [0, 0, 0], [0, 0, 0]];
const { plot, context } = createFakePlot({ left: -10, top: -10 });
(plot as { data: unknown }).data = zeroed;
hooks.init(plot);
hooks.draw(plot);
expect(context.fillRect).toHaveBeenCalledTimes(9);
expect(new Set(context.fills).size).toBe(1);
});
it('skips columns outside the current x range', () => {
const hooks = createHooks();
const { plot, context } = createFakePlot({ left: -10, top: -10 });
(plot as { scales: unknown }).scales = {
x: { min: TIMESTAMPS[0], max: TIMESTAMPS[0] + STEP },
};
hooks.init(plot);
hooks.draw(plot);
// Only the first two columns overlap the range; the third starts past its end.
// 2 columns x 3 rows, less the null cell in column 1.
expect(context.fillRect).toHaveBeenCalledTimes(5);
});
it('draws nothing without columns', () => {
const hooks = createHooks();
const { plot, context } = createFakePlot({ left: -10, top: -10 });
(plot as { data: unknown }).data = [[]];
hooks.init(plot);
hooks.draw(plot);
expect(context.fillRect).not.toHaveBeenCalled();
});
});
describe('heatmap renderer — hover', () => {
it('focuses the hovered row and reports the cell under the cursor', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
// Left third of the plot is column 0; the top third is the overflow row.
const { plot, setSeries } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenCalledWith({ row: 2, column: 0, count: 7 });
expect(setSeries).toHaveBeenCalledWith(3, { focus: true });
});
it('reports a data gap as a null count instead of zero', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
const { plot } = createFakePlot({
left: PLOT_WIDTH / 2,
top: PLOT_HEIGHT / 2,
});
hooks.init(plot);
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenCalledWith({
row: 1,
column: 1,
count: null,
});
});
it('does not re-report the same cell', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
const { plot } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenCalledTimes(1);
});
it('shows the overlay over the hovered cell and dims around it', () => {
const hooks = createHooks(undefined, true);
const { plot, over } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
const overlay = over.querySelector<HTMLDivElement>(
'[data-testid="heatmap-hover-overlay"]',
);
expect(overlay?.style.display).toBe('block');
// Column 0 spans the left third of a 300px plot.
expect(overlay?.lastElementChild).toHaveStyle({
left: '0px',
width: '100px',
});
});
it('clips the overlay to the plot area', () => {
const hooks = createHooks();
const { plot, over } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
// An end cell whose bucket or time slice is only partly in view is positioned
// past the axis; the plot area's edge is where the highlight has to stop.
const overlay = over.querySelector<HTMLDivElement>(
'[data-testid="heatmap-hover-overlay"]',
);
expect(overlay?.style.overflow).toBe('hidden');
});
it('collapses the dim rects when dimming is off', () => {
const hooks = createHooks(undefined, false);
const { plot, over } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
const overlay = over.querySelector<HTMLDivElement>(
'[data-testid="heatmap-hover-overlay"]',
);
expect(overlay?.firstElementChild).toHaveStyle({
width: '0px',
height: '0px',
});
});
it('releases focus and hides the overlay when the cursor leaves', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
const { plot, over, setSeries } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
(plot as { cursor: { left: number; top: number } }).cursor = {
left: -10,
top: -10,
};
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenLastCalledWith(null);
expect(setSeries).toHaveBeenLastCalledWith(null, { focus: true });
expect(
over.querySelector<HTMLDivElement>('[data-testid="heatmap-hover-overlay"]')
?.style.display,
).toBe('none');
});
it('clears the hover when the cursor is inside the plot but past the last column', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
const { plot } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
(plot as { data: unknown }).data = [[], [], [], []];
(plot as { cursor: { left: number; top: number } }).cursor = {
left: 10,
top: 10,
};
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenLastCalledWith(null);
});
});

View File

@@ -0,0 +1,75 @@
import { getPaletteStops } from '../palettes';
import { HeatmapColorPalette } from '../types';
const ALL_PALETTES = Object.values(HeatmapColorPalette);
/** Perceived brightness, good enough to tell a ramp's ends apart. */
function luminance(hex: string): number {
const value = parseInt(hex.slice(1), 16);
// eslint-disable-next-line no-bitwise
const [r, g, b] = [(value >> 16) & 255, (value >> 8) & 255, value & 255];
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
describe('getPaletteStops', () => {
it.each(ALL_PALETTES)('%s is a full ramp of valid colours', (palette) => {
const stops = getPaletteStops(palette, true);
expect(stops).toHaveLength(9);
stops.forEach((stop) => expect(stop).toMatch(/^#[0-9a-f]{6}$/));
});
it.each(ALL_PALETTES)(
'%s climbs from dark to bright on a dark panel',
(palette) => {
const stops = getPaletteStops(palette, true);
// Low counts must sit near the surface, whichever direction the ramp is
// stored in — otherwise empty cells become the loudest thing on screen.
expect(luminance(stops[0])).toBeLessThan(luminance(stops[stops.length - 1]));
},
);
it.each(ALL_PALETTES)(
'%s falls from pale to saturated on a light panel',
(palette) => {
const stops = getPaletteStops(palette, false);
expect(luminance(stops[0])).toBeGreaterThan(
luminance(stops[stops.length - 1]),
);
},
);
it.each(ALL_PALETTES)('%s uses the same colours in both themes', (palette) => {
// Only the polarity flips; the palette itself is theme-independent.
expect([...getPaletteStops(palette, false)].reverse()).toStrictEqual(
getPaletteStops(palette, true),
);
});
it('never mutates the stored ramp when reversing it', () => {
const first = getPaletteStops(HeatmapColorPalette.Lava, false);
const second = getPaletteStops(HeatmapColorPalette.Lava, false);
expect(first).toStrictEqual(second);
});
it('falls back to the first ramp for an unknown palette', () => {
const unknown = 'nope' as HeatmapColorPalette;
expect(getPaletteStops(unknown, true)).toStrictEqual(
getPaletteStops(HeatmapColorPalette.Ice, true),
);
});
it('offers a neutral ramp for panels that already spend colour elsewhere', () => {
const stops = getPaletteStops(HeatmapColorPalette.Graphite, true);
// Every stop is a grey: red, green and blue channels stay equal.
stops.forEach((stop) => {
expect(stop.slice(1, 3)).toBe(stop.slice(3, 5));
expect(stop.slice(3, 5)).toBe(stop.slice(5, 7));
});
});
});

View File

@@ -0,0 +1,244 @@
import { Color as DesignToken } from '@signozhq/design-tokens';
import Color from 'color';
import { getPaletteStops } from './palettes';
import {
HeatmapColorMode,
HeatmapColorOptions,
HeatmapColorScale,
HeatmapColorPalette,
} from './types';
export const MIN_COLOR_STEPS = 2;
export const MAX_COLOR_STEPS = 128;
export const DEFAULT_COLOR_STEPS = 64;
/** Without a floor, the lowest counts read as "no data". */
export const MIN_OPACITY_ALPHA = 0.1;
/** Log's bottom when the grid holds no positive count to take it from. */
const DEFAULT_LOG_FLOOR = 1;
/** Widest span a single ramp is stretched over on a log scale. */
const MAX_LOG_DECADES = 6;
/** Used when neither an explicit fill nor a series colour is available. */
export const DEFAULT_OPACITY_FILL = DesignToken.BG_ROBIN_500;
export const DEFAULT_HEATMAP_COLORS: HeatmapColorOptions = {
mode: HeatmapColorMode.Palette,
scale: HeatmapColorScale.Log,
minCount: null,
maxCount: null,
palette: HeatmapColorPalette.Lava,
steps: DEFAULT_COLOR_STEPS,
fill: '',
};
export interface CountDomain {
min: number;
max: number;
/** Bottom decade of the log scale: the smallest count it separates from zero.
* Always positive, since log has no bottom otherwise. */
logFloor: number;
}
/** Highest count, ignoring `null`. 0 for an empty grid. */
export function getMaxCount(counts: Array<Array<number | null>>): number {
let max = 0;
for (const row of counts) {
for (const count of row) {
if (count !== null && Number.isFinite(count) && count > max) {
max = count;
}
}
}
return max;
}
/** Smallest count above zero, ignoring `null`. `null` for a grid without one. */
export function getSmallestPositiveCount(
counts: Array<Array<number | null>>,
): number | null {
let smallest: number | null = null;
for (const row of counts) {
for (const count of row) {
if (
count !== null &&
Number.isFinite(count) &&
count > 0 &&
(smallest === null || count < smallest)
) {
smallest = count;
}
}
}
return smallest;
}
/**
* The grid's own resolution: whole counts floor at 1, while a heatmap of rates or
* ratios can live entirely below it, where a fixed floor of 1 would flatten every
* cell onto the bottom colour. Capped at `MAX_LOG_DECADES` so one stray tiny cell
* cannot stretch the ramp over a range nothing else occupies.
*/
function resolveLogFloor(
counts: Array<Array<number | null>>,
max: number,
): number {
const smallest = getSmallestPositiveCount(counts) ?? DEFAULT_LOG_FLOOR;
return Math.max(smallest, max / 10 ** MAX_LOG_DECADES);
}
/** Explicit clamps win; otherwise 0 to the grid's highest count. */
export function resolveCountDomain(
options: Pick<HeatmapColorOptions, 'minCount' | 'maxCount'>,
counts: Array<Array<number | null>>,
): CountDomain {
const min = options.minCount ?? 0;
const max = options.maxCount ?? getMaxCount(counts);
const logFloor = resolveLogFloor(counts, max);
return max > min ? { min, max, logFloor } : { min, max: min, logFloor };
}
/** Position on the colour scale, 0..1. A degenerate domain collapses to 0 so an
* all-zero grid renders at the bottom rather than disappearing. */
export function normalizeCount({
count,
domain,
scale,
}: {
count: number;
domain: CountDomain;
scale: HeatmapColorScale;
}): number {
const { min, max } = domain;
if (!(max > min)) {
return 0;
}
const clamped = Math.min(Math.max(count, min), max);
if (scale === HeatmapColorScale.Log) {
// Anything at or below the floor sits at the bottom; log cannot place it.
const bottom = Math.max(min, domain.logFloor);
const logMin = Math.log10(bottom);
const logMax = Math.log10(max);
if (logMax > logMin) {
return (Math.log10(Math.max(clamped, bottom)) - logMin) / (logMax - logMin);
}
// The floor already reaches the top of the domain, so there is no span to
// spread logarithmically. Fall through rather than flatten the whole grid.
}
const linear = (clamped - min) / (max - min);
return scale === HeatmapColorScale.Sqrt ? Math.sqrt(linear) : linear;
}
export function clampColorSteps(steps: number): number {
if (!Number.isFinite(steps)) {
return DEFAULT_COLOR_STEPS;
}
return Math.min(Math.max(Math.round(steps), MIN_COLOR_STEPS), MAX_COLOR_STEPS);
}
/** Colour at `t` (0..1) along a multi-stop ramp. */
function sampleStops(stops: string[], t: number): string {
if (stops.length === 0) {
return 'transparent';
}
if (stops.length === 1) {
return stops[0];
}
const scaled = Math.min(Math.max(t, 0), 1) * (stops.length - 1);
const lower = Math.min(Math.floor(scaled), stops.length - 2);
return Color(stops[lower])
.mix(Color(stops[lower + 1]), scaled - lower)
.hex();
}
/**
* Colour the densest cells are drawn with — the palette's extreme, or the opacity
* fill at full strength. Depends only on the options, not on the data, so callers
* can read it before a grid exists.
*/
export function resolveExtremeColor({
options,
isDarkMode,
seriesColor,
}: {
options: HeatmapColorOptions;
isDarkMode: boolean;
seriesColor: string;
}): string {
if (options.mode === HeatmapColorMode.Opacity) {
return options.fill || seriesColor || DEFAULT_OPACITY_FILL;
}
const stops = getPaletteStops(options.palette, isDarkMode);
return stops[stops.length - 1] ?? DEFAULT_OPACITY_FILL;
}
export interface HeatmapColorResolver {
/** `null` for a `null` count, which must be hatched. */
colorFor: (count: number | null) => string | null;
/** 0..1, or `null` for a `null` count. */
positionOf: (count: number | null) => number | null;
/** Low to high. The colour bar renders exactly these. */
ramp: string[];
domain: CountDomain;
}
/** Palette mode walks a sequential ramp; opacity mode varies the alpha of one
* fill, so the grid matches its group's legend swatch. */
export function createHeatmapColorResolver({
options,
domain,
isDarkMode,
seriesColor,
}: {
options: HeatmapColorOptions;
domain: CountDomain;
isDarkMode: boolean;
/** Opacity-mode fill when `options.fill` is empty. */
seriesColor: string;
}): HeatmapColorResolver {
const steps = clampColorSteps(options.steps);
const positions = Array.from({ length: steps }, (_, index) =>
steps === 1 ? 0 : index / (steps - 1),
);
let ramp: string[];
if (options.mode === HeatmapColorMode.Opacity) {
const base = Color(options.fill || seriesColor || DEFAULT_OPACITY_FILL);
ramp = positions.map((t) =>
base
.alpha(MIN_OPACITY_ALPHA + t * (1 - MIN_OPACITY_ALPHA))
.rgb()
.string(),
);
} else {
const stops = getPaletteStops(options.palette, isDarkMode);
ramp = positions.map((t) => sampleStops(stops, t));
}
const positionOf = (count: number | null): number | null => {
if (count === null || !Number.isFinite(count)) {
return null;
}
return normalizeCount({ count, domain, scale: options.scale });
};
return {
positionOf,
colorFor: (count): string | null => {
const t = positionOf(count);
if (t === null) {
return null;
}
const index = Math.min(Math.floor(t * steps), steps - 1);
return ramp[index];
},
ramp,
domain,
};
}

View File

@@ -0,0 +1,489 @@
import { HeatmapAxisScale, HeatmapRow, HeatmapYAxis } from './types';
/** Used when the ratio cannot be inferred, i.e. a single boundary. */
const FALLBACK_LOG_RATIO = 2;
const EMPTY_Y_AXIS: HeatmapYAxis = {
rows: [],
edges: [],
splits: [],
overflowSplit: null,
toBucketValue: (axisValue: number): number => axisValue,
min: 0,
max: 1,
};
/** Ascending, finite, de-duplicated boundaries. */
function normalizeBounds(bounds: number[]): number[] {
const sorted = bounds
.filter((bound) => Number.isFinite(bound))
.sort((a, b) => a - b);
return sorted.filter(
(bound, index) => index === 0 || bound !== sorted[index - 1],
);
}
/** True when a plain log axis can place every boundary. */
export function canUseLogAxis(bounds: number[]): boolean {
return bounds.length > 0 && bounds.every((bound) => bound > 0);
}
interface AxisTransform {
toAxisValue: (value: number) => number;
toBucketValue: (axisValue: number) => number;
}
const LINEAR_TRANSFORM: AxisTransform = {
toAxisValue: (value) => value,
toBucketValue: (axisValue) => axisValue,
};
const LOG_TRANSFORM: AxisTransform = {
toAxisValue: (value) => Math.log10(value),
toBucketValue: (axisValue) => 10 ** axisValue,
};
/**
* Where "near zero" starts, taken as the smallest non-zero boundary magnitude. The
* bucket layout already declares it, so it never needs to be configured.
*/
function resolveLinearThreshold(bounds: number[]): number {
let threshold = Number.POSITIVE_INFINITY;
for (const bound of bounds) {
const magnitude = Math.abs(bound);
if (magnitude > 0 && magnitude < threshold) {
threshold = magnitude;
}
}
return Number.isFinite(threshold) ? threshold : 1;
}
/**
* Symmetric log: linear within ±threshold, logarithmic beyond, mirrored across
* zero. Bucketing an arbitrary logs/traces field can straddle zero — clock skew,
* deltas, balances — which a plain log cannot place at all, and which a linear axis
* squeezes into sub-pixel rows exactly where the interesting data sits.
*
* The gradient kink at ±threshold is invisible here: the threshold *is* a boundary,
* so it lands on a row edge, and row edges are already discrete.
*/
function createSymlogTransform(threshold: number): AxisTransform {
return {
toAxisValue: (value) =>
Math.abs(value) <= threshold
? value / threshold
: Math.sign(value) * (1 + Math.log10(Math.abs(value) / threshold)),
toBucketValue: (axisValue) =>
Math.abs(axisValue) <= 1
? axisValue * threshold
: Math.sign(axisValue) * threshold * 10 ** (Math.abs(axisValue) - 1),
};
}
/** Symmetric log about the threshold the bucket layout implies. All-zero
* boundaries have no magnitude to scale against and stay linear. */
function resolveSymlogTransform(bounds: number[]): AxisTransform {
if (!bounds.some((bound) => bound !== 0)) {
return LINEAR_TRANSFORM;
}
return createSymlogTransform(resolveLinearThreshold(bounds));
}
/** One typical bucket, in axis space — the mean ratio between adjacent positive
* boundaries, which on a geometric layout is exactly one bucket. */
function resolveLogGap(positive: number[]): number {
const axisFirst = Math.log10(positive[0]);
const axisLast = Math.log10(positive[positive.length - 1]);
const gap =
positive.length > 1
? (axisLast - axisFirst) / (positive.length - 1)
: Math.log10(FALLBACK_LOG_RATIO);
return gap > 0 ? gap : Math.log10(FALLBACK_LOG_RATIO);
}
/**
* Plain log10, with the boundaries a logarithm has no answer for — zero and
* below — pinned one bucket beneath the smallest positive one. They keep their
* own rows, ticks and labels; only their height is synthetic, and it is the
* height of a bucket rather than the decade a symmetric log would spend on them.
*
* Several of them share that one edge, which squashes them together: a layout
* that straddles zero wants `Symlog`. This is the scale for the one non-positive
* boundary an explicit-bounds histogram routinely carries — its zero bucket.
*/
function createFloorLogTransform(positive: number[]): AxisTransform {
const floor = Math.log10(positive[0]) - resolveLogGap(positive);
return {
toAxisValue: (value) => (value > 0 ? Math.log10(value) : floor),
toBucketValue: (axisValue) => 10 ** axisValue,
};
}
function resolveAxisTransform(
bounds: number[],
scale: HeatmapAxisScale,
): AxisTransform {
if (scale === HeatmapAxisScale.Linear) {
return LINEAR_TRANSFORM;
}
if (scale === HeatmapAxisScale.Symlog) {
return resolveSymlogTransform(bounds);
}
if (canUseLogAxis(bounds)) {
return LOG_TRANSFORM;
}
// Only a negative boundary straddles zero; the lone zero an explicit-bounds
// histogram carries does not.
if (scale === HeatmapAxisScale.Auto && bounds.some((bound) => bound < 0)) {
return resolveSymlogTransform(bounds);
}
const positive = bounds.filter((bound) => bound > 0);
return positive.length > 0
? createFloorLogTransform(positive)
: LINEAR_TRANSFORM;
}
/**
* The open-ended rows still need a height, so each gets the grid's typical bucket
* width — the mean gap in axis space, which on a geometric layout is exactly one
* bucket ratio. Linear stays in value space so it can refuse to cross zero.
*/
function resolveOuterEdges(
bounds: number[],
transform: AxisTransform,
isLinear: boolean,
): { lower: number; upper: number } {
const first = bounds[0];
const last = bounds[bounds.length - 1];
if (isLinear) {
const gap = bounds.length > 1 ? (last - first) / (bounds.length - 1) : 0;
const safeGap = gap > 0 ? gap : Math.abs(first) || 1;
// Never extend below zero unless the boundaries already do.
const lower = first > 0 ? Math.max(0, first - safeGap) : first - safeGap;
return { lower, upper: last + safeGap };
}
const axisFirst = transform.toAxisValue(first);
const axisLast = transform.toAxisValue(last);
const fallback = Math.log10(FALLBACK_LOG_RATIO);
const gap =
bounds.length > 1 ? (axisLast - axisFirst) / (bounds.length - 1) : fallback;
const safeGap = gap > 0 ? gap : fallback;
return {
lower: transform.toBucketValue(axisFirst - safeGap),
upper: transform.toBucketValue(axisLast + safeGap),
};
}
/** N boundaries produce N+1 rows: an underflow row below the first, and the
* `+Inf` overflow row above the last. */
export function resolveHeatmapYAxis(
bounds: number[],
scale: HeatmapAxisScale,
): HeatmapYAxis {
const normalized = normalizeBounds(bounds);
if (normalized.length === 0) {
return EMPTY_Y_AXIS;
}
const transform = resolveAxisTransform(normalized, scale);
const isLinear = transform === LINEAR_TRANSFORM;
const { toAxisValue, toBucketValue } = transform;
const { lower, upper } = resolveOuterEdges(normalized, transform, isLinear);
const last = normalized[normalized.length - 1];
const rows: HeatmapRow[] = [
{ lower, upper: normalized[0], isUnderflow: true, isOverflow: false },
];
for (let index = 1; index < normalized.length; index += 1) {
rows.push({
lower: normalized[index - 1],
upper: normalized[index],
isUnderflow: false,
isOverflow: false,
});
}
rows.push({ lower: last, upper, isUnderflow: false, isOverflow: true });
const edges = [
toAxisValue(lower),
...normalized.map(toAxisValue),
toAxisValue(upper),
];
return {
rows,
edges,
splits: normalized.map(toAxisValue),
overflowSplit: toAxisValue(upper),
toBucketValue,
min: edges[0],
max: edges[edges.length - 1],
};
}
/** Row containing `axisValue`, or `null` when it falls outside the grid. */
export function resolveRowIndex(
edges: number[],
axisValue: number,
): number | null {
if (edges.length < 2) {
return null;
}
if (axisValue < edges[0] || axisValue > edges[edges.length - 1]) {
return null;
}
let low = 0;
let high = edges.length - 2;
while (low <= high) {
const mid = (low + high) >> 1;
if (axisValue < edges[mid]) {
high = mid - 1;
} else if (axisValue >= edges[mid + 1]) {
low = mid + 1;
} else {
return mid;
}
}
// Exactly on the top edge.
return edges.length - 2;
}
/**
* A containment test, not a nearest-timestamp lookup: uPlot's own `cursor.idx`
* snaps to the closest boundary and would report the next column as soon as the
* cursor passed a cell's midpoint.
*/
export function resolveColumnIndex(
timestamps: ArrayLike<number>,
xValue: number,
step: number,
): number | null {
if (timestamps.length === 0) {
return null;
}
let low = 0;
let high = timestamps.length - 1;
let candidate = -1;
while (low <= high) {
const mid = (low + high) >> 1;
if (timestamps[mid] <= xValue) {
candidate = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
if (candidate < 0) {
return null;
}
const width = step > 0 ? step : Number.POSITIVE_INFINITY;
return xValue < timestamps[candidate] + width ? candidate : null;
}
/** The open-ended rows are labelled by their one real boundary; the synthetic
* edge is a drawing device, not a value. */
export function formatRowLabel(
row: HeatmapRow,
formatValue: (value: number) => string,
): string {
if (row.isOverflow) {
return `> ${formatValue(row.lower)}`;
}
if (row.isUnderflow) {
return `${formatValue(row.upper)}`;
}
return `${formatValue(row.lower)} ${formatValue(row.upper)}`;
}
/**
* Drops boundary ticks that would overlap. Filters by pixel distance rather than
* index, since linear rows are not the same height, and walks down from the top
* so the `∞` edge survives whatever else is dropped.
*/
export function decimateAxisSplits({
splits,
min,
max,
plotHeight,
minGapPx,
}: {
/** Candidates in axis space, ascending. */
splits: number[];
min: number;
max: number;
/** Plotting area height, in CSS pixels. */
plotHeight: number;
minGapPx: number;
}): number[] {
if (splits.length < 2 || plotHeight <= 0 || minGapPx <= 0 || !(max > min)) {
return splits;
}
const pixelsPerUnit = plotHeight / (max - min);
const kept: number[] = [];
let lastPosition = 0;
for (let index = splits.length - 1; index >= 0; index -= 1) {
// Axis values grow upward, pixel offsets downward.
const position = (max - splits[index]) * pixelsPerUnit;
if (kept.length === 0 || position - lastPosition >= minGapPx) {
kept.push(splits[index]);
lastPosition = position;
}
}
return kept.reverse();
}
/** Where uPlot switches from a fixed increment to a calendar walk. */
const MONTH_INCR_SECONDS = 3600 * 24 * 28;
const YEAR_INCR_SECONDS = 3600 * 24 * 365;
/** Shifts a timestamp into the axis timezone, as uPlot's `tzDate` does: the
* returned date's *local* fields read as that timezone's wall clock. */
type ToAxisDate = (timestamp: number) => Date;
const BROWSER_DATE: ToAxisDate = (timestamp) => new Date(timestamp * 1e3);
/**
* Real epoch seconds of the midnight at or before `timestamp`, in the axis
* timezone. The browser's own offset cancels: it is inside the shifted date's
* fields and inside the correction.
*/
function resolveDayOrigin(timestamp: number, toDate: ToAxisDate): number {
const shifted = toDate(timestamp);
const midnight = new Date(
shifted.getFullYear(),
shifted.getMonth(),
shifted.getDate(),
);
const correction = Math.floor(timestamp) - Math.floor(shifted.getTime() / 1e3);
return Math.floor(midnight.getTime() / 1e3) + correction;
}
function fromAxisDate(wall: Date, toDate: ToAxisDate): number {
const wallTs = Math.floor(wall.getTime() / 1e3);
return wallTs + (wallTs - Math.floor(toDate(wallTs).getTime() / 1e3));
}
function snapToColumnEdge(value: number, phase: number, width: number): number {
return phase + Math.round((value - phase) / width) * width;
}
/**
* Month and year ticks, walked as calendar dates the way uPlot walks them — no
* fixed increment expresses a month. Their spacing is uneven to begin with, so
* each tick is snapped to its own nearest column edge.
*/
function resolveCalendarSplits({
incr,
min,
max,
toDate,
phase,
columnWidth,
}: {
incr: number;
min: number;
max: number;
toDate: ToAxisDate;
phase: number;
columnWidth: number;
}): number[] {
const isYear = incr >= YEAR_INCR_SECONDS;
const monthsPerTick = Math.max(
1,
isYear
? Math.round(incr / YEAR_INCR_SECONDS) * 12
: Math.round(incr / MONTH_INCR_SECONDS),
);
const start = toDate(min);
const baseYear = start.getFullYear();
const baseMonth = isYear ? 0 : start.getMonth();
const splits: number[] = [];
for (let index = 0; ; index += 1) {
const wall = new Date(baseYear, baseMonth + monthsPerTick * index, 1);
const value = snapToColumnEdge(
fromAxisDate(wall, toDate),
phase,
columnWidth,
);
if (value > max) {
break;
}
if (value >= min && value !== splits[splits.length - 1]) {
splits.push(value);
}
}
return splits;
}
/**
* Time ticks placed on column edges, so a vertical grid line falls in the gap
* between two cells instead of through one. uPlot's increment is rounded up to a
* whole number of columns, and the sequence starts at the column edge nearest
* the timezone's midnight — the closest the grid can get to the ticks uPlot
* would have drawn. Where midnight is itself an edge, they are those ticks.
*/
export function resolveColumnAlignedSplits({
anchor,
step,
incr,
min,
max,
toDate = BROWSER_DATE,
}: {
/** Any column start: every edge sits at `anchor + n * step`. */
anchor: number;
/** Column width in seconds. */
step: number;
/** Increment uPlot picked for the axis, in seconds. */
incr: number;
min: number;
max: number;
toDate?: ToAxisDate;
}): number[] {
if (!(incr > 0) || !(max > min)) {
return [];
}
const columnWidth = step > 0 ? step : incr;
const phase = step > 0 ? ((anchor % step) + step) % step : 0;
if (incr >= MONTH_INCR_SECONDS) {
return resolveCalendarSplits({
incr,
min,
max,
toDate,
phase,
columnWidth,
});
}
const tickIncr = Math.ceil(incr / columnWidth) * columnWidth;
const origin = snapToColumnEdge(
resolveDayOrigin(min, toDate),
phase,
columnWidth,
);
const splits: number[] = [];
for (let index = Math.ceil((min - origin) / tickIncr); ; index += 1) {
const value = origin + index * tickIncr;
if (value > max) {
break;
}
splits.push(value);
}
return splits;
}

View File

@@ -0,0 +1,77 @@
import { HeatmapGrid, HeatmapSeries } from './types';
const EMPTY_GRID: HeatmapGrid = {
bounds: [],
timestamps: [],
step: 0,
counts: [],
};
/** Groups the legend currently has enabled. `undefined` means all of them. */
function resolveVisible(
series: HeatmapSeries[],
visibleGroups: string[] | undefined,
): HeatmapSeries[] {
if (visibleGroups === undefined) {
return series;
}
const allowed = new Set(visibleGroups);
return series.filter((entry) => allowed.has(entry.label));
}
/**
* Pivots the response's column-major counts into the row-major grid the renderer
* draws, and sums the enabled groups — counts are additive, so the sum is exact and
* needs no extra request. A cell is `null` only when no group contributed to it.
*/
export function resolveHeatmapGrid({
buckets,
step,
series,
visibleGroups,
}: {
buckets: number[];
/** Column width in seconds. */
step: number;
series: HeatmapSeries[];
/** Labels the legend has enabled. `undefined` sums every group. */
visibleGroups?: string[];
}): HeatmapGrid {
if (buckets.length === 0 || series.length === 0) {
return EMPTY_GRID;
}
const selected = resolveVisible(series, visibleGroups);
// Groups are not guaranteed to share timestamps, so the columns are their union.
const timestampSet = new Set<number>();
selected.forEach((entry) => {
entry.points.forEach((point) => timestampSet.add(point.timestamp));
});
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
const columnOf = new Map(timestamps.map((value, index) => [value, index]));
// N boundaries describe N+1 rows: the underflow row and the `+Inf` overflow row.
const rowCount = buckets.length + 1;
const counts: Array<Array<number | null>> = Array.from(
{ length: rowCount },
() => new Array<number | null>(timestamps.length).fill(null),
);
selected.forEach((entry) => {
entry.points.forEach((point) => {
const column = columnOf.get(point.timestamp);
if (column === undefined) {
return;
}
point.counts.forEach((count, row) => {
if (row >= rowCount || count === null || count === undefined) {
return;
}
counts[row][column] = (counts[row][column] ?? 0) + count;
});
});
});
return { bounds: buckets, timestamps, step, counts };
}

View File

@@ -0,0 +1,178 @@
import uPlot from 'uplot';
import {
createHeatmapColorResolver,
HeatmapColorResolver,
resolveCountDomain,
} from './colorScale';
import { resolveColumnIndex, resolveRowIndex } from './geometry';
import {
createHoverOverlay,
HeatmapHoverOverlay,
showHoverOverlay,
} from './hoverOverlay';
import { createHatchPattern, drawCells, drawOverflowBoundary } from './paint';
import { HeatmapCell, HeatmapColorOptions, HeatmapYAxis } from './types';
export interface HeatmapRenderOptions {
yAxis: HeatmapYAxis;
/** Column width in seconds. */
step: number;
colors: HeatmapColorOptions;
isDarkMode: boolean;
/** Opacity-mode fill when `colors.fill` is empty. */
seriesColor: string;
/** Default true. */
dimOnHover?: boolean;
/** `null` when the cursor leaves. */
onHoverChange?: (cell: HeatmapCell | null) => void;
}
/**
* Registered through `UPlotConfigBuilder.addHook`, not as a `uPlot.Plugin`: uPlot
* appends plugin hooks *after* the hook arrays, and `setCursor` must run before
* TooltipPlugin's so the focused row is resolved when the tooltip positions
* itself. As a plugin it trails a frame and the tooltip flashes at the origin.
*/
export interface HeatmapHooks {
init: (u: uPlot) => void;
draw: (u: uPlot) => void;
setCursor: (u: uPlot) => void;
destroy: (u: uPlot) => void;
}
export function createHeatmapHooks({
yAxis,
step,
colors,
isDarkMode,
seriesColor,
dimOnHover = true,
onHoverChange,
}: HeatmapRenderOptions): HeatmapHooks {
let overlay: HeatmapHoverOverlay | null = null;
let hovered: HeatmapCell | null = null;
let hatchPattern: CanvasPattern | null = null;
// On auto, the domain comes from the data, but these hooks are captured once at
// config-build time. Resolving lazily keeps a refetch on uPlot's `setData` path
// rather than forcing a rebuild.
let cachedData: uPlot.AlignedData | null = null;
let cachedResolver: HeatmapColorResolver | null = null;
function getResolver(u: uPlot): HeatmapColorResolver {
if (cachedResolver && cachedData === u.data) {
return cachedResolver;
}
cachedResolver = createHeatmapColorResolver({
options: colors,
domain: resolveCountDomain(
colors,
u.data.slice(1) as Array<Array<number | null>>,
),
isDarkMode,
seriesColor,
});
cachedData = u.data;
return cachedResolver;
}
function clearHover(u: uPlot): void {
if (overlay) {
overlay.container.style.display = 'none';
}
if (hovered === null) {
return;
}
hovered = null;
u.setSeries(null, { focus: true });
onHoverChange?.(null);
}
return {
init: (u: uPlot): void => {
overlay = createHoverOverlay(isDarkMode);
u.over.appendChild(overlay.container);
},
draw: (u: uPlot): void => {
const timestamps = u.data[0] as ArrayLike<number> | undefined;
if (!timestamps?.length || yAxis.rows.length === 0) {
return;
}
const { ctx } = u;
hatchPattern ??= createHatchPattern(ctx, isDarkMode);
ctx.save();
ctx.beginPath();
ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
ctx.clip();
drawCells({ u, yAxis, step, resolver: getResolver(u), hatchPattern });
ctx.restore();
drawOverflowBoundary({ u, yAxis, isDarkMode });
},
setCursor: (u: uPlot): void => {
const { left = -10, top = -10 } = u.cursor;
if (left < 0 || top < 0) {
clearHover(u);
return;
}
const column = resolveColumnIndex(
u.data[0] as ArrayLike<number>,
u.posToVal(left, 'x'),
step,
);
const row = resolveRowIndex(yAxis.edges, u.posToVal(top, 'y'));
if (column === null || row === null) {
clearHover(u);
return;
}
const count =
(u.data[row + 1] as Array<number | null> | undefined)?.[column] ?? null;
// The count is part of the identity: a refetch swaps the data under a
// stationary cursor, and the cell it points at then means something else.
if (
hovered?.row === row &&
hovered?.column === column &&
hovered?.count === count
) {
return;
}
hovered = { row, column, count };
// Drives TooltipPlugin, which only shows a tooltip for a focused series.
// uPlot's own focus is disabled here: it picks the series nearest in value
// space, and a heatmap's value is a colour, not a y coordinate.
u.setSeries(row + 1, { focus: true });
if (overlay) {
showHoverOverlay({
overlay,
u,
yAxis,
step,
row,
column,
dim: dimOnHover,
});
}
onHoverChange?.(hovered);
},
destroy: (): void => {
overlay?.container.remove();
overlay = null;
hatchPattern = null;
cachedData = null;
cachedResolver = null;
// A rebuilt plot starts with no cursor, so a listener still holding this
// cell would keep drawing a hover that no longer exists.
if (hovered !== null) {
hovered = null;
onHoverChange?.(null);
}
},
};
}

View File

@@ -0,0 +1,122 @@
import { Color } from '@signozhq/design-tokens';
import uPlot from 'uplot';
import { HeatmapYAxis } from './types';
const HIGHLIGHT_BORDER_WIDTH = 1;
/** ~55% alpha. */
const DIM_ALPHA = '8C';
export interface HeatmapHoverOverlay {
container: HTMLDivElement;
highlight: HTMLDivElement;
/** Four corner rects whose complement is the hovered row/column cross. */
dims: HTMLDivElement[];
}
function createOverlayElement(): HTMLDivElement {
const element = document.createElement('div');
element.style.position = 'absolute';
element.style.pointerEvents = 'none';
return element;
}
function setRect(
element: HTMLDivElement,
left: number,
top: number,
width: number,
height: number,
): void {
element.style.left = `${left}px`;
element.style.top = `${top}px`;
element.style.width = `${Math.max(0, width)}px`;
element.style.height = `${Math.max(0, height)}px`;
}
/** Kept out of the canvas so moving between cells repositions a few nodes
* instead of repainting the grid. */
export function createHoverOverlay(isDarkMode: boolean): HeatmapHoverOverlay {
const container = createOverlayElement();
container.style.inset = '0';
container.style.display = 'none';
// The plot area's edge, as the canvas clip is to the cells: a cell at either end
// runs past the axis when its bucket or its time slice is only partly in view,
// and the highlight would otherwise be drawn over the axis and the panel.
container.style.overflow = 'hidden';
container.setAttribute('data-testid', 'heatmap-hover-overlay');
const dimColor = `${
isDarkMode ? Color.BG_INK_500 : Color.BG_VANILLA_100
}${DIM_ALPHA}`;
const dims = Array.from({ length: 4 }, () => {
const dim = createOverlayElement();
dim.style.background = dimColor;
container.appendChild(dim);
return dim;
});
const highlight = createOverlayElement();
highlight.style.border = `${HIGHLIGHT_BORDER_WIDTH}px solid ${
isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_300
}`;
highlight.style.boxSizing = 'border-box';
container.appendChild(highlight);
return { container, highlight, dims };
}
/** Positions the highlight, and the four corner rects so only the hovered row
* and column stay at full contrast. */
export function showHoverOverlay({
overlay,
u,
yAxis,
step,
row,
column,
dim,
}: {
overlay: HeatmapHoverOverlay;
u: uPlot;
yAxis: HeatmapYAxis;
step: number;
row: number;
column: number;
dim: boolean;
}): void {
const timestamps = u.data[0] as ArrayLike<number>;
const width = u.over.clientWidth;
const height = u.over.clientHeight;
const cellLeft = u.valToPos(timestamps[column], 'x');
const cellRight = u.valToPos(timestamps[column] + step, 'x');
const cellTop = u.valToPos(yAxis.edges[row + 1], 'y');
const cellBottom = u.valToPos(yAxis.edges[row], 'y');
setRect(
overlay.highlight,
cellLeft,
cellTop,
cellRight - cellLeft,
cellBottom - cellTop,
);
const [topLeft, topRight, bottomLeft, bottomRight] = overlay.dims;
if (dim) {
setRect(topLeft, 0, 0, cellLeft, cellTop);
setRect(topRight, cellRight, 0, width - cellRight, cellTop);
setRect(bottomLeft, 0, cellBottom, cellLeft, height - cellBottom);
setRect(
bottomRight,
cellRight,
cellBottom,
width - cellRight,
height - cellBottom,
);
} else {
overlay.dims.forEach((element) => setRect(element, 0, 0, 0, 0));
}
overlay.container.style.display = 'block';
}

View File

@@ -0,0 +1,128 @@
import { Color } from '@signozhq/design-tokens';
import uPlot from 'uplot';
import { HeatmapColorResolver } from './colorScale';
import { HeatmapYAxis } from './types';
/** Cells at least this wide/tall keep a hairline separator. */
const MIN_CELL_SIZE_FOR_GAP = 4;
const HATCH_TILE_SIZE = 6;
const OVERFLOW_DASH: [number, number] = [4, 3];
/** Hatch for `null` cells: a gap must never share the bottom-of-scale fill, or a
* scrape outage reads as a quiet period. */
export function createHatchPattern(
ctx: CanvasRenderingContext2D,
isDarkMode: boolean,
): CanvasPattern | null {
const pxRatio = uPlot.pxRatio;
const size = Math.max(2, Math.round(HATCH_TILE_SIZE * pxRatio));
const tile = document.createElement('canvas');
tile.width = size;
tile.height = size;
const tileCtx = tile.getContext('2d');
if (!tileCtx) {
return null;
}
tileCtx.strokeStyle = isDarkMode
? `${Color.BG_VANILLA_400}59`
: `${Color.BG_INK_300}40`;
tileCtx.lineWidth = Math.max(1, pxRatio);
tileCtx.beginPath();
// Three strokes keep the pattern continuous across tile seams.
tileCtx.moveTo(0, size);
tileCtx.lineTo(size, 0);
tileCtx.moveTo(-size / 2, size / 2);
tileCtx.lineTo(size / 2, -size / 2);
tileCtx.moveTo(size / 2, size * 1.5);
tileCtx.lineTo(size * 1.5, size / 2);
tileCtx.stroke();
return ctx.createPattern(tile, 'repeat');
}
/** One canvas pass. Offscreen columns are skipped rather than clipped. */
// eslint-disable-next-line sonarjs/cognitive-complexity
export function drawCells({
u,
yAxis,
step,
resolver,
hatchPattern,
}: {
u: uPlot;
yAxis: HeatmapYAxis;
step: number;
resolver: HeatmapColorResolver;
hatchPattern: CanvasPattern | null;
}): void {
const { ctx } = u;
const timestamps = u.data[0] as ArrayLike<number>;
const { rows, edges } = yAxis;
const pxRatio = uPlot.pxRatio;
const xMin = u.scales.x.min ?? timestamps[0];
const xMax = u.scales.x.max ?? timestamps[timestamps.length - 1] + step;
const rowEdgePositions = edges.map((edge) => u.valToPos(edge, 'y', true));
for (let column = 0; column < timestamps.length; column += 1) {
const columnStart = timestamps[column];
const columnEnd = columnStart + step;
if (columnEnd < xMin || columnStart > xMax) {
continue;
}
const left = u.valToPos(columnStart, 'x', true);
const rawWidth = u.valToPos(columnEnd, 'x', true) - left;
const gapX = rawWidth > MIN_CELL_SIZE_FOR_GAP * pxRatio ? pxRatio : 0;
const width = Math.max(1, rawWidth - gapX);
for (let row = 0; row < rows.length; row += 1) {
const top = rowEdgePositions[row + 1];
const rawHeight = rowEdgePositions[row] - top;
const gapY = rawHeight > MIN_CELL_SIZE_FOR_GAP * pxRatio ? pxRatio : 0;
const count = (u.data[row + 1] as Array<number | null> | undefined)?.[
column
];
const fill = resolver.colorFor(count ?? null);
if (fill === null && hatchPattern === null) {
continue;
}
ctx.fillStyle = fill ?? (hatchPattern as CanvasPattern);
ctx.fillRect(left, top, width, Math.max(1, rawHeight - gapY));
}
}
}
/** The `+Inf` row is unbounded, so its height is a drawing convenience and
* should not be compared with the real buckets. */
export function drawOverflowBoundary({
u,
yAxis,
isDarkMode,
}: {
u: uPlot;
yAxis: HeatmapYAxis;
isDarkMode: boolean;
}): void {
const overflowIndex = yAxis.rows.length - 1;
if (overflowIndex < 1 || !yAxis.rows[overflowIndex].isOverflow) {
return;
}
const { ctx } = u;
const y = Math.round(u.valToPos(yAxis.edges[overflowIndex], 'y', true));
ctx.save();
ctx.setLineDash(OVERFLOW_DASH);
ctx.lineWidth = Math.max(1, uPlot.pxRatio);
ctx.strokeStyle = isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_300;
ctx.beginPath();
ctx.moveTo(u.bbox.left, y);
ctx.lineTo(u.bbox.left + u.bbox.width, y);
ctx.stroke();
ctx.restore();
}

View File

@@ -0,0 +1,167 @@
import { HeatmapColorPalette } from './types';
interface PaletteDefinition {
/** Evenly spaced, one end of the ramp to the other. */
stops: string[];
/** `true` when `stops[0]` is the dark end. */
darkFirst: boolean;
}
/**
* Stop values come from the long-established public palette families —
* ColorBrewer for the hue ramps, matplotlib's perceptual set for the rest.
*/
const PALETTES: Record<HeatmapColorPalette, PaletteDefinition> = {
[HeatmapColorPalette.Ice]: {
darkFirst: false,
stops: [
'#f7fbff',
'#deebf7',
'#c3dbee',
'#9cc8e2',
'#6daed5',
'#4391c6',
'#2271b4',
'#0c5198',
'#08306b',
],
},
[HeatmapColorPalette.Moss]: {
darkFirst: false,
stops: [
'#f7fcf5',
'#e3f4de',
'#c6e8bf',
'#a0d89b',
'#73c378',
'#45aa5d',
'#228b45',
'#066b2d',
'#00441b',
],
},
[HeatmapColorPalette.Rust]: {
darkFirst: false,
stops: [
'#fff5f0',
'#feddcf',
'#fcbaa1',
'#fc9273',
'#f9694c',
'#eb3d2f',
'#cb1c1e',
'#a10e15',
'#67000d',
],
},
[HeatmapColorPalette.Graphite]: {
darkFirst: false,
stops: [
'#ffffff',
'#efefef',
'#d8d8d8',
'#bbbbbb',
'#979797',
'#737373',
'#505050',
'#262626',
'#000000',
],
},
[HeatmapColorPalette.Ember]: {
darkFirst: false,
stops: [
'#ffffcc',
'#ffeda0',
'#fed676',
'#feb250',
'#fd893c',
'#f8502b',
'#e11e20',
'#b90424',
'#800026',
],
},
[HeatmapColorPalette.Lagoon]: {
darkFirst: false,
stops: [
'#ffffd9',
'#eaf7b8',
'#c1e7b5',
'#81cebb',
'#45b4c2',
'#248fbd',
'#2260a9',
'#20378d',
'#081d58',
],
},
[HeatmapColorPalette.Orchid]: {
darkFirst: false,
stops: [
'#fff7f3',
'#fddfdc',
'#fcc3c3',
'#fa9cb4',
'#f369a3',
'#da3495',
'#ad0a81',
'#7b0176',
'#49006a',
],
},
[HeatmapColorPalette.Verdant]: {
darkFirst: true,
stops: [
'#440154',
'#472d7b',
'#3b528b',
'#2c728e',
'#21918c',
'#28ae80',
'#5ec962',
'#addc30',
'#fde725',
],
},
[HeatmapColorPalette.Lava]: {
darkFirst: true,
stops: [
'#000004',
'#1d1147',
'#51127c',
'#832681',
'#b73779',
'#e75263',
'#fc8961',
'#fec488',
'#fcfdbf',
],
},
[HeatmapColorPalette.Beacon]: {
darkFirst: true,
stops: [
'#002051',
'#11366c',
'#3c4d6e',
'#62646f',
'#7f7c75',
'#9a9478',
'#bbaf71',
'#e2cb5c',
'#fdea45',
],
},
};
/** Stops oriented low-count first for the active theme. At the wrong polarity,
* empty cells become the loudest thing on screen. */
export function getPaletteStops(
palette: HeatmapColorPalette,
isDarkMode: boolean,
): string[] {
const definition = PALETTES[palette] ?? PALETTES[HeatmapColorPalette.Ice];
return definition.darkFirst === isDarkMode
? definition.stops
: [...definition.stops].reverse();
}

View File

@@ -0,0 +1,122 @@
export enum HeatmapColorScale {
Log = 'log',
Sqrt = 'sqrt',
Linear = 'linear',
}
export enum HeatmapColorMode {
Palette = 'palette',
Opacity = 'opacity',
}
/** Sequential ramps only: colour means "count", so a midpoint or hue cycle would
* read as a threshold that does not exist. */
export enum HeatmapColorPalette {
Ice = 'ice',
Moss = 'moss',
Rust = 'rust',
Graphite = 'graphite',
Ember = 'ember',
Lagoon = 'lagoon',
Orchid = 'orchid',
Verdant = 'verdant',
Lava = 'lava',
Beacon = 'beacon',
}
export interface HeatmapColorOptions {
mode: HeatmapColorMode;
scale: HeatmapColorScale;
/** `null` derives it, which is always 0 — a count of 0 belongs at the bottom. */
minCount: number | null;
/** `null` derives it from the grid's highest count. */
maxCount: number | null;
palette: HeatmapColorPalette;
/** Colour steps the ramp is quantised into, 2..128. Unrelated to `step`, the
* column width in seconds. */
steps: number;
/** Opacity mode. Empty falls back to the caller's series colour. */
fill: string;
}
/** Row-height distribution of the bucket axis. */
export enum HeatmapAxisScale {
/** Whichever of the three below the boundaries admit: log when they are all
* positive or sit above a zero, symmetric log when they reach below it,
* linear when they are all zero. The choice is a property of the data, so
* this is the default. */
Auto = 'auto',
Linear = 'linear',
/** Plain log10. A boundary at or below zero has no logarithm, so it is pinned
* one bucket below the smallest positive one — see `resolveHeatmapYAxis`. */
Log = 'log',
/** Linear within ±the smallest non-zero boundary, logarithmic beyond,
* mirrored across zero. The scale for boundaries that straddle zero. */
Symlog = 'symlog',
}
export interface HeatmapSeriesPoint {
/** Column start, in seconds. */
timestamp: number;
/** One per bucket row, lowest first. `null` is "no data", never `0`. */
counts: Array<number | null>;
}
export interface HeatmapSeriesLabel {
key: string;
value: string;
}
export interface HeatmapSeries {
/** Group label, as the legend and the tooltip name it. Empty when there is no
* grouping. */
label: string;
points: HeatmapSeriesPoint[];
}
/** Counts pivoted into rows and aligned to one column axis. Internal to the
* chart, which resolves it from `buckets` and `series`. */
export interface HeatmapGrid {
/** Ascending. N boundaries describe N+1 rows, including the `+Inf` overflow. */
bounds: number[];
/** Column starts, in seconds. */
timestamps: number[];
/** Column width in seconds. Cells span `[timestamps[j], timestamps[j] + step)`,
* and the last column has no successor to infer it from. */
step: number;
/** `counts[row][column]`, row 0 lowest. `null` (no data) renders hatched, `0`
* at the bottom of the scale — conflating them hides an outage. */
counts: Array<Array<number | null>>;
}
export interface HeatmapRow {
/** Synthetic on the underflow row. */
lower: number;
/** Synthetic on the overflow row. */
upper: number;
isUnderflow: boolean;
isOverflow: boolean;
}
/** The bucket axis in uPlot y-scale space. A log axis is log10 values on a
* *linear* scale, not uPlot's log distribution, so boundaries stay exactly on
* ticks and uPlot's decade-only label filter cannot hide them. */
export interface HeatmapYAxis {
rows: HeatmapRow[];
/** Row edges, ascending. Length is `rows.length + 1`. */
edges: number[];
/** Real bucket boundaries — one tick each. */
splits: number[];
/** Where the `∞` tick goes: the overflow row's upper edge, not its centre,
* which would sit half a row from the last boundary and collide with it. */
overflowSplit: number | null;
toBucketValue: (axisValue: number) => number;
min: number;
max: number;
}
export interface HeatmapCell {
row: number;
column: number;
count: number | null;
}

View File

@@ -41,6 +41,9 @@ export default function ChartWrapper({
customTooltip,
pinnedTooltipElement,
tooltipPortalRoot,
customLegend,
legendLabels,
contentFooter,
'data-testid': testId,
}: ChartWrapperProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
@@ -61,6 +64,10 @@ export default function ChartWrapper({
if (!showLegend) {
return null;
}
// Charts whose legend does not list uPlot series supply their own.
if (customLegend) {
return customLegend(averageLegendWidth);
}
return (
<UPlotLegend
config={config}
@@ -69,7 +76,7 @@ export default function ChartWrapper({
/>
);
},
[config, legendConfig.position, showLegend],
[config, legendConfig.position, showLegend, customLegend],
);
const renderTooltipCallback = useCallback(
@@ -100,6 +107,8 @@ export default function ChartWrapper({
containerHeight={containerHeight}
legendConfig={legendConfig}
legendComponent={legendComponent}
seriesLabels={legendLabels}
contentFooter={contentFooter}
layoutChildren={layoutChildren}
>
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (

View File

@@ -0,0 +1,324 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
import ColorBar from 'lib/uPlotV2/components/ColorBar/ColorBar';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import HeatmapTooltip from 'lib/uPlotV2/components/Tooltip/components/HeatmapTooltip/HeatmapTooltip';
import {
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import {
createHeatmapColorResolver,
DEFAULT_HEATMAP_COLORS,
resolveCountDomain,
resolveExtremeColor,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import type { LegendItem } from 'lib/uPlotV2/config/types';
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { resolveHeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
import {
HeatmapAxisScale,
HeatmapCell,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { HeatmapChartProps } from 'lib/visualization/charts/types';
import { useHeatmapGroupLegend } from './useHeatmapGroupLegend';
import {
buildHeatmapConfig,
prepareHeatmapChartData,
resolveBoundaryPrecision,
} from './utils';
/** Vertical space the colour bar takes out of the container. */
const COLOR_BAR_HEIGHT = 28;
/** Row, column and count together: a refetch changes what the cell under a
* stationary cursor means, while the row and column stay put. */
function isSameCell(a: HeatmapCell | null, b: HeatmapCell | null): boolean {
if (a === null || b === null) {
return a === b;
}
return a.row === b.row && a.column === b.column && a.count === b.count;
}
/**
* Columns are time slices, rows are bucket ranges, cell colour is the observation
* count — so a distribution can be watched changing shape instead of collapsing to
* percentile lines. Drawn on canvas (see `createHeatmapHooks`): a 40 × 240 grid is
* ~9,600 cells, far past what per-cell DOM carries.
*/
export default function Heatmap(props: HeatmapChartProps): JSX.Element {
const {
id,
buckets,
step,
series,
width,
height,
isDarkMode,
axisScale = HeatmapAxisScale.Auto,
yAxisUnit,
decimalPrecision,
timezone,
showVisualMap = true,
showLegend = true,
legendPosition = LegendPosition.BOTTOM,
dimOnHover = true,
showTooltip = true,
canPinTooltip = false,
pinKey,
seriesColor,
minTimeScale,
maxTimeScale,
onDragSelect,
onCellClick,
renderTooltipFooter,
tooltipPortalRoot,
layoutChildren,
'data-testid': testId,
} = props;
const [hoveredCell, setHoveredCell] = useState<HeatmapCell | null>(null);
const hoveredCellRef = useRef<HeatmapCell | null>(null);
const hoverFrameRef = useRef<number | null>(null);
const onCellClickRef = useRef(onCellClick);
onCellClickRef.current = onCellClick;
const groups = useMemo(() => series.map((entry) => entry.label), [series]);
const colors = useMemo(
() => ({ ...DEFAULT_HEATMAP_COLORS, ...props.colors }),
[props.colors],
);
// The opacity fill no longer follows a group colour: with several groups enabled
// at once there is no single one to follow.
const resolvedSeriesColor = seriesColor ?? DEFAULT_HEATMAP_COLORS.fill;
// Opacity mode keeps the solid fill; a partially transparent marker is hard to
// read against the panel.
const extremeColor = resolveExtremeColor({
options: colors,
isDarkMode,
seriesColor: resolvedSeriesColor,
});
const { visibleGroups, focusedSeriesIndex, onLegendAction } =
useHeatmapGroupLegend({ groups });
const grid = useMemo(
() => resolveHeatmapGrid({ buckets, step, series, visibleGroups }),
[buckets, step, series, visibleGroups],
);
const yAxis = useMemo(
() => resolveHeatmapYAxis(grid.bounds, axisScale),
[grid.bounds, axisScale],
);
// The axis, the series labels and the tooltip all name rows by their boundaries,
// so they share one precision.
const boundaryPrecision = useMemo(
() => resolveBoundaryPrecision({ yAxis, yAxisUnit, decimalPrecision }),
[yAxis, yAxisUnit, decimalPrecision],
);
const hasGrid = yAxis.rows.length > 0 && grid.timestamps.length > 0;
const data = useMemo(
() =>
hasGrid
? prepareHeatmapChartData(grid, yAxis.rows.length)
: ([[]] as unknown as ReturnType<typeof prepareHeatmapChartData>),
[grid, yAxis.rows.length, hasGrid],
);
const colorResolver = useMemo(
() =>
createHeatmapColorResolver({
options: colors,
domain: resolveCountDomain(colors, grid.counts),
isDarkMode,
seriesColor: resolvedSeriesColor,
}),
[colors, grid.counts, isDarkMode, resolvedSeriesColor],
);
// Stable: the renderer captures it at config-build time, so a new identity would
// recreate the plot on every hover.
//
// The plot reports the cell from inside its own render path, which React can be
// driving — a resize or a data swap runs the plot's hooks during a commit, and a
// rebuilt plot re-reports the cell the cursor is still sitting on. Committing to
// state there nests an update inside the commit that caused it, so the two feed
// each other until React gives up at its depth limit. The frame takes the update
// out of that chain; the equality check drops a report that carries nothing new.
const handleHoverChange = useCallback((cell: HeatmapCell | null): void => {
hoveredCellRef.current = cell;
if (hoverFrameRef.current !== null) {
return;
}
hoverFrameRef.current = requestAnimationFrame(() => {
hoverFrameRef.current = null;
setHoveredCell((previous) =>
isSameCell(previous, hoveredCellRef.current)
? previous
: hoveredCellRef.current,
);
});
}, []);
useEffect(
() => (): void => {
if (hoverFrameRef.current !== null) {
cancelAnimationFrame(hoverFrameRef.current);
}
},
[],
);
const config = useMemo(
() =>
buildHeatmapConfig({
id,
grid,
yAxis,
colors,
isDarkMode,
seriesColor: resolvedSeriesColor,
dimOnHover,
onHoverChange: handleHoverChange,
yAxisUnit,
decimalPrecision: boundaryPrecision,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
id,
grid,
yAxis,
colors,
isDarkMode,
resolvedSeriesColor,
dimOnHover,
handleHoverChange,
yAxisUnit,
boundaryPrecision,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
],
);
const legendItems = useMemo<LegendItem[]>(
() =>
groups.map((group, index) => ({
// +1 mirrors uPlot's 1-based data series, so the shared legend's index
// handling is identical across charts.
seriesIndex: index + 1,
label: group,
// Colour means count here, so a marker names its group rather than keying
// a colour — every one of them takes the top of the ramp.
color: extremeColor,
show: visibleGroups.includes(group),
})),
[groups, visibleGroups, extremeColor],
);
const renderTooltip = useCallback(
(args: TooltipRenderArgs): React.ReactNode => (
<HeatmapTooltip
{...args}
id={id}
yAxis={yAxis}
step={grid.step}
series={series}
visibleGroups={visibleGroups}
groupColor={extremeColor}
yAxisUnit={yAxisUnit}
decimalPrecision={boundaryPrecision}
timezone={timezone}
canPinTooltip={canPinTooltip}
renderTooltipFooter={renderTooltipFooter}
/>
),
[
id,
yAxis,
grid.step,
series,
visibleGroups,
extremeColor,
yAxisUnit,
boundaryPrecision,
timezone,
canPinTooltip,
renderTooltipFooter,
],
);
const handleClick = useCallback((clickData: ChartClickData): void => {
if (hoveredCellRef.current) {
onCellClickRef.current?.(hoveredCellRef.current, clickData);
}
}, []);
const groupLegend = useCallback(
(averageLegendWidth: number): React.ReactNode => (
<Legend
items={legendItems}
position={legendPosition}
averageLegendWidth={averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onAction={onLegendAction}
/>
),
[legendItems, legendPosition, focusedSeriesIndex, onLegendAction],
);
const visualMap = useMemo(() => {
if (!showVisualMap || !hasGrid) {
return null;
}
return (
<ColorBar
label="count"
ramp={colorResolver.ramp}
minLabel={colorResolver.domain.min.toLocaleString()}
maxLabel={colorResolver.domain.max.toLocaleString()}
markerPosition={colorResolver.positionOf(hoveredCell?.count ?? null)}
/>
);
}, [showVisualMap, hasGrid, colorResolver, hoveredCell]);
return (
<ChartWrapper
config={config}
data={data}
width={width}
height={
showVisualMap && hasGrid ? Math.max(0, height - COLOR_BAR_HEIGHT) : height
}
legendConfig={{ position: legendPosition }}
showLegend={showLegend}
customLegend={groupLegend}
legendLabels={groups}
showTooltip={showTooltip}
canPinTooltip={canPinTooltip}
pinKey={pinKey}
onClick={onCellClick ? handleClick : undefined}
yAxisUnit={yAxisUnit}
decimalPrecision={decimalPrecision}
customTooltip={renderTooltip}
renderTooltipFooter={renderTooltipFooter}
tooltipPortalRoot={tooltipPortalRoot}
contentFooter={visualMap}
layoutChildren={layoutChildren}
data-testid={testId}
/>
);
}

View File

@@ -0,0 +1,231 @@
import type React from 'react';
import userEvent from '@testing-library/user-event';
import type { LegendItem } from 'lib/uPlotV2/config/types';
import { render, screen } from 'tests/test-utils';
import {
DEFAULT_HEATMAP_COLORS,
resolveExtremeColor,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import {
HeatmapColorMode,
HeatmapSeries,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import Heatmap from '../Heatmap';
// The shared Legend virtualises its items; render them all so they are queryable.
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
}): JSX.Element => (
<div>
{data.map((item, index) => (
<div key={item.seriesIndex}>{itemContent(index, item)}</div>
))}
</div>
),
}));
const BUCKETS = [128, 256, 1024];
const STEP = 60;
/** Two groups whose counts sum to a peak of 1,204 in the combined view. */
const SERIES: HeatmapSeries[] = [
{
label: 'service.name=cart',
points: [
{ timestamp: 1000, counts: [1, 4, 7, 10] },
{ timestamp: 1060, counts: [2, null, 8, 11] },
{ timestamp: 1120, counts: [3, 6, 9, 1200] },
],
},
{
label: 'service.name=checkout',
points: [{ timestamp: 1120, counts: [0, 0, 0, 4] }],
},
];
function renderHeatmap(
props: Partial<React.ComponentProps<typeof Heatmap>> = {},
): ReturnType<typeof render> {
return render(
<Heatmap
id="panel-1"
buckets={BUCKETS}
step={STEP}
series={SERIES}
width={800}
height={400}
isDarkMode
data-testid="heatmap"
{...props}
/>,
);
}
describe('Heatmap', () => {
it('renders the plot container', () => {
renderHeatmap();
expect(screen.getByTestId('heatmap')).toBeInTheDocument();
});
it('shows the colour bar with the resolved count domain', () => {
renderHeatmap();
expect(screen.getByTestId('color-bar')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('1,204')).toBeInTheDocument();
});
it('puts the colour bar against the plot, with the legend after it', () => {
renderHeatmap();
const bar = screen.getByTestId('color-bar');
const legend = screen
.getByText('service.name=cart')
.closest('[data-legend-item-id]');
// The bar is the scale key for the grid, so it reads before the controls.
expect(
bar.compareDocumentPosition(legend as Node) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it('keeps the colour bar inside the chart column, not below the legend', () => {
renderHeatmap();
expect(
screen.getByTestId('color-bar').closest('.chart-layout__content'),
).not.toBeNull();
});
it('hides the colour bar when the visual map is off', () => {
renderHeatmap({ showVisualMap: false });
expect(screen.queryByTestId('color-bar')).not.toBeInTheDocument();
});
it('labels the colour bar with an explicit clamp instead of the data range', () => {
renderHeatmap({ colors: { minCount: 5, maxCount: 500 } });
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('500')).toBeInTheDocument();
});
it('falls back to the no-data state when the metric has no buckets', () => {
renderHeatmap({ buckets: [] });
expect(screen.getByText('No Data')).toBeInTheDocument();
expect(screen.queryByTestId('color-bar')).not.toBeInTheDocument();
});
it('falls back to the no-data state when no columns came back', () => {
renderHeatmap({ series: [] });
expect(screen.getByText('No Data')).toBeInTheDocument();
});
});
describe('Heatmap group legend', () => {
const CART = 'service.name=cart';
const CHECKOUT = 'service.name=checkout';
function legendItem(label: string): HTMLElement {
return screen.getByRole('switch', { name: label });
}
function marker(label: string): HTMLElement {
const element = legendItem(label).querySelector<HTMLElement>(
'[data-is-legend-marker]',
);
if (!element) {
throw new Error(`no marker for ${label}`);
}
return element;
}
it('lists the groups, with no combined-view entry', () => {
renderHeatmap();
expect(screen.getByText(CART)).toBeInTheDocument();
expect(screen.getByText(CHECKOUT)).toBeInTheDocument();
expect(screen.queryByText(/all groups/i)).not.toBeInTheDocument();
});
it('enables every group to begin with', () => {
renderHeatmap();
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'true');
});
it('isolates a group when its label is clicked', async () => {
renderHeatmap();
await userEvent.click(screen.getByText(CART));
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
});
it('restores every group when the isolated label is clicked again', async () => {
renderHeatmap();
await userEvent.click(screen.getByText(CART));
await userEvent.click(screen.getByText(CART));
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'true');
});
it('excludes just one group when its marker is clicked', async () => {
renderHeatmap();
await userEvent.click(marker(CHECKOUT));
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
});
it('gives every marker the top of the palette, whatever the group"s counts', () => {
renderHeatmap();
// The DOM lowercases hex; the palette is built uppercase.
const extreme = resolveExtremeColor({
options: DEFAULT_HEATMAP_COLORS,
isDarkMode: true,
seriesColor: DEFAULT_HEATMAP_COLORS.fill,
}).toLowerCase();
// cart peaks at 1200 and checkout at 4, which the marker does not report.
expect(marker(CART).style.borderColor.toLowerCase()).toBe(extreme);
expect(marker(CHECKOUT).style.borderColor.toLowerCase()).toBe(extreme);
});
it('gives every marker the solid fill in opacity mode', () => {
renderHeatmap({
colors: { mode: HeatmapColorMode.Opacity, fill: '#e5484d' },
});
// A partially transparent marker is hard to read against the panel.
expect(marker(CART).style.borderColor).toBe(
marker(CHECKOUT).style.borderColor,
);
expect(marker(CART).style.borderColor).not.toBe('');
});
it('shows the legend for a single group, which names what the grid plots', () => {
renderHeatmap({ series: [SERIES[0]] });
expect(screen.getByText(CART)).toBeInTheDocument();
});
it('hides the legend when asked', () => {
renderHeatmap({ showLegend: false });
expect(screen.queryByText(CART)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,184 @@
import { act, renderHook } from '@testing-library/react';
import { LegendAction } from 'lib/uPlotV2/components/types';
import { useHeatmapGroupLegend } from '../useHeatmapGroupLegend';
const GROUPS = ['cart', 'checkout', 'payments'];
function render(
groups: string[] = GROUPS,
): ReturnType<
typeof renderHook<ReturnType<typeof useHeatmapGroupLegend>, unknown>
> {
return renderHook(() => useHeatmapGroupLegend({ groups }));
}
describe('useHeatmapGroupLegend', () => {
it('enables every group to begin with', () => {
const { result } = render();
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
});
it('isolates a group when the legend asks to show only it', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 2,
}),
);
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
});
it('restores every group when the legend asks to show all', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 2,
}),
);
act(() => result.current.onLegendAction({ type: LegendAction.SHOW_ALL }));
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
});
it('moves the isolation to the group named last', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 1,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 3,
}),
);
expect(result.current.visibleGroups).toStrictEqual(['payments']);
});
it('excludes just one group when it is toggled off', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
expect(result.current.visibleGroups).toStrictEqual(['cart', 'payments']);
});
it('re-includes a group when it is toggled again', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
});
it('excludes more than one group', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 3,
}),
);
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
});
it('allows every group to be excluded, as the other legends do', () => {
const { result } = render();
GROUPS.forEach((_, index) =>
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: index + 1,
}),
),
);
expect(result.current.visibleGroups).toStrictEqual([]);
});
it('ignores an action naming an entry that is not there', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 99,
}),
);
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
});
it('forgets a hidden group that left the result', () => {
const { result, rerender } = renderHook(
({ groups }) => useHeatmapGroupLegend({ groups }),
{ initialProps: { groups: GROUPS } },
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 3,
}),
);
rerender({ groups: ['cart', 'checkout'] });
expect(result.current.visibleGroups).toStrictEqual(['cart', 'checkout']);
});
it('tracks the hovered entry for the legend"s focus highlight', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.HOVER,
seriesIndex: 2,
}),
);
expect(result.current.focusedSeriesIndex).toBe(2);
act(() =>
result.current.onLegendAction({
type: LegendAction.HOVER,
seriesIndex: null,
}),
);
expect(result.current.focusedSeriesIndex).toBeNull();
});
});

View File

@@ -0,0 +1,262 @@
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { DEFAULT_HEATMAP_COLORS } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import {
HeatmapAxisScale,
HeatmapGrid,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import type uPlot from 'uplot';
import { PrecisionOptionsEnum } from 'components/Graph/types';
import {
buildHeatmapConfig,
prepareHeatmapChartData,
resolveBoundaryPrecision,
} from '../utils';
const GRID: HeatmapGrid = {
bounds: [128, 256, 1024],
timestamps: [1000, 1060, 1120],
step: 60,
counts: [
[1, 2, 3],
[4, null, 6],
[7, 8, 9],
[0, 0, 0],
],
};
const Y_AXIS = resolveHeatmapYAxis(GRID.bounds, HeatmapAxisScale.Log);
/** Tall enough that no tick needs thinning. */
const TALL_PLOT = { bbox: { height: 1000 } } as uPlot;
function readSplits(
config: ReturnType<typeof buildHeatmapConfig>,
plot: uPlot,
): number[] {
const [, yAxisConfig] = config.getConfig().axes ?? [];
return (yAxisConfig.splits as (self: uPlot) => number[])(plot);
}
function readLabels(
config: ReturnType<typeof buildHeatmapConfig>,
splits: number[],
): string[] {
const [, yAxisConfig] = config.getConfig().axes ?? [];
return (yAxisConfig.values as (u: uPlot, splits: number[]) => string[])(
{} as uPlot,
splits,
);
}
function readRange(scale?: uPlot.Scale): [number, number] {
const range = scale?.range as (
u: uPlot,
min: number,
max: number,
) => [number, number];
return range({} as uPlot, 0, 0);
}
function buildConfig(
overrides: Partial<Parameters<typeof buildHeatmapConfig>[0]> = {},
): ReturnType<typeof buildHeatmapConfig> {
return buildHeatmapConfig({
id: 'panel-1',
grid: GRID,
yAxis: Y_AXIS,
colors: DEFAULT_HEATMAP_COLORS,
isDarkMode: true,
seriesColor: '#4e74f8',
...overrides,
});
}
/** Two decimals round every one of these to `0.06 ms` or `0.07 ms`. */
const CLOSE_BOUNDS = [
0.05731275270029195, 0.059850205043660856, 0.0625, 0.06526711140171336,
0.0681567332915786,
];
describe('resolveBoundaryPrecision', () => {
it('raises the precision until every boundary prints differently', () => {
expect(
resolveBoundaryPrecision({
yAxis: resolveHeatmapYAxis(CLOSE_BOUNDS, HeatmapAxisScale.Log),
yAxisUnit: 'ms',
decimalPrecision: 2,
}),
).toBe(3);
});
it('leaves the panel"s precision alone where the boundaries already separate', () => {
expect(
resolveBoundaryPrecision({
yAxis: Y_AXIS,
yAxisUnit: 'ms',
decimalPrecision: 2,
}),
).toBe(2);
});
it('never drops below the panel"s precision', () => {
expect(
resolveBoundaryPrecision({
yAxis: Y_AXIS,
yAxisUnit: 'ms',
decimalPrecision: 4,
}),
).toBe(4);
});
it('stops at the most precise the panel can express', () => {
expect(
resolveBoundaryPrecision({
yAxis: resolveHeatmapYAxis(
[0.062501, 0.062502, 0.062503],
HeatmapAxisScale.Log,
),
yAxisUnit: 'ms',
decimalPrecision: 2,
}),
).toBe(4);
});
it('leaves full precision as it is, having nothing above it to reach for', () => {
expect(
resolveBoundaryPrecision({
yAxis: resolveHeatmapYAxis(CLOSE_BOUNDS, HeatmapAxisScale.Log),
yAxisUnit: 'ms',
decimalPrecision: PrecisionOptionsEnum.FULL,
}),
).toBe(PrecisionOptionsEnum.FULL);
});
});
describe('prepareHeatmapChartData', () => {
it('puts timestamps first and one series per bucket row', () => {
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
expect(data).toHaveLength(Y_AXIS.rows.length + 1);
expect(data[0]).toStrictEqual(GRID.timestamps);
expect(data[1]).toStrictEqual([1, 2, 3]);
});
it('preserves null cells rather than zeroing them', () => {
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
expect(data[2]).toStrictEqual([4, null, 6]);
});
it('pads short rows so every uPlot data array is the same length', () => {
const data = prepareHeatmapChartData(
{ ...GRID, counts: [[1]] },
Y_AXIS.rows.length,
);
expect(data[1]).toStrictEqual([1, null, null]);
});
it('pads missing rows up to the resolved row count', () => {
const data = prepareHeatmapChartData({ ...GRID, counts: [] }, 2);
expect(data).toHaveLength(3);
expect(data[2]).toStrictEqual([null, null, null]);
});
});
describe('buildHeatmapConfig', () => {
it('registers one series per bucket row, plus uPlot"s timestamp series', () => {
const config = buildHeatmapConfig({
id: 'panel-1',
grid: GRID,
yAxis: Y_AXIS,
colors: DEFAULT_HEATMAP_COLORS,
isDarkMode: true,
seriesColor: '#4e74f8',
}).getConfig();
expect(config.series).toHaveLength(Y_AXIS.rows.length + 1);
});
it('draws no paths or points per series — the renderer paints the cells', () => {
const [, firstRow] = buildConfig().getConfig().series ?? [];
expect((firstRow as uPlot.Series).paths?.({} as uPlot, 1, 0, 1)).toBeNull();
expect((firstRow as uPlot.Series).points?.show).toBe(false);
});
it('labels series by bucket range, including the open-ended rows', () => {
const labels = (buildConfig().getConfig().series ?? [])
.slice(1)
.map((series) => series.label);
expect(labels[0]).toContain('≤');
expect(labels[labels.length - 1]).toContain('>');
});
it('spans the x scale to the end of the last column, not its start', () => {
const { x } = buildConfig().getConfig().scales ?? {};
expect(readRange(x)).toStrictEqual([1000, 1180]);
});
it('prefers the query window over the grid extent', () => {
const { x } =
buildConfig({ minTimeScale: 900, maxTimeScale: 1500 }).getConfig().scales ??
{};
expect(readRange(x)).toStrictEqual([900, 1500]);
});
it('pins the y scale to the bucket axis instead of auto-ranging on counts', () => {
const { y } = buildConfig().getConfig().scales ?? {};
expect(y?.auto).toBe(false);
expect(readRange(y)).toStrictEqual([Y_AXIS.min, Y_AXIS.max]);
});
it('puts a y tick on every bucket boundary plus the overflow row"s upper edge', () => {
const splits = readSplits(buildConfig(), TALL_PLOT);
expect(splits).toStrictEqual([...Y_AXIS.splits, Y_AXIS.overflowSplit]);
});
it('labels the overflow edge as infinite and the rest by bucket value', () => {
const config = buildConfig();
const labels = readLabels(config, readSplits(config, TALL_PLOT));
expect(labels[0]).toBe('128');
expect(labels[labels.length - 1]).toBe('∞');
});
it('thins the tick set when the panel is too short to label every boundary', () => {
const config = buildConfig();
const splits = readSplits(config, { bbox: { height: 40 } } as uPlot);
expect(splits.length).toBeLessThan(Y_AXIS.splits.length + 1);
// The infinite edge is the one label that must never be dropped.
expect(readLabels(config, splits).at(-1)).toBe('∞');
});
it('disables uPlot cursor focus and points, which cannot read a colour axis', () => {
const config = buildConfig().getConfig();
expect(config.cursor?.focus?.prox).toBe(-1);
expect(config.cursor?.points?.show).toBe(false);
});
it('keeps focus alpha at 1 so focusing a row does not force a full redraw', () => {
expect(buildConfig().getConfig().focus?.alpha).toBe(1);
});
it('registers the renderer hooks', () => {
const { hooks } = buildConfig().getConfig();
expect(hooks?.init).toHaveLength(1);
expect(hooks?.draw).toHaveLength(1);
expect(hooks?.setCursor).toHaveLength(1);
expect(hooks?.destroy).toHaveLength(1);
});
});

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