Compare commits

..

58 Commits

Author SHA1 Message Date
Abhi Kumar
4cf28f572b refactor(dashboard): rename scatter click helper and type legend series mocks 2026-10-22 13:49:38 +05:30
Abhi Kumar
db3fc8bcc3 chore: pr review changes 2026-10-22 01:49:17 +05:30
Abhi Kumar
04810eb479 fix(dashboard): drop PromQL from scatter plot query types 2026-10-22 01:30:06 +05:30
Abhi Kumar
a5a8710de5 fix(dashboard): declare scatter plot query modes 2026-10-22 01:30:06 +05:30
Abhi Kumar
859e61615a refactor(dashboard): trim scatter plot panel comments and share the joined table helper 2026-10-22 01:30:06 +05:30
Abhi Kumar
10eace166f fix(dashboard): warn of the scatter plot cap only when groups were capped 2026-10-22 01:30:06 +05:30
Abhi Kumar
afec3394fa feat(dashboard): let scatter plot thresholds mark the x or y axis 2026-10-22 01:30:06 +05:30
Abhi Kumar
d0d080e256 feat(dashboard): use colorBy and sizeBy in the scatter plot, labelled Color by and Size by 2026-10-22 01:29:32 +05:30
Abhi Kumar
bb78d9986d feat(dashboard): explain mismatched group bys in the scatter plot 2026-10-22 01:29:32 +05:30
Abhi Kumar
ac96f5a44b refactor(dashboard): move scatter plot panel helpers into utils 2026-10-22 01:29:32 +05:30
Abhi Kumar
9823e5f86f style(charts): match scatter tooltip spacing and marker to the heatmap tooltip and legend 2026-10-22 01:29:32 +05:30
Abhi Kumar
eb905f7b8b fix(charts): drop the scatter tooltip size row when it repeats x or y 2026-10-22 01:29:22 +05:30
Abhi Kumar
6d3d1a2ced feat(dashboard): sync scatter plot group by across queries
Panel kinds declare which builder fields to keep equal across queries. A query
added or switched to another signal takes the others' values, since a signal
switch reloads its own.
2026-10-22 01:29:22 +05:30
Abhi Kumar
702c39873a feat(dashboard): warn when a scatter plot dimension isn't in the results 2026-10-22 01:29:22 +05:30
Abhi Kumar
a9e67a1839 feat(query-builder): show the scatter plot's default limit as the limit placeholder 2026-10-22 01:29:22 +05:30
Abhi Kumar
66dbc69d12 feat(dashboard): move scatter plot notes from the footer into the panel warning 2026-10-22 01:29:22 +05:30
Abhi Kumar
35f4f228fb fix(dashboard): limit scatter plot queries to 10,000 groups on the server
Only a lone builder query gets the default: per-query limits on several
queries can keep different groups and break the join.
2026-10-22 01:29:22 +05:30
Abhi Kumar
bf4eaf6b9c fix(dashboard): cap the scatter plot at 10,000 dots
A canvas path of a few hundred thousand dots fails to paint and takes the later series with it.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
ddb9a1a16c fix(dashboard): redraw the scatter plot after an empty state
The chart area stays mounted, since the size observer binds to its node only once.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
d5ffbc25b7 refactor(dashboard): build scatter plot axes from the shared axis fields
Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
46726465bf feat(dashboard): lay out scatter plot axes with label, range and scale rows
Range help says when a log axis ignores a bound at or below zero.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
b6c970b645 feat(dashboard): restyle scatter plot dimensions with field info and auto labels
Colour is a multi-select of group-by keys; Auto options name the column they resolve to.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
ee7b60bf69 feat(dashboard): hide legend format for scatter plot
It only renames a value column here; axis labels name what each axis measures.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
0075ef5d66 fix(dashboard): send reduceTo with scatter plot metric queries
The V5 conversion kept reduceTo only for table, pie and value panels, so the
API rejected a scatter plot's scalar metric query.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
8db3efc257 feat(query-builder): treat scatter plot like table in the builder
Reduce to for metrics, and no step interval for traces or logs, since both
send the same scalar request.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
521fdedccd feat(dashboard): drill down and download CSV from a scatter plot
A dot drills into its group through Y's query, or X's when Y has none. A
breakout opens as a table, since regrouping one query would break the join.
CSV keeps both of two columns that share a name.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
6473cd7eae fix(dashboard): carry builder fields on a switch to scatter plot
Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
24098249ab fix(dashboard): keep column units across a Table/Scatter Plot switch
Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
982b96b922 feat(dashboard): edit scatter plot point size and opacity
A single size slider until a size column is bound, then a min/max range.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
e6d0260987 feat(dashboard): add scatter plot panel
One dot per group from the joined scalar table. Dimensions bind to Table's
column keys so column units carry across a Table/Scatter switch.

Assisted-by: Claude Opus 5.5
2026-10-22 01:29:22 +05:30
Abhi Kumar
51289501d2 chore: pr review changes 2026-10-22 01:28:03 +05:30
Abhi Kumar
c92b7e7048 style(charts): format scatter chart data with oxfmt 0.73 2026-10-22 00:51:44 +05:30
Abhi Kumar
b65be666a9 refactor(charts): trim scatter chart layer comments and dead code 2026-10-22 00:45:35 +05:30
Abhi Kumar
d5d00a8e3b fix(charts): pick the scatter auto scale by how the points spread
Max/min decades let one outlier decide and never left linear with zeros.
Compare middle-half coverage instead, falling back to symlog.
2026-10-22 00:45:35 +05:30
Abhi Kumar
924876a5d6 fix(charts): restore the default log axis grid
Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
5ab8f6e856 fix(dashboard): honour scatter plot soft limits on every axis scale
Only Auto weighs the soft limits when choosing a scale; an explicit scale keeps its choice.

Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
7b22b9718a feat(charts): draw axis lines in the grid's style by default
Padding the scatter range moved the plot edges off the outer grid lines, which
had stood in for the axes.

Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
d47d7315e8 fix(charts): keep scatter dots whole at the plot edges
uPlot clips series to the plot area, so a dot at an extreme value lost the half
past the edge. Each scatter scale now pads by the largest dot's radius in
pixels. Log axes are no longer snapped to powers of ten, since snapping after
the pad could add a whole empty decade; they place 1/2/5 ticks themselves.

Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
bf39698b75 fix(charts): draw log axis grid lines at powers of ten only
uPlot splits a log axis at every 1–9 × 10ⁿ, and a line on each buried the
points under a mesh.

Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
35874d28bf feat(charts): report the scatter point a click lands on
The shared click data finds its series by x position, which a faceted plot has
no axis for; the hit is read off the cursor instead.

Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
08173e5223 fix(charts): forward plotRef and skip x lookups on faceted clicks
ChartWrapper dropped its plotRef prop, and a click on a faceted plot threw in
posToIdx, which reads the null data[0].

Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
8601ebf8ea fix(charts): outline the hovered scatter point
Snap the ring to whole pixels so uPlot's ceil on left/top doesn't shift it
off the disc.

Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
711d224efe feat(charts): scatter axis scales and titles
auto picks log only for positive values spanning three decades; log falls
back to symlog when given values at or below zero.

Assisted-by: Claude Opus 5.5
2026-10-22 00:45:35 +05:30
Abhi Kumar
3ae527eddb chore: scatter plot base work 2026-10-22 00:45:35 +05:30
Abhi Kumar
75cf276320 feat(dashboards): add an axis to scatter plot thresholds 2026-10-22 00:43:26 +05:30
Naman Verma
ed489f9ec0 chore: add more validations for scatter plot 2026-10-22 00:43:26 +05:30
Abhi Kumar
afea1cabf6 feat(dashboards): rename scatter plot dimensions to colorBy and sizeBy 2026-10-22 00:43:26 +05:30
Abhi Kumar
1bbdcfb517 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-22 00:43:26 +05:30
Abhi Kumar
f823217030 feat(dashboards): add point size and opacity to scatter plot spec
The fixed size and the min/max range are both stored so binding or unbinding
the size dimension restores the other's last setting.

Assisted-by: Claude Opus 5.5
2026-10-22 00:43:26 +05:30
Abhi Kumar
d1a7f4a254 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-22 00:43:26 +05:30
Abhi Kumar
dca6628c94 feat(dashboards): add axis labels and multi-key colour to scatter plot spec
An empty colour list colours by every group-by label.

Assisted-by: Claude Opus 5.5
2026-10-22 00:43:26 +05:30
Abhi Kumar
f754129103 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-22 00:43:26 +05:30
Abhi Kumar
781294fb27 feat(dashboards): add plugin schema for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-22 00:43:26 +05:30
Ashwin Bhatkal
fcb685bf37 feat(notification-channels): add the telegram channel form (#13119)
#### Description

Gives Telegram a form, now that the v2 API models the kind. Last in the
stack, so it is written against the v2 schemas rather than the v1 form
it would have had to use underneath.

- `Settings/Telegram.tsx`: bot token, the numeric chat id, an optional
forum topic id, the message template, an optional self-hosted Bot API
URL, and a send-silently toggle.
- Telegram joins the channel type select and `renderSettings()`.
- Stories for the new and edit screens, a prefill case covering every
field, and a round-trip case so a saved channel comes back unchanged.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/583

#### Screenshots / Screen Recordings

[Screen diff against `main`, dark and
light](https://claude.ai/artifact/SeVW4fTm88oSCJpiCq2UM8)

Four new screens: the Telegram form, editing one, saving with the
required fields empty, and one with update denied. The other **115
screens are pixel for pixel the same** as on main, in both themes. The
single remaining row is a focus outline left by a story's own click, 95
pixels, checked by cropping both shots.

#### Additional Information

`chatId` is an int64 and `messageThreadId` an int, the first numeric
fields the channel form has had. An empty or half-typed box sends
nothing rather than `NaN`, which would serialise as `null` and be
rejected. The prefill test's field tuples widen to `string | number`
because a number input reports a number.

The message template is left out of the defaults on purpose: the API
fills `DefaultTelegramMessageTemplate` when the key is absent, and
sending an empty string would overwrite it.

The kind itself was taught to the list, the filter and the JSON view
(including masking the bot token) in the base PR, which is what the
exhaustive per-kind maps require once the schema carries `telegram`.
2026-10-21 17:38:49 +00:00
Vikrant Gupta
e69d3fcc8c fix(metricreduction): type the no-kept-labels preview count so the driver can scan it (#13221)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Previewing a rule that drops every non-protected label of a metric
returned 500 "failed to estimate reduction impact". With no kept labels
the cardinality query selects a bare `1`, which ClickHouse types as
`UInt8`, and the driver refuses to scan that into the `uint64` the code
reads it into.
- The literal is now `toUInt64(1)`, matching the scan target, so such
previews report one retained series as intended.
2026-10-21 12:33:51 +00:00
Naman Verma
354214ca87 chore: drop sloglint key-naming-case (#13220)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Dotted keys like rule.id are already the convention across the codebase.
2026-10-21 12:04:26 +00:00
Vikrant Gupta
e3488c1592 feat(authz): enable FGA for metrics explorer routes (#13150)
#### Description

- Metrics explorer routes were still on the coarse `ViewAccess` gate, so
a custom role scoped to a workspace key on metrics could list, inspect
and read stats for every metric through them while being denied on
`query_range`.
- Stats, treemap, inspect, highlights and onboarding derive the resource
from the caller's `filter.expression` or resolve to the metrics
wildcard, exactly as `query_range` does. Key-scoped custom roles get 403
unless the filter pins a granted key.
- A new `metrics-field` metaresource covers the metrics list, the
metadata and attributes reads, and the metadata update, with wildcard
grants: admin and editor may update, every managed role may list and
read. Existing organizations get the tuples through a migration.
- The alerts and dashboards lookups read the rule and dashboard stores,
so they require `rule:list` and `dashboard:list`.
- The new `metricsexplorer` integration suite is added to the CI matrix.

#### Issues closed by this PR

Part of SigNoz/keystone-pod#141
2026-10-21 11:40:56 +00:00
Swapnil Nakade
574bfa62b8 feat: enabled logs and added collection strategy for gcp integration (#13184)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- with release of 0.163.0 of collector contrib, `googlecloudpubsubpush`
receiver is published
- enabling logs services in gcp integration for manual flow

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Contributes to https://github.com/SigNoz/keystone-pod/issues/164

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2026-10-21 11:40:25 +00:00
184 changed files with 9906 additions and 371 deletions

View File

@@ -75,6 +75,7 @@ jobs:
- ttl
- clickhousecluster
- metricreduction
- metricsexplorer
sqlstore-provider:
- postgres
- sqlite

View File

@@ -41,7 +41,6 @@ linters:
no-global: all
context: all
static-msg: true
key-naming-case: snake
exclusions:
generated: lax
presets:

View File

@@ -3581,6 +3581,7 @@ components:
- meter-metrics
- logs-field
- traces-field
- metrics-field
type: string
CoretypesObject:
properties:
@@ -3782,6 +3783,13 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4524,6 +4532,7 @@ components:
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -4539,6 +4548,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4552,6 +4562,7 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4637,6 +4648,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
properties:
kind:
enum:
- signoz/ScatterPlotPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
properties:
kind:
@@ -4716,6 +4739,16 @@ components:
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesPointDiameter:
maximum: 40
minimum: 2
nullable: true
type: integer
DashboardtypesPointOpacity:
maximum: 1
minimum: 0.1
nullable: true
type: number
DashboardtypesPostableDashboardV2:
properties:
generateName:
@@ -4904,6 +4937,104 @@ components:
required:
- queryValue
type: object
DashboardtypesScatterPlotAxes:
properties:
x:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
"y":
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
type: object
DashboardtypesScatterPlotAxis:
properties:
label:
description: Axis title. Empty draws none.
type: string
scale:
$ref: '#/components/schemas/DashboardtypesAxisScale'
softMax:
nullable: true
type: number
softMin:
nullable: true
type: number
type: object
DashboardtypesScatterPlotChartAppearance:
properties:
points:
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
type: object
DashboardtypesScatterPlotDimensions:
properties:
colorBy:
description: Group-by label names (e.g. k8s.namespace.name) whose combined
values colour dots and drive the legend. Empty colours by every group-by
label.
items:
type: string
nullable: true
type: array
sizeBy:
description: Value column key that scales dot size. Empty draws every dot
at the default size.
type: string
x:
description: Value column key (queryName, or queryName.expression for a
multi-aggregation query) plotted on the x axis. Empty uses the first value
column.
type: string
"y":
description: Value column key plotted on the y axis. Empty uses the second
value column.
type: string
type: object
DashboardtypesScatterPlotPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
dimensions:
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
formatting:
$ref: '#/components/schemas/DashboardtypesTableFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesScatterPlotPoints:
properties:
maxSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
minSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
opacity:
$ref: '#/components/schemas/DashboardtypesPointOpacity'
size:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
type: object
DashboardtypesScatterPlotThreshold:
properties:
axis:
$ref: '#/components/schemas/DashboardtypesThresholdAxis'
color:
type: string
label:
type: string
unit:
type: string
value:
format: double
type: number
required:
- value
- color
type: object
DashboardtypesSource:
enum:
- user
@@ -5017,6 +5148,11 @@ components:
- value
- name
type: object
DashboardtypesThresholdAxis:
enum:
- x
- "y"
type: string
DashboardtypesThresholdFormat:
enum:
- text
@@ -20637,9 +20773,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics-field:list
- tokenizer:
- VIEWER
- metrics-field:list
summary: List metric names
tags:
- metrics
@@ -20706,9 +20842,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: Get metric alerts
tags:
- metrics
@@ -20790,9 +20926,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics-field:read
- tokenizer:
- VIEWER
- metrics-field:read
summary: Get metric attributes
tags:
- metrics
@@ -20860,9 +20996,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Get metric highlights
tags:
- metrics
@@ -20919,9 +21055,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Inspect raw metric data points
tags:
- metrics
@@ -20989,9 +21125,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics-field:read
- tokenizer:
- VIEWER
- metrics-field:read
summary: Get metric metadata
tags:
- metrics
@@ -21035,9 +21171,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- metrics-field:update
- tokenizer:
- EDITOR
- metrics-field:update
summary: Update metric metadata
tags:
- metrics
@@ -21083,9 +21219,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Check if non-SigNoz metrics have been received
tags:
- metrics
@@ -21142,9 +21278,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Get metrics statistics
tags:
- metrics
@@ -21201,9 +21337,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Get metrics treemap
tags:
- metrics
@@ -28342,9 +28478,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- dashboard:list
- tokenizer:
- VIEWER
- dashboard:list
summary: Get metric dashboards (v2)
tags:
- metrics

View File

@@ -116,7 +116,7 @@ func (c *clickhouse) EstimateCardinality(ctx context.Context, metricName string,
sb := sqlbuilder.NewSelectBuilder()
reducedExpr := "1"
reducedExpr := "toUInt64(1)"
if len(keptLabels) > 0 {
reducedExpr = "uniq(("
for i, label := range keptLabels {

View File

@@ -57,6 +57,7 @@ const mockPaths = {
const mockTzDate = jest.fn(
(date: Date, _timezone: string) => new Date(date.getTime()),
);
const mockOrient = jest.fn();
// Mock uPlot constructor - this needs to be a proper constructor function
function MockUPlot(
@@ -70,6 +71,7 @@ function MockUPlot(
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
MockUPlot.orient = mockOrient;
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
MockUPlot.pxRatio = 1;

View File

@@ -187,5 +187,24 @@
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
"api_key_required": "API Key is mandatory",
"to_required": "To field is mandatory",
"channel_name_required": "Channel name is mandatory"
"channel_name_required": "Channel name is mandatory",
"bot_token_required": "Bot token is mandatory for creating a telegram channel",
"chat_id_required": "Chat ID is mandatory for creating a telegram channel",
"telegram_tip": "Create a bot with @BotFather, add it to the chat, then paste its token and the chat's numeric id below.",
"telegram_tip_link": "Learn how",
"field_telegram_bot_token": "Bot token",
"tooltip_telegram_bot_token": "The token BotFather returns, in the form 123456789:AA...",
"field_telegram_chat_id": "Chat ID",
"tooltip_telegram_chat_id": "The numeric chat the bot posts to, negative for groups and channels. Telegram's @username form is not accepted.",
"placeholder_telegram_chat_id": "e.g. -1001234567890",
"field_telegram_message_thread_id": "Message thread ID",
"tooltip_telegram_message_thread_id": "Posts into one message thread of a forum group. Leave empty to post in the group itself.",
"field_telegram_title": "Title",
"tooltip_telegram_title": "Sent in bold above the message. Telegram HTML, so interpolated values are escaped.",
"field_telegram_message": "Message",
"tooltip_telegram_message": "Template for the message body. Telegram HTML, so interpolated values are escaped.",
"field_telegram_api_url": "Bot API URL",
"tooltip_telegram_api_url": "Point at a self-hosted Bot API server. Leave empty to use Telegram's.",
"field_telegram_disable_notifications": "Send silently",
"tooltip_telegram_disable_notifications": "Delivers without a sound or vibration on the recipients' devices."
}

View File

@@ -3291,6 +3291,7 @@ export enum CoretypesKindDTO {
'meter-metrics' = 'meter-metrics',
'logs-field' = 'logs-field',
'traces-field' = 'traces-field',
'metrics-field' = 'metrics-field',
}
export enum CoretypesTypeDTO {
user = 'user',
@@ -5061,6 +5062,12 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export enum DashboardtypesAxisScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -5817,6 +5824,126 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesHeatmapPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind {
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
export interface DashboardtypesScatterPlotAxisDTO {
/**
* @type string
* @description Axis title. Empty draws none.
*/
label?: string;
scale?: DashboardtypesAxisScaleDTO;
/**
* @type number,null
*/
softMax?: number | null;
/**
* @type number,null
*/
softMin?: number | null;
}
export interface DashboardtypesScatterPlotAxesDTO {
x?: DashboardtypesScatterPlotAxisDTO;
y?: DashboardtypesScatterPlotAxisDTO;
}
/**
* @minimum 2
* @maximum 40
* @nullable
*/
export type DashboardtypesPointDiameterDTO = number | null;
/**
* @minimum 0.1
* @maximum 1
* @nullable
*/
export type DashboardtypesPointOpacityDTO = number | null;
export interface DashboardtypesScatterPlotPointsDTO {
maxSize?: DashboardtypesPointDiameterDTO | null;
minSize?: DashboardtypesPointDiameterDTO | null;
opacity?: DashboardtypesPointOpacityDTO | null;
size?: DashboardtypesPointDiameterDTO | null;
}
export interface DashboardtypesScatterPlotChartAppearanceDTO {
points?: DashboardtypesScatterPlotPointsDTO;
}
export interface DashboardtypesScatterPlotDimensionsDTO {
/**
* @type array,null
* @description Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label.
*/
colorBy?: string[] | null;
/**
* @type string
* @description Value column key that scales dot size. Empty draws every dot at the default size.
*/
sizeBy?: string;
/**
* @type string
* @description Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column.
*/
x?: string;
/**
* @type string
* @description Value column key plotted on the y axis. Empty uses the second value column.
*/
y?: string;
}
export enum DashboardtypesThresholdAxisDTO {
x = 'x',
y = 'y',
}
export interface DashboardtypesScatterPlotThresholdDTO {
axis?: DashboardtypesThresholdAxisDTO;
/**
* @type string
*/
color: string;
/**
* @type string
*/
label?: string;
/**
* @type string
*/
unit?: string;
/**
* @type number
* @format double
*/
value: number;
}
export interface DashboardtypesScatterPlotPanelSpecDTO {
axes?: DashboardtypesScatterPlotAxesDTO;
chartAppearance?: DashboardtypesScatterPlotChartAppearanceDTO;
dimensions?: DashboardtypesScatterPlotDimensionsDTO;
formatting?: DashboardtypesTableFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesScatterPlotThresholdDTO[] | null;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO {
/**
* @enum signoz/ScatterPlotPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind;
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -5827,7 +5954,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6809,6 +6937,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
/**
* @nullable

View File

@@ -21,6 +21,7 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
convertBuilderQueriesToV5,
mapPanelTypeToRequestType,
prepareQueryRangePayloadV5,
} from './prepareQueryRangePayloadV5';
@@ -904,6 +905,12 @@ describe('prepareQueryRangePayloadV5', () => {
});
});
describe('mapPanelTypeToRequestType', () => {
it('sends a scatter plot as scalar, like a table', () => {
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
});
});
describe('convertBuilderQueriesToV5 having normalization', () => {
const buildSpec = (having: unknown): MetricBuilderQuery => {
const [envelope] = convertBuilderQueriesToV5(

View File

@@ -51,6 +51,7 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
case PANEL_TYPES.BAR:
return 'time_series';
case PANEL_TYPES.TABLE:
case PANEL_TYPES.SCATTER:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return 'scalar';
@@ -274,6 +275,7 @@ export function createAggregation(
queryData.dataSource === DataSource.METRICS &&
panelType &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE);

View File

@@ -2,8 +2,11 @@ import { useCallback, useEffect, useState } from 'react';
import { Button, InputNumber, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { LogViewMode } from 'container/OptionsMenu/types';
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
import {
LogViewMode,
FontSize,
OptionsMenuConfig,
} from 'container/OptionsMenu/types';
import {
Check,
ChevronLeft,

View File

@@ -1,9 +1,14 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { Formula } from 'container/QueryBuilder/components/Formula';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
import { getBuilderVariant } from 'lib/query/queryMode';
import {
BuilderVariant,
IBuilderTraceOperator,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderField } from './queryBuilderFields.types';
@@ -59,8 +64,20 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
[config],
);
const isAIQuery =
getBuilderVariant(currentQuery.builder) === BuilderVariant.AI;
useEffect(() => {
if (currentDataSource !== initialDataSource || newPanelType !== panelType) {
// AI queries are traces-only; BAR must not force metrics on them.
if (isAIQuery) {
handleSetConfig(newPanelType, DataSource.TRACES);
return;
}
if (newPanelType === PANEL_TYPES.BAR) {
handleSetConfig(PANEL_TYPES.BAR, DataSource.METRICS);
return;
}
handleSetConfig(newPanelType, currentDataSource);
}
}, [
@@ -69,6 +86,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
initialDataSource,
currentDataSource,
newPanelType,
isAIQuery,
]);
useEffect(() => {

View File

@@ -18,6 +18,7 @@ import {
STEP_INTERVAL_PLACEHOLDER,
} from '../../queryBuilderFields.utils';
import { isScalarPanelType } from '../panelTypes';
import QueryAggregationSelect from './QueryAggregationSelect';
import './QueryAggregation.styles.scss';
@@ -45,10 +46,7 @@ function QueryAggregationOptions({
const isNonMetricSource =
dataSource === DataSource.TRACES || dataSource === DataSource.LOGS;
if (
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
) {
if (isNonMetricSource && isScalarPanelType(panelType)) {
return { hidden: true, disabled: false, pinned: false };
}

View File

@@ -9,7 +9,6 @@ import {
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import cx from 'classnames';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import QBEntityOptions from 'container/QueryBuilder/components/QBEntityOptions/QBEntityOptions';
import { QueryProps } from 'container/QueryBuilder/type';
import SpanScopeSelector from 'container/QueryBuilder/filters/QueryBuilderSearchV2/SpanScopeSelector';
@@ -27,6 +26,7 @@ import {
resolveQueryBuilderField,
} from '../queryBuilderFields.utils';
import { isScalarPanelType } from './panelTypes';
import MetricsAggregateSection from './MerticsAggregateSection/MetricsAggregateSection';
import { MetricsSelect } from './MetricsSelect/MetricsSelect';
import QueryAddOns from './QueryAddOns/QueryAddOns';
@@ -112,11 +112,7 @@ export const QueryV2 = forwardRef(function QueryV2(
}
const showReduceTo = useMemo(
() =>
dataSource === DataSource.METRICS &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE),
() => dataSource === DataSource.METRICS && isScalarPanelType(panelType),
[dataSource, panelType],
);

View File

@@ -0,0 +1,13 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
/** Panels that reduce each query to one value per group over the whole window. */
const SCALAR_PANEL_TYPES: ReadonlySet<string> = new Set<string>([
PANEL_TYPES.TABLE,
PANEL_TYPES.SCATTER,
PANEL_TYPES.PIE,
PANEL_TYPES.VALUE,
]);
export function isScalarPanelType(panelType?: string | null): boolean {
return panelType != null && SCALAR_PANEL_TYPES.has(panelType);
}

View File

@@ -14,6 +14,17 @@ jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
const queryData = initialQueriesMap.traces.builder
.queryData[0] as IBuilderQuery;
function renderFor(panelType: PANEL_TYPES, dataSource: DataSource): void {
render(
<QueryAggregationOptions
dataSource={dataSource}
panelType={panelType}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
}
describe('QueryAggregationOptions step interval placeholder', () => {
it('shows the configured placeholder', () => {
render(
@@ -34,14 +45,27 @@ describe('QueryAggregationOptions step interval placeholder', () => {
});
it('falls back to Auto', () => {
render(
<QueryAggregationOptions
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TIME_SERIES}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByPlaceholderText('Auto')).toBeInTheDocument();
});
});
describe('QueryAggregationOptions step interval', () => {
it.each([PANEL_TYPES.TABLE, PANEL_TYPES.SCATTER, PANEL_TYPES.PIE])(
'is hidden for a %s panel over traces, which reduces the whole window',
(panelType) => {
renderFor(panelType, DataSource.TRACES);
expect(screen.queryByText('every')).not.toBeInTheDocument();
},
);
it('stays for a scatter plot over metrics, where it sets the series step', () => {
renderFor(PANEL_TYPES.SCATTER, DataSource.METRICS);
expect(screen.getByText('every')).toBeInTheDocument();
});
it('stays for a time series over traces', () => {
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByText('every')).toBeInTheDocument();
});
});

View File

@@ -1,109 +0,0 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { render } from 'tests/test-utils';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderV2 } from '../QueryBuilderV2';
jest.mock('hooks/queryBuilder/useQueryBuilder');
jest.mock('../QueryV2/QueryV2', () => ({ QueryV2: (): null => null }));
jest.mock('../QueryV2/QueryFooter/QueryFooter', () => (): null => null);
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
const aiQuery: Query = {
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryData: initialQueriesMap.traces.builder.queryData.map((query) => ({
...query,
builderQueryType: QueryMode.AI_QUERY_BUILDER,
})),
},
};
function mockContext({
panelType = null,
initialDataSource = null,
currentQuery = initialQueriesMap.metrics,
}: {
panelType?: PANEL_TYPES | null;
initialDataSource?: DataSource | null;
currentQuery?: Query;
}): jest.Mock {
const handleSetConfig = jest.fn();
mockedUseQueryBuilder.mockReturnValue({
currentQuery,
panelType,
initialDataSource,
handleSetConfig,
addNewBuilderQuery: jest.fn(),
addNewFormula: jest.fn(),
addTraceOperator: jest.fn(),
handleRunQuery: jest.fn(),
} as unknown as ReturnType<typeof useQueryBuilder>);
return handleSetConfig;
}
describe('QueryBuilderV2 provider config sync', () => {
it('leaves the data source unset for a bar panel without a static config', () => {
const handleSetConfig = mockContext({});
render(<QueryBuilderV2 panelType={PANEL_TYPES.BAR} version="v3" />);
expect(handleSetConfig).toHaveBeenCalledWith(PANEL_TYPES.BAR, null);
});
it('syncs the static data source', () => {
const handleSetConfig = mockContext({});
render(
<QueryBuilderV2
version="v3"
panelType={PANEL_TYPES.BAR}
config={{ initialDataSource: DataSource.LOGS, queryVariant: 'static' }}
/>,
);
expect(handleSetConfig).toHaveBeenCalledWith(
PANEL_TYPES.BAR,
DataSource.LOGS,
);
});
it('keeps an AI query on traces for a bar panel', () => {
const handleSetConfig = mockContext({ currentQuery: aiQuery });
render(
<QueryBuilderV2
version="v3"
panelType={PANEL_TYPES.BAR}
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
/>,
);
expect(handleSetConfig).toHaveBeenCalledWith(
PANEL_TYPES.BAR,
DataSource.TRACES,
);
});
it('does not reset the config when the provider already matches', () => {
const handleSetConfig = mockContext({
panelType: PANEL_TYPES.TIME_SERIES,
initialDataSource: DataSource.TRACES,
});
render(
<QueryBuilderV2
version="v3"
panelType={PANEL_TYPES.TIME_SERIES}
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
/>,
);
expect(handleSetConfig).not.toHaveBeenCalled();
});
});

View File

@@ -36,6 +36,7 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HEATMAP]: null,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -337,6 +337,7 @@ export enum PANEL_TYPES {
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
SCATTER = 'scatter',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',

View File

@@ -0,0 +1,174 @@
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { Input as AntdInput } from 'antd';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
const { TextArea } = AntdInput;
/**
* The API models the chat and the topic as integers. An empty or half-typed box
* has to become `undefined`, not `NaN`: `omitBlank` keeps `NaN`, which
* serialises as `null` and the API rejects it. react-hook-form also runs this
* over the value already in the form, which arrives as a number, not a string.
*/
const asOptionalNumber = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return Number.isNaN(value) ? undefined : value;
}
if (typeof value !== 'string' || value.trim() === '') {
return undefined;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? undefined : parsed;
};
function TelegramSettings(): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
return (
<>
<FormCard title={t('section_connection')}>
<Typography.Text
color="muted"
size="sm"
testId="telegram-tip"
style={{ display: 'block', marginBottom: 16 }}
>
{t('telegram_tip')}{' '}
<Typography.Link
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
target="_blank"
rel="noopener noreferrer"
>
{t('telegram_tip_link')}
</Typography.Link>
</Typography.Text>
<FormField
label={t('field_telegram_bot_token')}
tooltip={t('tooltip_telegram_bot_token')}
error={errors.botToken?.message}
required
>
<Input
{...register('botToken')}
aria-required
type="password"
data-testid="telegram-bot-token-textbox"
/>
</FormField>
<FormField
label={t('field_telegram_chat_id')}
tooltip={t('tooltip_telegram_chat_id')}
error={errors.chatId?.message}
required
>
<Input
{...register('chatId', { setValueAs: asOptionalNumber })}
aria-required
type="number"
placeholder={t('placeholder_telegram_chat_id')}
data-testid="telegram-chat-id-textbox"
/>
</FormField>
<FormField
label={t('field_telegram_message_thread_id')}
tooltip={t('tooltip_telegram_message_thread_id')}
error={errors.messageThreadId?.message}
>
<Input
{...register('messageThreadId', { setValueAs: asOptionalNumber })}
type="number"
data-testid="telegram-message-thread-id-textbox"
/>
</FormField>
</FormCard>
<FormCard title={t('section_message')}>
<FormField
label={t('field_telegram_title')}
tooltip={t('tooltip_telegram_title')}
error={errors.title?.message}
>
<Controller
control={control}
name="title"
render={({ field }): JSX.Element => (
<TextArea
rows={2}
data-testid="telegram-title-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
<FormField
label={t('field_telegram_message')}
tooltip={t('tooltip_telegram_message')}
error={errors.message?.message}
>
<Controller
control={control}
name="message"
render={({ field }): JSX.Element => (
<TextArea
rows={6}
data-testid="telegram-message-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
</FormCard>
<FormCard title={t('section_advanced')}>
<FormField
label={t('field_telegram_api_url')}
tooltip={t('tooltip_telegram_api_url')}
error={errors.apiUrl?.message}
>
<Input {...register('apiUrl')} data-testid="telegram-api-url-textbox" />
</FormField>
<FormField
label={t('field_telegram_disable_notifications')}
tooltip={t('tooltip_telegram_disable_notifications')}
>
<Controller
control={control}
name="disableNotifications"
render={({ field }): JSX.Element => (
<Switch
color="primary"
textPlacement="right"
testId="telegram-disable-notifications-switch"
value={!!field.value}
onChange={field.onChange}
/>
)}
/>
</FormField>
</FormCard>
</>
);
}
export default TelegramSettings;

View File

@@ -20,6 +20,7 @@ import MsTeamsSettings from './Settings/MsTeams';
import OpsgenieSettings from './Settings/Opsgenie';
import PagerSettings from './Settings/Pager';
import SlackSettings from './Settings/Slack';
import TelegramSettings from './Settings/Telegram';
import WebhookSettings from './Settings/Webhook';
import { Button } from '@signozhq/ui/button';
@@ -38,6 +39,7 @@ const CHANNEL_TYPE_OPTIONS = [
{ value: ChannelKind.jira, label: 'Jira' },
{ value: ChannelKind.jsmops, label: 'Jira Service Management Ops' },
{ value: ChannelKind.incidentio, label: 'incident.io' },
{ value: ChannelKind.telegram, label: 'Telegram' },
];
function FormAlertChannels({
@@ -80,6 +82,8 @@ function FormAlertChannels({
return <IncidentIOSettings />;
case ChannelKind.opsgenie:
return <OpsgenieSettings />;
case ChannelKind.telegram:
return <TelegramSettings />;
case ChannelKind.email:
return <EmailSettings />;
default:

View File

@@ -28,6 +28,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
TRACE: false,
BAR: true,
AREA: true,
SCATTER: false,
PIE: false,
HISTOGRAM: false,
HEATMAP: false,

View File

@@ -16,6 +16,7 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.TRACE]: null,
// Dashboards v2 renders this kind; the V1 wrapper map is never asked for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,

View File

@@ -14,6 +14,7 @@ export type PartialPanelTypes = {
[PANEL_TYPES.AREA]: 'area';
[PANEL_TYPES.VALUE]: 'value';
[PANEL_TYPES.PIE]: 'pie';
[PANEL_TYPES.SCATTER]: 'scatter';
[PANEL_TYPES.HISTOGRAM]: 'histogram';
[PANEL_TYPES.HEATMAP]: 'heatmap';
};
@@ -150,6 +151,7 @@ export const panelTypeDataSourceFormValuesMap: Record<
[PANEL_TYPES.HEATMAP]: bySource(HEATMAP, HEATMAP_METRICS),
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.SCATTER]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),
[PANEL_TYPES.LIST]: bySource(RAW_ROWS, RAW_ROWS_METRICS),
};

View File

@@ -0,0 +1,94 @@
// Sections own the padding so the footer spans edge to edge.
.container {
// Same inset as the heatmap tooltip.
--gutter: 14px;
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);
}
}
.divider {
display: block;
width: 100%;
height: 1px;
background-color: var(--l2-border);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-6) var(--gutter);
}
// Matches the legend row's marker.
.marker {
box-sizing: border-box;
width: 12px;
height: 12px;
border: 1.5px solid;
border-radius: var(--radius);
flex-shrink: 0;
}
.title {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--text-vanilla-100);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-5) var(--gutter);
}
.row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--spacing-4);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
color: var(--text-vanilla-100);
}
.rowMuted {
color: var(--text-vanilla-400);
}
.rowLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowValue {
flex: 0 0 auto;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,139 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { Pin } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { ScatterTooltipProps } from '../types';
import { buildChannelRows, resolveHoveredPoint } from './scatterTooltipContent';
import Styles from './ScatterTooltip.module.scss';
/** Own component: the shared Tooltip lists one row per series at a shared x. */
export default function ScatterTooltip({
uPlotInstance,
dataIndexes,
seriesIndex,
channels,
resolvePointGroupValues,
decimalPrecision,
isPinned,
dismiss,
renderTooltipFooter,
}: ScatterTooltipProps): JSX.Element | null {
const hoveredPoint = useMemo(
() => resolveHoveredPoint(uPlotInstance, seriesIndex, dataIndexes),
[uPlotInstance, seriesIndex, dataIndexes],
);
const rows = useMemo(
() =>
hoveredPoint
? buildChannelRows(hoveredPoint, channels, decimalPrecision)
: [],
[hoveredPoint, channels, decimalPrecision],
);
const labels = useMemo(
() =>
hoveredPoint
? (resolvePointGroupValues?.(
hoveredPoint.seriesIndex,
hoveredPoint.dataIndex,
) ?? [])
: [],
[hoveredPoint, resolvePointGroupValues],
);
if (!hoveredPoint) {
return null;
}
return (
<div
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
data-pinned={isPinned}
data-testid="scatter-tooltip"
>
<div className={Styles.header}>
<span
className={Styles.marker}
style={{
backgroundColor: hoveredPoint.color,
borderColor: hoveredPoint.color,
}}
/>
<Typography.Text
as="span"
className={Styles.title}
title={hoveredPoint.label}
testId="scatter-tooltip-title"
>
{hoveredPoint.label}
</Typography.Text>
{isPinned && (
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<Pin size={12} />}
testId="scatter-tooltip-status"
>
Pinned
</Button>
)}
</div>
<span className={Styles.divider} />
<div className={Styles.rows}>
{rows.map((row) => (
<div
key={row.label}
className={Styles.row}
data-testid="scatter-tooltip-row"
>
<Typography.Text as="span" className={Styles.rowLabel}>
{row.label}
</Typography.Text>
<Typography.Text as="span" className={Styles.rowValue}>
{row.value}
</Typography.Text>
</div>
))}
</div>
{labels.length > 0 && (
<>
<span className={Styles.divider} />
<div className={Styles.rows}>
{labels.map((label) => (
<div
key={label.key}
className={cx(Styles.row, Styles.rowMuted)}
data-testid="scatter-tooltip-label"
>
<Typography.Text
as="span"
className={Styles.rowLabel}
title={label.key}
>
{label.key}
</Typography.Text>
<Typography.Text
as="span"
className={Styles.rowValue}
title={label.value}
>
{label.value}
</Typography.Text>
</div>
))}
</div>
</>
)}
{renderTooltipFooter?.({ isPinned, dismiss })}
</div>
);
}

View File

@@ -0,0 +1,107 @@
import type uPlot from 'uplot';
import {
buildChannelRows,
resolveHoveredPoint,
ScatterHoveredPoint,
} from '../scatterTooltipContent';
jest.mock('components/Graph/yAxisConfig', () => ({
getToolTipValue: jest.fn((value: number | string, unit?: string) =>
`${value} ${unit ?? ''}`.trim(),
),
}));
const plot = {
data: [
null,
[
[10, 20],
[100, 200],
[5, null],
],
[[30], [300]],
],
series: [
{},
{ label: 'cart', stroke: '#ff0000' },
{ label: 'checkout', stroke: (): string => '#00ff00' },
],
} as unknown as uPlot;
describe('resolveHoveredPoint', () => {
it('reads the focused series at its own data index', () => {
expect(resolveHoveredPoint(plot, 1, [null, 1, null])).toStrictEqual({
seriesIndex: 1,
dataIndex: 1,
label: 'cart',
color: '#ff0000',
x: 20,
y: 200,
size: null,
});
});
it('carries the size column when present and resolves function strokes', () => {
expect(resolveHoveredPoint(plot, 1, [null, 0, null])?.size).toBe(5);
expect(resolveHoveredPoint(plot, 2, [null, null, 0])).toMatchObject({
label: 'checkout',
color: '#00ff00',
size: null,
});
});
it('is null without a focused series or an index for it', () => {
expect(resolveHoveredPoint(plot, null, [null, 0, null])).toBeNull();
expect(resolveHoveredPoint(plot, 0, [0, 0, null])).toBeNull();
expect(resolveHoveredPoint(plot, 1, [null, null, null])).toBeNull();
});
});
describe('buildChannelRows', () => {
const point: ScatterHoveredPoint = {
seriesIndex: 1,
dataIndex: 0,
label: 'cart',
color: '#f00',
x: 12,
y: 340,
size: 7,
};
it('formats x and y with their own units', () => {
const rows = buildChannelRows(point, {
x: { label: 'Throughput', unit: 'reqps' },
y: { label: 'p99', unit: 'ms' },
});
expect(rows).toStrictEqual([
{ label: 'Throughput', value: '12 reqps' },
{ label: 'p99', value: '340 ms' },
]);
});
it('adds the size row only when the channel is mapped and the point has one', () => {
const channels = {
x: { label: 'x' },
y: { label: 'y' },
size: { label: 'Errors' },
};
expect(buildChannelRows(point, channels)).toHaveLength(3);
expect(buildChannelRows({ ...point, size: null }, channels)).toHaveLength(2);
});
it('leaves out a size row that repeats the x row', () => {
const rows = buildChannelRows(
{ ...point, size: point.x },
{
x: { label: 'A.count()' },
y: { label: 'p99' },
size: { label: 'A.count()' },
},
);
expect(rows.map((row) => row.label)).toStrictEqual(['A.count()', 'p99']);
});
});

View File

@@ -0,0 +1,97 @@
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import type uPlot from 'uplot';
import type {
ScatterChannel,
ScatterChannels,
ScatterSeriesData,
} from '../../plugins/ScatterPlugin/types';
import { resolveSeriesColor } from './utils';
export interface ScatterHoveredPoint {
seriesIndex: number;
dataIndex: number;
label: string;
color: string;
x: number;
y: number;
size: number | null;
}
export interface ScatterTooltipRow {
label: string;
value: string;
}
export function resolveHoveredPoint(
u: uPlot,
seriesIndex: number | null,
dataIndexes: Array<number | null>,
): ScatterHoveredPoint | null {
if (seriesIndex == null || seriesIndex < 1) {
return null;
}
const dataIndex = dataIndexes[seriesIndex];
const series = u.series[seriesIndex];
const columns = u.data[seriesIndex] as unknown as
| ScatterSeriesData
| undefined;
if (dataIndex == null || !series || !columns) {
return null;
}
const x = columns[0][dataIndex];
const y = columns[1][dataIndex];
if (x == null || y == null) {
return null;
}
return {
seriesIndex,
dataIndex,
label: String(series.label ?? ''),
color: resolveSeriesColor(series.stroke, u, seriesIndex),
x,
y,
size: columns[2]?.[dataIndex] ?? null,
};
}
function formatChannel(
value: number,
channel: ScatterChannel,
decimalPrecision?: PrecisionOption,
): string {
return getToolTipValue(value, channel.unit, decimalPrecision);
}
export function buildChannelRows(
point: ScatterHoveredPoint,
channels: ScatterChannels,
decimalPrecision?: PrecisionOption,
): ScatterTooltipRow[] {
const rows: ScatterTooltipRow[] = [
{
label: channels.x.label,
value: formatChannel(point.x, channels.x, decimalPrecision),
},
{
label: channels.y.label,
value: formatChannel(point.y, channels.y, decimalPrecision),
},
];
if (channels.size && point.size != null) {
const sizeRow = {
label: channels.size.label,
value: formatChannel(point.size, channels.size, decimalPrecision),
};
// Size bound to the X or Y column would only repeat that row.
if (
!rows.some(
(row) => row.label === sizeRow.label && row.value === sizeRow.value,
)
) {
rows.push(sizeRow);
}
}
return rows;
}

View File

@@ -6,6 +6,10 @@ import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { LegendItem } from '../config/types';
import { HeatmapSeries, HeatmapYAxis } from '../plugins/HeatmapPlugin/types';
import type {
ScatterChannels,
ScatterPointLabel,
} from '../plugins/ScatterPlugin/types';
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
/**
@@ -119,6 +123,16 @@ export interface HeatmapTooltipProps
groupColor: string;
}
export interface ScatterTooltipProps
extends BaseTooltipProps, TooltipRenderArgs {
channels: ScatterChannels;
/** The group values behind a point, e.g. `service.name` → `cart`. */
resolvePointGroupValues?: (
seriesIndex: number,
dataIndex: number,
) => ScatterPointLabel[];
}
export type TooltipProps =
| TimeSeriesTooltipProps
| BarTooltipProps

View File

@@ -39,6 +39,16 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
};
}
/** Styled like the grid. */
private buildBorderConfig(
grid: uPlot.Axis.Grid | undefined,
): uPlot.Axis.Border | undefined {
if (this.props.showBorder === false || !grid || grid.show === false) {
return undefined;
}
return { show: true, stroke: grid.stroke, width: grid.width };
}
/**
* Build ticks configuration
*/
@@ -59,31 +69,47 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
/**
* Build values formatter for X-axis (time)
* A non-time x axis with a unit or precision formats like a value axis.
*/
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
const { isTimeAxis } = this.props;
const { isTimeAxis, yAxisUnit, decimalPrecision } = this.props;
if (isTimeAxis) {
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
}
if (yAxisUnit !== undefined || decimalPrecision !== undefined) {
return this.buildValueAxisFormatter();
}
return undefined;
}
/**
* Build values formatter for Y-axis (values with units)
* Blanks splits outside the scale; uPlot's asinh splits always include ±threshold.
*/
private buildYAxisValuesFormatter(): uPlot.Axis.Values {
const { yAxisUnit, decimalPrecision } = this.props;
private buildValueAxisFormatter(): uPlot.Axis.Values {
const { yAxisUnit, decimalPrecision, scaleKey } = this.props;
return (_, t): string[] =>
t.map((v) => {
if (v === null || v === undefined || Number.isNaN(v)) {
return (u, t): string[] => {
const scale = u?.scales?.[scaleKey];
const min = scale?.min ?? -Infinity;
const max = scale?.max ?? Infinity;
return t.map((v) => {
if (
v === null ||
v === undefined ||
Number.isNaN(v) ||
v < min ||
v > max
) {
return '';
}
const value = getToolTipValue(v.toString(), yAxisUnit, decimalPrecision);
return `${value}`;
});
};
}
/**
@@ -101,7 +127,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
return scaleKey === 'x'
? this.buildXAxisValuesFormatter()
: scaleKey === 'y'
? this.buildYAxisValuesFormatter()
? this.buildValueAxisFormatter()
: undefined;
}
@@ -149,10 +175,12 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
side = 2, // bottom by default
space,
splits,
filter,
gap = 5, // default gap is 5
} = this.props;
const grid = this.buildGridConfig();
const border = this.buildBorderConfig(grid);
const ticks = this.buildTicksConfig();
const values = this.buildValuesFormatter();
const size = this.buildSizeCalculator();
@@ -177,6 +205,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
if (ticks) {
axisConfig.ticks = ticks;
}
if (border) {
axisConfig.border = border;
}
if (values) {
axisConfig.values = values;
}
@@ -189,6 +220,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
if (space !== undefined) {
axisConfig.space = space;
}
if (filter) {
axisConfig.filter = filter;
}
if (size) {
axisConfig.size = size;
}

View File

@@ -20,6 +20,7 @@ import {
ConfigBuilder,
ConfigBuilderProps,
LegendItem,
PlotMode,
SelectionPreferencesSource,
StackMode,
} from './types';
@@ -66,6 +67,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
private stackMode: StackMode = StackMode.None;
private mode: PlotMode = PlotMode.Aligned;
private cursor: Cursor | undefined;
private hooks: Hooks.Arrays = {};
@@ -163,6 +166,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
return this.stackMode;
}
setMode(mode: PlotMode): void {
this.mode = mode;
}
/**
* Add or merge a scale configuration
*/
@@ -519,6 +526,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
{} as Record<string, uPlot.Scale>,
);
if (this.mode === PlotMode.Faceted) {
config.mode = this.mode as number as uPlot.Mode;
}
config.hooks = this.hooks;
config.select = this.select;

View File

@@ -40,6 +40,7 @@ export class UPlotScaleBuilder extends ConfigBuilder<
range,
thresholds,
logBase = 10,
asinhThreshold,
padMinBy = 0,
padMaxBy = 0.05,
} = this.props;
@@ -99,6 +100,7 @@ export class UPlotScaleBuilder extends ConfigBuilder<
time,
distr,
logBase,
asinhThreshold,
});
const { rangeConfig, hardMinOnly, hardMaxOnly, hasFixedRange } =

View File

@@ -88,6 +88,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
lineConfig.fill = finalFillColor;
} else if (this.props.drawStyle === DrawStyle.Histogram) {
lineConfig.fill = `${finalFillColor}40`;
} else if (this.props.drawStyle === DrawStyle.Scatter) {
lineConfig.fill = `${finalFillColor}${toAlphaHex(resolveFillOpacity(fillOpacity))}`;
} else if (fillMode && fillMode !== FillMode.None) {
const resolvedOpacity = resolveFillOpacity(fillOpacity);
if (fillMode === FillMode.Solid) {
@@ -123,7 +125,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
return { paths: pathBuilder };
}
if (drawStyle === DrawStyle.Points) {
if (isMarkerOnlyDrawStyle(drawStyle)) {
return { paths: (): null => null };
}
@@ -195,6 +197,10 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
if (drawStyle === DrawStyle.Points) {
return true;
}
// The discs are the series path; uPlot's own points would double-draw them.
if (drawStyle === DrawStyle.Scatter) {
return false;
}
return !!showPoints;
}
@@ -226,6 +232,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
show = true,
metric,
partialTimestamps,
facets,
} = this.props;
const resolvedLineColor = this.getLineColor();
@@ -255,6 +262,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
metric,
...(partialTimestamps?.size ? { partialTimestamps } : {}),
...(facets && { facets }),
};
}
}
@@ -270,6 +278,11 @@ interface PathBuilders {
/**
* Get path builder based on draw style and interpolation
*/
/** Draws standalone markers, never a connecting line. */
function isMarkerOnlyDrawStyle(drawStyle: DrawStyle): boolean {
return drawStyle === DrawStyle.Points || drawStyle === DrawStyle.Scatter;
}
function getPathBuilder({
drawStyle,
lineInterpolation,

View File

@@ -120,6 +120,41 @@ describe('UPlotAxisBuilder', () => {
});
});
it('draws the axis line by default, styled like the grid', () => {
const config = new UPlotAxisBuilder(
createAxisProps({ isDarkMode: true, grid: { width: 0.5 } }),
).getConfig();
expect(config.border).toStrictEqual({
show: true,
stroke: config.grid?.stroke,
width: 0.5,
});
});
it('leaves the axis line off when showBorder is false', () => {
expect(
new UPlotAxisBuilder(createAxisProps({ showBorder: false })).getConfig(),
).not.toHaveProperty('border');
});
it('leaves the axis line off when the grid is hidden', () => {
expect(
new UPlotAxisBuilder(createAxisProps({ grid: { show: false } })).getConfig(),
).not.toHaveProperty('border');
});
it('passes explicit splits and a label filter through to uPlot', () => {
const splits = jest.fn();
const filter = jest.fn();
const config = new UPlotAxisBuilder(
createAxisProps({ splits, filter }),
).getConfig();
expect(config.splits).toBe(splits);
expect(config.filter).toBe(filter);
});
it('uses provided ticks config when present and falls back to defaults otherwise', () => {
const customTicks = { width: 1, show: false };
const withTicks = new UPlotAxisBuilder(
@@ -376,3 +411,51 @@ describe('UPlotAxisBuilder', () => {
expect(config.values).toBeDefined();
});
});
describe('UPlotAxisBuilder value x axis', () => {
it('formats a non-time x axis with its unit', () => {
(getToolTipValue as jest.Mock).mockReturnValue('1.2K req/s');
const config = new UPlotAxisBuilder(
createAxisProps({ scaleKey: 'x', isTimeAxis: false, yAxisUnit: 'reqps' }),
).getConfig();
const values = (config.values as uPlot.Axis.DynamicValues)(
{} as uPlot,
[1200],
0,
0,
0,
);
expect(values).toStrictEqual(['1.2K req/s']);
expect(getToolTipValue).toHaveBeenCalledWith('1200', 'reqps', undefined);
});
it('leaves a non-time x axis to uPlot when nothing says how to format it', () => {
const config = new UPlotAxisBuilder(
createAxisProps({ scaleKey: 'x', isTimeAxis: false }),
).getConfig();
expect(config.values).toBeUndefined();
});
});
describe('UPlotAxisBuilder out-of-range splits', () => {
it('leaves a split the scale cannot place unlabelled', () => {
(getToolTipValue as jest.Mock).mockImplementation((v: string) => `${v} ms`);
const config = new UPlotAxisBuilder(
createAxisProps({ scaleKey: 'y', yAxisUnit: 'ms' }),
).getConfig();
const u = { scales: { y: { min: 0, max: 1000 } } } as unknown as uPlot;
const values = (config.values as uPlot.Axis.DynamicValues)(
u,
[-10, 0, 500, 5000],
0,
0,
0,
);
expect(values).toStrictEqual(['', '0 ms', '500 ms', '']);
});
});

View File

@@ -5,7 +5,12 @@ import {
STEP_INTERVAL_MULTIPLIER,
} from '../../constants';
import type { SeriesProps } from '../types';
import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types';
import {
DrawStyle,
PlotMode,
SelectionPreferencesSource,
StackMode,
} from '../types';
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
// Mock only the real boundary that hits localStorage
@@ -667,3 +672,14 @@ describe('UPlotConfigBuilder stacking', () => {
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
});
});
describe('UPlotConfigBuilder plot mode', () => {
it('leaves mode unset for aligned data and emits 2 when faceted', () => {
const aligned = new UPlotConfigBuilder({ id: 'aligned' });
expect(aligned.getConfig().mode).toBeUndefined();
const faceted = new UPlotConfigBuilder({ id: 'faceted' });
faceted.setMode(PlotMode.Faceted);
expect(faceted.getConfig().mode).toBe(2);
});
});

View File

@@ -425,3 +425,39 @@ describe('UPlotSeriesBuilder', () => {
);
});
});
describe('UPlotSeriesBuilder scatter', () => {
it('draws through the given path builder and hides uPlot points', () => {
const pathBuilder = jest.fn();
const config = new UPlotSeriesBuilder(
createBaseProps({
drawStyle: DrawStyle.Scatter,
pathBuilder,
facets: [{ scale: 'x' }, { scale: 'y' }],
lineColor: '#ff0000',
fillOpacity: 0.5,
lineWidth: 1,
pointSize: 8,
}),
).getConfig();
expect(config.paths).toBe(pathBuilder);
expect(config.facets).toStrictEqual([{ scale: 'x' }, { scale: 'y' }]);
expect(config.points?.show).toBe(false);
expect(config.points?.size).toBe(8);
expect(config.stroke).toBe('#ff0000');
expect(config.width).toBe(1);
expect(config.fill).toBe('#ff000080');
});
it('draws nothing without a path builder', () => {
const config = new UPlotSeriesBuilder(
createBaseProps({ drawStyle: DrawStyle.Scatter }),
).getConfig();
expect(
(config.paths as uPlot.Series.PathBuilder)({} as uPlot, 1, 0, 0),
).toBeNull();
expect(config.facets).toBeUndefined();
});
});

View File

@@ -80,6 +80,10 @@ export interface AxisProps {
values?: uPlot.Axis.Values;
/** Explicit axis splits, overriding the default tick calculation. */
splits?: uPlot.Axis.Splits;
/** Which splits get a label, replacing uPlot's per-distribution default. */
filter?: uPlot.Axis.Filter;
/** Draw the axis line along the plot edge, styled like the grid. Default true. */
showBorder?: boolean;
/** 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. */
@@ -90,7 +94,7 @@ export interface AxisProps {
isDarkMode?: boolean;
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
isLogScale?: boolean;
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`). */
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`); a non-time x axis reads it too. */
yAxisUnit?: string;
/**
* X axis carries timestamps, so its ticks format as dates/times. Declared by the caller
@@ -109,6 +113,13 @@ export interface AxisProps {
export enum DistributionType {
Linear = 'linear',
Logarithmic = 'logarithmic',
/** arcsinh: linear within ±`asinhThreshold`, log beyond; places zero and negatives. */
SymmetricLog = 'symlog',
}
export enum PlotMode {
Aligned = 1,
Faceted = 2,
}
export interface ScaleProps {
@@ -125,6 +136,8 @@ export interface ScaleProps {
auto?: boolean;
logBase?: uPlot.Scale.LogBase;
distribution?: DistributionType;
/** Half-width of a `SymmetricLog` scale's linear band around zero. Default 1. */
asinhThreshold?: number;
}
export enum DisconnectedValuesMode {
@@ -146,6 +159,7 @@ export enum DrawStyle {
Points = 'points',
Bar = 'bar',
Histogram = 'histogram',
Scatter = 'scatter',
}
export enum LineInterpolation {
@@ -232,6 +246,8 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
metric?: { [key: string]: string };
/** Epoch-second x values whose bucket doesn't cover a full step; marked with a dotted ring. */
partialTimestamps?: Set<number>;
/** Mode 2 only: the scales the series' own x and y columns are read against. */
facets?: Series.Facet[];
}
export interface LegendItem {

View File

@@ -0,0 +1,56 @@
import type uPlot from 'uplot';
import { thresholdsDrawHook } from '../useThresholdsDrawHook';
function mockPlot(ori: 0 | 1): { plot: uPlot; ctx: Record<string, jest.Mock> } {
const ctx = {
save: jest.fn(),
restore: jest.fn(),
setLineDash: jest.fn(),
beginPath: jest.fn(),
moveTo: jest.fn(),
lineTo: jest.fn(),
stroke: jest.fn(),
fillText: jest.fn(),
measureText: jest.fn(() => ({ width: 40 })),
};
const plot = {
ctx: { ...ctx, canvas: { height: 300 } },
bbox: { left: 10, top: 20, width: 400, height: 200 },
scales: { s: { ori } },
valToPos: (): number => 100,
} as unknown as uPlot;
return { plot, ctx };
}
const draw = (plot: uPlot): void =>
(
thresholdsDrawHook({
scaleKey: 's',
thresholds: [{ thresholdValue: 5, thresholdLabel: 'SLO' }],
}) as (u: uPlot) => void
)(plot);
describe('thresholdsDrawHook', () => {
it('draws a horizontal line across a vertical scale', () => {
const { plot, ctx } = mockPlot(1);
draw(plot);
expect(ctx.moveTo).toHaveBeenCalledWith(10, 100);
expect(ctx.lineTo).toHaveBeenCalledWith(410, 100);
expect(ctx.fillText).toHaveBeenCalledWith('SLO', 350, 85);
});
it('draws a vertical line across a horizontal scale', () => {
const { plot, ctx } = mockPlot(0);
draw(plot);
expect(ctx.moveTo).toHaveBeenCalledWith(100, 20);
expect(ctx.lineTo).toHaveBeenCalledWith(100, 220);
expect(ctx.fillText).toHaveBeenCalledWith(
'SLO',
expect.any(Number),
expect.any(Number),
);
});
});

View File

@@ -29,13 +29,42 @@ export function thresholdsDrawHook(
const scaleVal = u.valToPos(Number(yValue), scaleKey, true);
ctx.strokeStyle = color;
// A horizontal scale (e.g. a scatter plot's x) takes a vertical line.
if (u.scales[scaleKey]?.ori === 0) {
const x = Math.round(scaleVal);
const top = Math.round(u.bbox.top);
ctx.beginPath();
ctx.moveTo(x, top);
ctx.lineTo(x, Math.round(u.bbox.top + u.bbox.height));
ctx.stroke();
if (threshold.thresholdLabel) {
const gap = 6 * uPlot.pxRatio;
const textWidth = ctx.measureText(threshold.thresholdLabel).width;
const fitsRight = x + gap + textWidth <= u.bbox.left + u.bbox.width;
// uPlot leaves the last axis's text alignment on the context.
ctx.save();
ctx.fillStyle = color;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(
threshold.thresholdLabel,
fitsRight ? x + gap : x - gap - textWidth,
top + gap,
);
ctx.restore();
}
continue;
}
const x0 = Math.round(u.bbox.left);
const y0 = Math.round(scaleVal);
const x1 = Math.round(u.bbox.left + u.bbox.width);
const y1 = Math.round(scaleVal);
ctx.strokeStyle = color;
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);

View File

@@ -0,0 +1,116 @@
import type uPlot from 'uplot';
import { Quadtree } from '../../../utils/quadtree';
import {
resolveHit,
resolvePointDiameter,
resolveSizeDomain,
} from '../geometry';
import { ScatterHit, ScatterPointSize } from '../types';
const POINT_SIZE: ScatterPointSize = { fixed: 6, min: 4, max: 20 };
const asData = (columns: unknown[]): uPlot.AlignedData =>
columns as unknown as uPlot.AlignedData;
describe('resolveSizeDomain', () => {
it('spans the size columns of every series, skipping nulls', () => {
const data = asData([
null,
[
[1, 2],
[1, 2],
[10, null],
],
[[3], [3], [40]],
]);
expect(resolveSizeDomain(data)).toStrictEqual({ min: 10, max: 40 });
});
it('is null when no series carries sizes', () => {
expect(resolveSizeDomain(asData([null, [[1], [1]]]))).toBeNull();
expect(resolveSizeDomain(asData([null, [[1], [1], [null]]]))).toBeNull();
});
});
describe('resolvePointDiameter', () => {
it('uses the fixed diameter without a size or a domain', () => {
expect(resolvePointDiameter(null, { min: 0, max: 10 }, POINT_SIZE)).toBe(6);
expect(resolvePointDiameter(5, null, POINT_SIZE)).toBe(6);
});
it('maps the domain ends to min and max', () => {
const domain = { min: 0, max: 100 };
expect(resolvePointDiameter(0, domain, POINT_SIZE)).toBe(4);
expect(resolvePointDiameter(100, domain, POINT_SIZE)).toBe(20);
});
it('scales by area, not diameter', () => {
const midArea = (4 ** 2 + 20 ** 2) / 2;
expect(
resolvePointDiameter(50, { min: 0, max: 100 }, POINT_SIZE),
).toBeCloseTo(Math.sqrt(midArea));
});
it('clamps values outside the domain', () => {
const domain = { min: 10, max: 20 };
expect(resolvePointDiameter(-5, domain, POINT_SIZE)).toBe(4);
expect(resolvePointDiameter(500, domain, POINT_SIZE)).toBe(20);
});
it('uses the midpoint when every size is the same', () => {
expect(resolvePointDiameter(7, { min: 7, max: 7 }, POINT_SIZE)).toBe(12);
});
});
describe('resolveHit', () => {
const hit = (
seriesIndex: number,
dataIndex: number,
x: number,
y: number,
d: number,
): ScatterHit => ({ seriesIndex, dataIndex, x, y, w: d, h: d });
it('returns the disc under the cursor', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 6));
tree.add(hit(2, 3, 50, 50, 6));
expect(resolveHit(tree, 13, 13, 0)).toMatchObject({
seriesIndex: 1,
dataIndex: 0,
});
expect(resolveHit(tree, 52, 52, 0)).toMatchObject({
seriesIndex: 2,
dataIndex: 3,
});
});
it('is null when the cursor is off every disc', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 6));
expect(resolveHit(tree, 30, 30, 0)).toBeNull();
// Inside the disc's bounding square, outside the disc.
expect(resolveHit(tree, 15.8, 15.8, 0)).toBeNull();
});
it('tolerance widens each disc', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 6));
expect(resolveHit(tree, 18, 13, 0)).toBeNull();
expect(resolveHit(tree, 18, 13, 3)).not.toBeNull();
});
it('prefers the disc whose centre is nearest when they overlap', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 10));
tree.add(hit(1, 1, 14, 10, 10));
expect(resolveHit(tree, 13, 15, 0)?.dataIndex).toBe(0);
expect(resolveHit(tree, 21, 15, 0)?.dataIndex).toBe(1);
});
});

View File

@@ -0,0 +1,290 @@
import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../../../config/UPlotConfigBuilder';
import {
applyScatterPlugin,
createScatterPlugin,
SCATTER_FACETS,
} from '../scatterPlugin';
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
getStoredSeriesVisibility: jest.fn(),
}));
/** jsdom has no Path2D; the builder only needs something that takes the calls. */
class FakePath2D {
moveTo = jest.fn();
arc = jest.fn();
}
type OrientCallback = Parameters<typeof uPlot.orient>[2];
interface FakePlotArgs {
series: Array<{ xs: number[]; ys: number[]; sizes?: Array<number | null> }>;
cursor?: { left: number; top: number };
scaleX?: { min: number; max: number };
scaleY?: { min: number; max: number };
}
/**
* A 100×100 plot at the canvas origin with identity scales: value 10 draws at
* pixel 10 on x, and at 100 − 10 on y (uPlot's y grows downward).
*/
function createFakePlot({
series,
cursor = { left: -1, top: -1 },
scaleX = { min: 0, max: 100 },
scaleY = { min: 0, max: 100 },
}: FakePlotArgs): uPlot {
const data = [
null,
...series.map((entry) =>
entry.sizes ? [entry.xs, entry.ys, entry.sizes] : [entry.xs, entry.ys],
),
];
return {
data,
series: [{}, ...series.map((_, index) => ({ label: `s${index + 1}` }))],
bbox: { left: 0, top: 0, width: 100, height: 100 },
cursor,
scales: { x: scaleX, y: scaleY },
} as unknown as uPlot;
}
/** Stands in for `uPlot.orient`: identity x, flipped y, an `arc` that records. */
function orientWithIdentityScales(
u: uPlot,
seriesIdx: number,
cb: OrientCallback,
): void {
const columns = (u.data as unknown as Array<number[][] | null>)[seriesIdx];
if (!columns) {
return;
}
const scaleX = (u.scales as unknown as Record<string, uPlot.Scale>).x;
const scaleY = (u.scales as unknown as Record<string, uPlot.Scale>).y;
const valToPosX = (value: number): number => value;
const valToPosY = (value: number): number => 100 - value;
// Real uPlot's `arc` helper forwards to the path; the test counts those calls.
const arc = (path: FakePath2D, ...args: number[]): void => {
path.arc(...args);
};
cb(
u.series[seriesIdx],
columns[0],
columns[1],
scaleX,
scaleY,
valToPosX as unknown as uPlot.ValToPos,
valToPosY as unknown as uPlot.ValToPos,
0,
0,
100,
100,
jest.fn() as never,
jest.fn() as never,
jest.fn() as never,
arc as never,
jest.fn() as never,
);
}
describe('createScatterPlugin', () => {
beforeAll(() => {
(globalThis as { Path2D?: unknown }).Path2D = FakePath2D;
});
let orient: jest.SpyInstance;
beforeEach(() => {
orient = jest
.spyOn(uPlot, 'orient')
.mockImplementation(orientWithIdentityScales);
});
afterEach(() => {
orient.mockReset();
});
function drawAll(
u: uPlot,
plugin: ReturnType<typeof createScatterPlugin>,
): void {
plugin.hooks.drawClear(u);
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx++) {
const columns = (u.data as unknown as number[][][])[seriesIdx];
plugin.pathBuilder(u, seriesIdx, 0, columns[0].length - 1);
}
}
/** Runs the cursor scan the way uPlot does: every data series, in order. */
function scan(
u: uPlot,
plugin: ReturnType<typeof createScatterPlugin>,
): Array<number | null> {
const dataIdx = plugin.cursor.dataIdx as NonNullable<uPlot.Cursor['dataIdx']>;
const indexes: Array<number | null> = [null];
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx++) {
indexes.push(dataIdx(u, seriesIdx, 0, 0));
}
return indexes;
}
it('returns one path that strokes and fills the same discs', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({ series: [{ xs: [10, 20], ys: [10, 20] }] });
plugin.hooks.drawClear(u);
const paths = plugin.pathBuilder(u, 1, 0, 1) as uPlot.Series.Paths;
expect(paths.stroke).toBeInstanceOf(FakePath2D);
expect(paths.fill).toBe(paths.stroke);
expect((paths.fill as unknown as FakePath2D).arc).toHaveBeenCalledTimes(2);
});
it('resolves the hovered point to its own series and index', () => {
const plugin = createScatterPlugin({
pointSize: { fixed: 6, min: 4, max: 20 },
});
const u = createFakePlot({
series: [
{ xs: [10, 50], ys: [10, 50] },
{ xs: [80], ys: [80] },
],
// Over the second series' only point: x 80, y drawn at 100 − 80.
cursor: { left: 80, top: 20 },
});
drawAll(u, plugin);
expect(scan(u, plugin)).toStrictEqual([null, null, 0]);
expect(plugin.getHit()).toMatchObject({ seriesIndex: 2, dataIndex: 0 });
});
it('returns null for every series when the cursor is off the plot or off any disc', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({
series: [{ xs: [10], ys: [10] }],
cursor: { left: -1, top: -1 },
});
drawAll(u, plugin);
expect(scan(u, plugin)).toStrictEqual([null, null]);
(u.cursor as { left: number; top: number }).left = 50;
(u.cursor as { left: number; top: number }).top = 50;
expect(scan(u, plugin)).toStrictEqual([null, null]);
});
it('skips points outside the visible scale range', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({
series: [{ xs: [10, 500], ys: [10, 10] }],
cursor: { left: 10, top: 90 },
});
drawAll(u, plugin);
const paths = plugin.pathBuilder(u, 1, 0, 1) as uPlot.Series.Paths;
expect((paths.fill as unknown as FakePath2D).arc).toHaveBeenCalledTimes(1);
expect(scan(u, plugin)).toStrictEqual([null, 0]);
});
it('rings the hit disc, in CSS pixels, with a gap around it', () => {
const plugin = createScatterPlugin({
pointSize: { fixed: 8, min: 4, max: 20 },
});
const u = createFakePlot({
series: [{ xs: [10], ys: [10] }],
cursor: { left: 10, top: 90 },
});
drawAll(u, plugin);
scan(u, plugin);
const bbox = plugin.cursor.points?.bbox;
// The 8px disc spans 6..14; the ring sits 3px out and is 2px wide.
expect(bbox?.(u, 1)).toStrictEqual({
left: 1,
top: 81,
width: 18,
height: 18,
});
expect(bbox?.(u, 2)).toMatchObject({ width: 0, height: 0 });
});
it('snaps the ring to whole pixels without moving its centre', () => {
const plugin = createScatterPlugin({
pointSize: { fixed: 7, min: 4, max: 20 },
});
const u = createFakePlot({
series: [{ xs: [10], ys: [10] }],
cursor: { left: 10, top: 90 },
});
drawAll(u, plugin);
scan(u, plugin);
// The 7px disc starts at 6.5, so the ring's edge would land on 1.5.
const ring = plugin.cursor.points?.bbox?.(u, 1);
expect(ring).toMatchObject({ left: 1, width: 18 });
expect((ring?.left ?? 0) + (ring?.width ?? 0) / 2).toBe(10);
});
it('draws the hover marker as an outline in the series colour', () => {
const plugin = createScatterPlugin();
const u = {
series: [{}, { stroke: (): string => '#E5484D' }, { stroke: '#30A46C' }],
} as unknown as uPlot;
const points = plugin.cursor.points as {
fill: (u: uPlot, seriesIdx: number) => string;
stroke: (u: uPlot, seriesIdx: number) => string;
width: (u: uPlot, seriesIdx: number, size: number) => number;
};
expect(points.fill(u, 1)).toBe('transparent');
expect(points.stroke(u, 1)).toBe('#E5484D');
expect(points.stroke(u, 2)).toBe('#30A46C');
expect(points.width(u, 1, 0)).toBe(2);
});
it('drawClear drops cached paths on data series only', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({ series: [{ xs: [1], ys: [1] }] });
const [xSeries, dataSeries] = u.series as Array<{ _paths?: unknown }>;
xSeries._paths = 'x';
dataSeries._paths = 'cached';
plugin.hooks.drawClear(u);
expect(xSeries._paths).toBe('x');
expect(dataSeries._paths).toBeNull();
});
it('focus distance is zero, so the hit series wins focus', () => {
const plugin = createScatterPlugin();
expect(plugin.cursor.focus?.dist?.({} as uPlot, 1, 0, 0, 0)).toBe(0);
});
});
describe('applyScatterPlugin', () => {
it('switches the builder to faceted mode and disables drag selection', () => {
const builder = new UPlotConfigBuilder({ id: 'scatter' });
const plugin = createScatterPlugin();
applyScatterPlugin(builder, plugin);
const config = builder.getConfig();
expect(config.mode).toBe(2);
expect(config.cursor?.drag).toMatchObject({
x: false,
y: false,
setScale: false,
});
expect(config.hooks?.drawClear).toHaveLength(1);
expect(config.hooks?.destroy).toHaveLength(1);
});
it('facets read x and y against the shared scales', () => {
expect(SCATTER_FACETS).toStrictEqual([
{ scale: 'x', auto: true },
{ scale: 'y', auto: true },
]);
});
});

View File

@@ -0,0 +1,89 @@
import type uPlot from 'uplot';
import type { Quadtree } from '../../utils/quadtree';
import type { ScatterHit, ScatterPointSize, ScatterSeriesData } from './types';
export interface SizeDomain {
min: number;
max: number;
}
/** Size extent across all series, so equal values draw equal discs. */
export function resolveSizeDomain(data: uPlot.AlignedData): SizeDomain | null {
let min = Infinity;
let max = -Infinity;
for (let seriesIndex = 1; seriesIndex < data.length; seriesIndex++) {
const sizes = (data[seriesIndex] as unknown as ScatterSeriesData)[2];
if (!sizes) {
continue;
}
for (const size of sizes) {
if (size == null || !Number.isFinite(size)) {
continue;
}
min = Math.min(min, size);
max = Math.max(max, size);
}
}
return min <= max ? { min, max } : null;
}
/**
* Diameter for a size-channel value. Grows the disc's area, not its width,
* linearly with the value, so a value twice as large reads as twice the ink.
*/
export function resolvePointDiameter(
size: number | null | undefined,
domain: SizeDomain | null,
pointSize: ScatterPointSize,
): number {
if (size == null || domain == null || !Number.isFinite(size)) {
return pointSize.fixed;
}
if (domain.max === domain.min) {
return (pointSize.min + pointSize.max) / 2;
}
const t = Math.min(
1,
Math.max(0, (size - domain.min) / (domain.max - domain.min)),
);
const minArea = pointSize.min ** 2;
const maxArea = pointSize.max ** 2;
return Math.sqrt(minArea + (maxArea - minArea) * t);
}
/**
* The disc under the cursor, counting `tolerance` px beyond its edge as a hit.
* Where discs overlap, the one whose centre is closest wins; `null` if none.
*/
export function resolveHit(
tree: Quadtree<ScatterHit>,
cx: number,
cy: number,
tolerance: number,
): ScatterHit | null {
let best: ScatterHit | null = null;
let bestDistance = Infinity;
tree.get(
cx - tolerance,
cy - tolerance,
tolerance * 2,
tolerance * 2,
(hit) => {
const dx = cx - (hit.x + hit.w / 2);
const dy = cy - (hit.y + hit.h / 2);
const distance = dx * dx + dy * dy;
const reach = hit.w / 2 + tolerance;
if (distance > reach * reach) {
return;
}
if (distance < bestDistance) {
best = hit;
bestDistance = distance;
}
},
);
return best;
}

View File

@@ -0,0 +1,225 @@
import uPlot, { Series } from 'uplot';
import { DEFAULT_FOCUS_PROXIMITY_VALUE } from '../../constants';
import { PlotMode } from '../../config/types';
import type { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
import { Quadtree } from '../../utils/quadtree';
import {
resolveHit,
resolvePointDiameter,
resolveSizeDomain,
SizeDomain,
} from './geometry';
import {
DEFAULT_HOVER_TOLERANCE_PX,
DEFAULT_SCATTER_POINT_SIZE,
ScatterHit,
ScatterPluginOptions,
ScatterSeriesData,
} from './types';
export const SCATTER_FACETS: Series.Facet[] = [
{ scale: 'x', auto: true },
{ scale: 'y', auto: true },
];
const HIDDEN_BBOX: uPlot.BBox = { left: -10, top: -10, width: 0, height: 0 };
const TWO_PI = 2 * Math.PI;
/** CSS pixels between a hovered disc and the outline ring drawn around it. */
const HOVER_RING_GAP_PX = 3;
const HOVER_RING_WIDTH_PX = 2;
/** uPlot ceils a cursor point's left/top but not its size; snap so the ring stays centred. */
function snapToPixel(start: number, length: number): [number, number] {
const snapped = Math.floor(start);
return [snapped, length + (start - snapped) * 2];
}
export function getSeriesStroke(u: uPlot, seriesIdx: number): string {
const { stroke } = u.series[seriesIdx];
const color = typeof stroke === 'function' ? stroke(u, seriesIdx) : stroke;
return typeof color === 'string' ? color : '';
}
/** uPlot caches built paths on the series; the field is internal to it. */
type SeriesWithPaths = Series & { _paths?: Series.Paths | null };
export interface ScatterPlugin {
pathBuilder: Series.PathBuilder;
/** Hover by disc rather than by nearest x: mode 2 has no shared x to scan. */
cursor: uPlot.Cursor;
hooks: {
drawClear: (u: uPlot) => void;
destroy: (u: uPlot) => void;
};
getHit: () => ScatterHit | null;
}
export function createScatterPlugin({
pointSize = DEFAULT_SCATTER_POINT_SIZE,
hoverTolerance = DEFAULT_HOVER_TOLERANCE_PX,
}: ScatterPluginOptions = {}): ScatterPlugin {
let tree: Quadtree<ScatterHit> | null = null;
let hit: ScatterHit | null = null;
// Spans every series; cached per dataset.
let cachedData: uPlot.AlignedData | null = null;
let cachedDomain: SizeDomain | null = null;
function getSizeDomain(u: uPlot): SizeDomain | null {
if (cachedData !== u.data) {
cachedDomain = resolveSizeDomain(u.data);
cachedData = u.data;
}
return cachedDomain;
}
const pathBuilder: Series.PathBuilder = (u, seriesIdx, idx0, idx1) => {
const path = new Path2D();
const sizes = (u.data[seriesIdx] as unknown as ScatterSeriesData)[2];
const domain = getSizeDomain(u);
const { pxRatio } = uPlot;
uPlot.orient(
u,
seriesIdx,
(
_series,
dataX,
dataY,
scaleX,
scaleY,
valToPosX,
valToPosY,
xOff,
yOff,
xDim,
yDim,
_moveTo,
_lineTo,
_rect,
arc,
) => {
const xMin = scaleX.min ?? -Infinity;
const xMax = scaleX.max ?? Infinity;
const yMin = scaleY.min ?? -Infinity;
const yMax = scaleY.max ?? Infinity;
for (let i = idx0; i <= idx1; i++) {
const x = dataX[i];
const y = dataY[i];
if (
x == null ||
y == null ||
x < xMin ||
x > xMax ||
y < yMin ||
y > yMax
) {
continue;
}
const diameter =
resolvePointDiameter(sizes?.[i], domain, pointSize) * pxRatio;
const radius = diameter / 2;
const cx = valToPosX(x, scaleX, xDim, xOff);
const cy = valToPosY(y, scaleY, yDim, yOff);
path.moveTo(cx + radius, cy);
arc(path, cx, cy, radius, 0, TWO_PI);
tree?.add({
x: cx - radius - u.bbox.left,
y: cy - radius - u.bbox.top,
w: diameter,
h: diameter,
seriesIndex: seriesIdx,
dataIndex: i,
});
}
},
);
return { stroke: path, fill: path, clip: null };
};
const cursor: uPlot.Cursor = {
// Selection would set the dashboard time range; neither axis is time here.
drag: { x: false, y: false, setScale: false },
dataIdx: (u, seriesIdx): number | null => {
// uPlot asks series 1..n in order on every cursor move; resolve once.
if (seriesIdx === 1) {
const { left = -1, top = -1 } = u.cursor;
const { pxRatio } = uPlot;
hit =
tree && left >= 0 && top >= 0
? resolveHit(
tree,
left * pxRatio,
top * pxRatio,
hoverTolerance * pxRatio,
)
: null;
}
return hit?.seriesIndex === seriesIdx ? hit.dataIndex : null;
},
points: {
bbox: (_u, seriesIdx): uPlot.BBox => {
if (hit?.seriesIndex !== seriesIdx) {
return HIDDEN_BBOX;
}
const { pxRatio } = uPlot;
const inset = HOVER_RING_GAP_PX + HOVER_RING_WIDTH_PX;
const [left, width] = snapToPixel(
hit.x / pxRatio - inset,
hit.w / pxRatio + inset * 2,
);
const [top, height] = snapToPixel(
hit.y / pxRatio - inset,
hit.h / pxRatio + inset * 2,
);
return { left, top, width, height };
},
fill: (): string => 'transparent',
stroke: getSeriesStroke,
width: (): number => HOVER_RING_WIDTH_PX,
},
// uPlot only measures series that returned a data index, i.e. the hit one.
focus: { prox: DEFAULT_FOCUS_PROXIMITY_VALUE, dist: (): number => 0 },
};
return {
pathBuilder,
cursor,
hooks: {
drawClear: (u: uPlot): void => {
tree = new Quadtree<ScatterHit>(0, 0, u.bbox.width, u.bbox.height);
// The tree holds only what the path builder drew, so force a rebuild.
u.series.forEach((series, index) => {
if (index > 0) {
(series as SeriesWithPaths)._paths = null;
}
});
},
destroy: (): void => {
tree = null;
hit = null;
cachedData = null;
cachedDomain = null;
},
},
getHit: (): ScatterHit | null => hit,
};
}
export function applyScatterPlugin(
builder: UPlotConfigBuilder,
plugin: ScatterPlugin,
): void {
builder.setMode(PlotMode.Faceted);
builder.setCursor(plugin.cursor);
builder.addHook('drawClear', plugin.hooks.drawClear);
builder.addHook('destroy', plugin.hooks.destroy);
}

View File

@@ -0,0 +1,54 @@
import type { QuadtreeRect } from '../../utils/quadtree';
/** Diameters in CSS pixels. `min`/`max` bound the area scale when a size column is mapped. */
export interface ScatterPointSize {
fixed: number;
min: number;
max: number;
}
export const DEFAULT_SCATTER_POINT_SIZE: ScatterPointSize = {
fixed: 6,
min: 4,
max: 24,
};
/** CSS pixels around a point's disc that still register as a hover. */
export const DEFAULT_HOVER_TOLERANCE_PX = 3;
export interface ScatterPluginOptions {
pointSize?: ScatterPointSize;
hoverTolerance?: number;
}
/** Parallel columns; a `null` size draws at `fixed`. */
export type ScatterSeriesData = [
xs: number[],
ys: number[],
sizes?: Array<number | null>,
];
/** Mode-2 data: series 0 is uPlot's x placeholder and carries nothing. */
export type ScatterChartData = [null, ...ScatterSeriesData[]];
/** A drawn point's disc, in canvas pixels relative to the plot area. */
export interface ScatterHit extends QuadtreeRect {
seriesIndex: number;
dataIndex: number;
}
export interface ScatterChannel {
label: string;
unit?: string;
}
export interface ScatterChannels {
x: ScatterChannel;
y: ScatterChannel;
size?: ScatterChannel;
}
export interface ScatterPointLabel {
key: string;
value: string;
}

View File

@@ -0,0 +1,52 @@
import type uPlot from 'uplot';
import { buildClickData } from '../utils';
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
getFocusedSeriesAtPosition: jest.fn(() => ({ seriesIndex: 1 })),
}));
const event = {
offsetX: 10,
offsetY: 20,
clientX: 110,
clientY: 220,
} as MouseEvent;
describe('buildClickData', () => {
it('skips the x-index lookups on a faceted plot, which has no shared x series', () => {
const posToIdx = jest.fn(() => {
throw new Error('faceted data[0] is null');
});
const plot = {
data: [null],
posToVal: (pos: number): number => pos * 2,
posToIdx,
} as unknown as uPlot;
expect(buildClickData(event, plot)).toStrictEqual({
xValue: 20,
yValue: 40,
focusedSeries: null,
clickedDataTimestamp: 20,
mouseX: 10,
mouseY: 20,
absoluteMouseX: 110,
absoluteMouseY: 220,
});
expect(posToIdx).not.toHaveBeenCalled();
});
it('resolves the focused series and clicked timestamp on an aligned plot', () => {
const plot = {
data: [[100, 200, 300]],
posToVal: (pos: number): number => pos,
posToIdx: (): number => 1,
} as unknown as uPlot;
expect(buildClickData(event, plot)).toMatchObject({
focusedSeries: { seriesIndex: 1 },
clickedDataTimestamp: 200,
});
});
});

View File

@@ -174,6 +174,24 @@ export function createLayoutObserver(
export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
const xValue = plot.posToVal(event.offsetX, 'x');
const yValue = plot.posToVal(event.offsetY, 'y');
const pointer = {
mouseX: event.offsetX,
mouseY: event.offsetY,
absoluteMouseX: event.clientX,
absoluteMouseY: event.clientY,
};
// Faceted (scatter): no shared x, so `posToIdx` would throw.
if (plot.data[0] == null) {
return {
xValue,
yValue,
focusedSeries: null,
clickedDataTimestamp: xValue,
...pointer,
};
}
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
const dataIndex = plot.posToIdx(event.offsetX);
@@ -193,9 +211,6 @@ export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
yValue,
focusedSeries,
clickedDataTimestamp,
mouseX: event.offsetX,
mouseY: event.offsetY,
absoluteMouseX: event.clientX,
absoluteMouseY: event.clientY,
...pointer,
};
}

View File

@@ -0,0 +1,66 @@
import type uPlot from 'uplot';
import { logScaleSplits, spacedLogLabels } from '../logGridSplits';
describe('logScaleSplits', () => {
const splitsFor = (min: number, max: number): number[] =>
(
logScaleSplits as (
u: uPlot,
axisIdx: number,
min: number,
max: number,
) => number[]
)({} as uPlot, 0, min, max);
it('splits at 1, 2 and 5 × 10ⁿ inside an unsnapped range', () => {
expect(splitsFor(10.6, 990)).toStrictEqual([20, 50, 100, 200, 500]);
});
it('includes the ends when they fall on a split', () => {
expect(splitsFor(0.1, 10)).toStrictEqual([0.1, 0.2, 0.5, 1, 2, 5, 10]);
});
it('steps evenly through a range too narrow for two 1/2/5 splits', () => {
expect(splitsFor(7.6, 9.4)).toStrictEqual([8, 9]);
expect(splitsFor(8.12, 8.19)).toStrictEqual([
8.12, 8.13, 8.14, 8.15, 8.16, 8.17, 8.18, 8.19,
]);
});
it('is empty for a range a log scale cannot hold', () => {
expect(splitsFor(0, 10)).toStrictEqual([]);
expect(splitsFor(5, 5)).toStrictEqual([]);
});
});
describe('spacedLogLabels', () => {
// 100px per decade.
const plot = {
axes: [{ scale: 'x' }],
valToPos: (value: number): number => Math.log10(value) * 100,
} as unknown as uPlot;
const label = (splits: number[], space: number): (number | null)[] =>
spacedLogLabels(plot, splits, 0, space, 0) as (number | null)[];
it('always labels the powers of ten', () => {
expect(label([10, 20, 50, 100], 200)).toStrictEqual([10, null, null, 100]);
});
it('adds a 2 or 5 that clears the labels already kept', () => {
// 20 is 30px from 10; 50 is 70px from 10 and 30px from 100.
expect(label([10, 20, 50, 100], 25)).toStrictEqual([10, 20, 50, 100]);
expect(label([10, 20, 50, 100], 40)).toStrictEqual([10, null, null, 100]);
});
it('labels both ends of a range spanning under a decade', () => {
// 100 sits at 200px; 20 (130px) and 500 (270px) clear it, 50 and 200 do not.
expect(label([20, 50, 100, 200, 500], 60)).toStrictEqual([
20,
null,
100,
null,
500,
]);
});
});

View File

@@ -0,0 +1,64 @@
import { Quadtree, QuadtreeRect } from '../quadtree';
interface Item extends QuadtreeRect {
id: number;
}
function collect(
tree: Quadtree<Item>,
x: number,
y: number,
w: number,
h: number,
): Set<number> {
const ids = new Set<number>();
tree.get(x, y, w, h, (item) => ids.add(item.id));
return ids;
}
describe('Quadtree', () => {
it('returns items in the queried region and not those far from it', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
tree.add({ id: 1, x: 10, y: 10, w: 5, h: 5 });
tree.add({ id: 2, x: 80, y: 80, w: 5, h: 5 });
// Below the split threshold every item is visited; callers refine the hit.
expect(collect(tree, 9, 9, 8, 8)).toStrictEqual(new Set([1, 2]));
});
it('splits past the object limit and still finds every item', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
const total = 50;
for (let id = 0; id < total; id++) {
tree.add({ id, x: (id % 10) * 10, y: Math.floor(id / 10) * 10, w: 4, h: 4 });
}
expect(collect(tree, 0, 0, 100, 100).size).toBe(total);
});
it('after a split, a query in one quadrant skips items confined to another', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
for (let id = 0; id < 20; id++) {
// All in the north-west quadrant.
tree.add({ id, x: 1 + id, y: 1, w: 2, h: 2 });
}
tree.add({ id: 99, x: 90, y: 90, w: 2, h: 2 });
const northWest = collect(tree, 0, 0, 10, 10);
expect(northWest.has(99)).toBe(false);
expect(collect(tree, 85, 85, 10, 10).has(99)).toBe(true);
});
it('reports an item straddling the midline from either side', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
for (let id = 0; id < 20; id++) {
tree.add({ id, x: 1, y: 1 + id, w: 2, h: 2 });
}
tree.add({ id: 99, x: 48, y: 48, w: 4, h: 4 });
expect(collect(tree, 40, 40, 5, 5).has(99)).toBe(true);
expect(collect(tree, 55, 55, 5, 5).has(99)).toBe(true);
// A zero-size query exactly on both midlines.
expect(collect(tree, 50, 50, 0, 0).has(99)).toBe(true);
});
});

View File

@@ -195,3 +195,22 @@ describe('scale utils', () => {
});
});
});
describe('symmetric log scale', () => {
it('maps to uPlot arcsinh with the given linear threshold', () => {
expect(
scaleUtils.getDistributionConfig({
time: false,
distr: DistributionType.SymmetricLog,
asinhThreshold: 0.01,
}),
).toStrictEqual({ distr: 4, log: 10, asinh: 0.01 });
expect(
scaleUtils.getDistributionConfig({
time: false,
distr: DistributionType.SymmetricLog,
}).asinh,
).toBe(scaleUtils.DEFAULT_ASINH_THRESHOLD);
});
});

View File

@@ -0,0 +1,92 @@
import type uPlot from 'uplot';
const DECADE_TOLERANCE = 1e-9;
function isDecade(value: number): boolean {
if (value === 0) {
return true;
}
const exponent = Math.log10(Math.abs(value));
return Math.abs(exponent - Math.round(exponent)) < DECADE_TOLERANCE;
}
const LOG_SPLIT_MULTIPLES = [1, 2, 5];
/** Decades finer than the range's own span that the fallback steps through. */
const FALLBACK_STEP_DECADES = 3;
/** Rounded through toPrecision so 0.1 × 3 noise can't fall outside [min, max]. */
function roundSplit(value: number): number {
return Number(value.toPrecision(12));
}
/** Evenly stepped splits for a range too narrow to hold two 1/2/5 splits. */
function narrowRangeSplits(min: number, max: number): number[] {
const coarsest = Math.floor(Math.log10(max - min));
for (
let exponent = coarsest;
exponent > coarsest - FALLBACK_STEP_DECADES;
exponent--
) {
const step = 10 ** exponent;
const first = Math.ceil(roundSplit(min / step));
const last = Math.floor(roundSplit(max / step));
if (last > first) {
return Array.from({ length: last - first + 1 }, (_, index) =>
roundSplit((first + index) * step),
);
}
}
return [];
}
/** 1, 2, 5 × 10ⁿ in range; uPlot's log splits step from an unsnapped min (10.79, 21.58…). */
export const logScaleSplits: uPlot.Axis.Splits = (_u, _axisIdx, min, max) => {
if (!(min > 0) || !(max > min)) {
return [];
}
const splits: number[] = [];
for (
let exponent = Math.floor(Math.log10(min));
exponent <= Math.ceil(Math.log10(max));
exponent++
) {
LOG_SPLIT_MULTIPLES.forEach((multiple) => {
const split = roundSplit(multiple * 10 ** exponent);
if (split >= min && split <= max) {
splits.push(split);
}
});
}
if (splits.length >= 2) {
return splits;
}
const narrow = narrowRangeSplits(min, max);
return narrow.length > splits.length ? narrow : splits;
};
/** Labels every decade, plus any 2 or 5 at least `space` px clear. */
export const spacedLogLabels: uPlot.Axis.Filter = (
u,
splits,
axisIdx,
space,
) => {
const scaleKey = u.axes[axisIdx]?.scale ?? 'x';
const positions = splits.map((split) =>
split == null ? null : u.valToPos(split, scaleKey),
);
const kept = splits.map((split) => split != null && isDecade(split));
const isClear = (index: number): boolean =>
kept.every(
(isKept, other) =>
!isKept ||
Math.abs((positions[other] ?? 0) - (positions[index] ?? 0)) >= space,
);
splits.forEach((split, index) => {
if (split != null && !kept[index] && isClear(index)) {
kept[index] = true;
}
});
return splits.map((split, index) => (kept[index] ? split : null));
};

View File

@@ -0,0 +1,101 @@
export interface QuadtreeRect {
x: number;
y: number;
w: number;
h: number;
}
const MAX_OBJECTS = 10;
const MAX_LEVELS = 4;
/** Rect index for cursor hit-testing; items straddling quadrants are stored in each, so `get` may repeat them. */
export class Quadtree<T extends QuadtreeRect = QuadtreeRect> {
private items: T[] = [];
private quadrants: Quadtree<T>[] | null = null;
constructor(
private readonly x: number,
private readonly y: number,
private readonly w: number,
private readonly h: number,
private readonly level = 0,
) {}
add(item: T): void {
if (this.quadrants) {
this.forEachQuadrant(item, (quadrant) => quadrant.add(item));
return;
}
this.items.push(item);
if (this.items.length > MAX_OBJECTS && this.level < MAX_LEVELS) {
this.split();
const items = this.items;
this.items = [];
for (const existing of items) {
this.forEachQuadrant(existing, (quadrant) => quadrant.add(existing));
}
}
}
/** Visits every item whose quadrant overlaps the rectangle; callers refine the test. */
get(
x: number,
y: number,
w: number,
h: number,
visit: (item: T) => void,
): void {
for (const item of this.items) {
visit(item);
}
if (this.quadrants) {
this.forEachQuadrant({ x, y, w, h }, (quadrant) =>
quadrant.get(x, y, w, h, visit),
);
}
}
private split(): void {
const w = this.w / 2;
const h = this.h / 2;
const level = this.level + 1;
// North-east, north-west, south-west, south-east.
this.quadrants = [
new Quadtree<T>(this.x + w, this.y, w, h, level),
new Quadtree<T>(this.x, this.y, w, h, level),
new Quadtree<T>(this.x, this.y + h, w, h, level),
new Quadtree<T>(this.x + w, this.y + h, w, h, level),
];
}
private forEachQuadrant(
rect: QuadtreeRect,
visit: (quadrant: Quadtree<T>) => void,
): void {
if (!this.quadrants) {
return;
}
const midX = this.x + this.w / 2;
const midY = this.y + this.h / 2;
const startsNorth = rect.y < midY;
const startsWest = rect.x < midX;
// Inclusive, so a zero-size query on a midline still reaches a quadrant.
const endsEast = rect.x + rect.w >= midX;
const endsSouth = rect.y + rect.h >= midY;
if (startsNorth && endsEast) {
visit(this.quadrants[0]);
}
if (startsWest && startsNorth) {
visit(this.quadrants[1]);
}
if (startsWest && endsSouth) {
visit(this.quadrants[2]);
}
if (endsEast && endsSouth) {
visit(this.quadrants[3]);
}
}
}

View File

@@ -58,18 +58,23 @@ function normalizeLogLimit(
return logBase ** exp;
}
export const DEFAULT_ASINH_THRESHOLD = 1;
/**
* Returns uPlot scale distribution options for the Y axis.
* Time (X) scale gets no distr/log; Y scale gets distr 1 (linear) or 3 (log) and log base 2 or 10.
* Returns uPlot scale distribution options for a value axis.
* Time scales get no distr/log; value scales get distr 1 (linear), 3 (log) or
* 4 (arcsinh, uPlot's symmetric log) and log base 2 or 10.
*/
export function getDistributionConfig({
time,
distr,
logBase,
asinhThreshold,
}: {
time: ScaleProps['time'];
distr?: DistributionType;
logBase?: number;
asinhThreshold?: number;
}): Partial<Scale> {
if (time) {
return {};
@@ -77,6 +82,14 @@ export function getDistributionConfig({
const resolvedLogBase = (logBase ?? 10) === 2 ? 2 : 10;
if (distr === DistributionType.SymmetricLog) {
return {
distr: 4,
log: resolvedLogBase,
asinh: asinhThreshold ?? DEFAULT_ASINH_THRESHOLD,
};
}
return {
distr: distr === DistributionType.Logarithmic ? 3 : 1,
log: resolvedLogBase,

View File

@@ -37,6 +37,7 @@ export default function ChartWrapper({
syncKey,
syncFilterMode,
onDestroy = noop,
plotRef,
children,
layoutChildren,
yAxisUnit,
@@ -123,6 +124,7 @@ export default function ChartWrapper({
height={chartHeight}
plotRef={(plot): void => {
plotInstanceRef.current = plot;
plotRef?.(plot);
}}
onDestroy={(plot: uPlot): void => {
plotInstanceRef.current = null;

View File

@@ -0,0 +1,45 @@
import { render } from 'tests/test-utils';
import type uPlot from 'uplot';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import ChartWrapper from '../ChartWrapper';
const FAKE_PLOT = { id: 'plot' } as unknown as uPlot;
jest.mock('lib/uPlotV2/components/UPlotChart/UPlotChart', () => ({
__esModule: true,
default: ({ plotRef }: { plotRef?: (plot: uPlot | null) => void }): null => {
plotRef?.(FAKE_PLOT);
return null;
},
}));
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('ChartWrapper', () => {
it('hands the plot instance to the caller', () => {
const plotRef = jest.fn();
render(
<ChartWrapper
config={new UPlotConfigBuilder({ id: 'chart' })}
data={[[1], [2]]}
width={400}
height={300}
legendConfig={{ position: LegendPosition.BOTTOM }}
showLegend={false}
showTooltip={false}
plotRef={plotRef}
/>,
);
expect(plotRef).toHaveBeenCalledWith(FAKE_PLOT);
});
});

View File

@@ -0,0 +1,256 @@
import { useMemo } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import type { Threshold } from 'lib/uPlotV2/hooks/types';
import type { ScatterPointLabel } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import Scatter from './Scatter';
import { ScatterAxisScale, type ScatterSeries } from './types';
import { buildScatterConfig, prepareScatterChartData } from './utils';
const SERVICES = [
'frontend',
'cart',
'checkout',
'payment',
'shipping',
'currency',
'email',
'recommendation',
'ads',
'product-catalog',
];
type Shape = 'spread' | 'single' | 'sameX';
interface ScatterStoryProps {
groups: number;
pointsPerGroup: number;
/** Adds an error-count size column. */
sized: boolean;
xScale: ScatterAxisScale;
yScale: ScatterAxisScale;
/** Zeroes a share of y values, which forces the symmetric log. */
withZeros: boolean;
shape: Shape;
thresholds: boolean;
pointSize: number;
/** 0–1. */
fillOpacity: number;
width: number;
height: number;
}
/** Deterministic, so a story renders the same points on every run. */
function createRng(seed: number): () => number {
let state = seed >>> 0;
return (): number => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 2 ** 32;
};
}
function buildSeries({
groups,
pointsPerGroup,
sized,
withZeros,
shape,
}: ScatterStoryProps): ScatterSeries[] {
const rng = createRng(42);
return Array.from({ length: groups }, (_, groupIndex) => {
const label = SERVICES[groupIndex % SERVICES.length];
// Each service sits in its own throughput/latency band, so groups are telling
// apart rather than one cloud.
const baseThroughput = 20 * 2 ** (groupIndex % 5);
const baseLatency = 40 + 60 * (groupIndex % 4);
const count = shape === 'single' ? 1 : pointsPerGroup;
const xs: number[] = [];
const ys: number[] = [];
const sizes: Array<number | null> = [];
for (let i = 0; i < count; i++) {
const throughput =
shape === 'sameX' ? baseThroughput : baseThroughput * (0.5 + rng() * 1.5);
// Latency grows with load, plus noise; the odd outlier keeps the axis honest.
const outlier = rng() < 0.03 ? 4 + rng() * 6 : 1;
let latency =
baseLatency *
(0.8 + (throughput / baseThroughput) * 0.4 + rng() * 0.3) *
outlier;
if (withZeros && rng() < 0.2) {
latency = 0;
}
xs.push(Number(throughput.toFixed(2)));
ys.push(Number(latency.toFixed(2)));
sizes.push(rng() < 0.1 ? null : Math.round(rng() * rng() * 500));
}
return sized ? { label, xs, ys, sizes } : { label, xs, ys };
});
}
const Y_THRESHOLDS: Threshold[] = [
{
thresholdValue: 300,
thresholdUnit: 'ms',
thresholdColor: '#E5484D',
thresholdLabel: 'p99 SLO',
},
];
const X_THRESHOLDS: Threshold[] = [
{
thresholdValue: 60,
thresholdUnit: 'reqps',
thresholdColor: '#3E63DD',
thresholdLabel: 'Capacity',
},
];
function ScatterStory(props: ScatterStoryProps): JSX.Element {
const {
xScale,
yScale,
sized,
thresholds,
pointSize,
fillOpacity,
width,
height,
} = props;
const isDarkMode = useIsDarkMode();
const series = useMemo(() => buildSeries(props), [props]);
const config = useMemo(
() =>
buildScatterConfig({
id: 'scatter-story',
series,
isDarkMode,
x: {
unit: 'reqps',
scale: xScale,
thresholds: thresholds ? X_THRESHOLDS : undefined,
},
y: {
unit: 'ms',
scale: yScale,
thresholds: thresholds ? Y_THRESHOLDS : undefined,
},
pointSize: { fixed: pointSize, min: 4, max: pointSize * 4 },
fillOpacity,
}),
[series, isDarkMode, xScale, yScale, pointSize, fillOpacity, thresholds],
);
const data = useMemo(() => prepareScatterChartData(series), [series]);
const resolvePointGroupValues = (
seriesIndex: number,
dataIndex: number,
): ScatterPointLabel[] => [
{ key: 'service.name', value: series[seriesIndex - 1]?.label ?? '' },
{
key: 'k8s.pod.name',
value: `pod-${dataIndex.toString().padStart(3, '0')}`,
},
];
return (
<div style={{ width, padding: 16 }}>
<Scatter
config={config}
data={data}
width={width}
height={height}
legendConfig={{ position: LegendPosition.BOTTOM }}
channels={{
x: { label: 'Throughput', unit: 'reqps' },
y: { label: 'p99 latency', unit: 'ms' },
...(sized && { size: { label: 'Errors', unit: 'short' } }),
}}
resolvePointGroupValues={resolvePointGroupValues}
canPinTooltip
/>
</div>
);
}
const meta = {
title: 'Charts/Scatter',
component: ScatterStory,
parameters: { layout: 'padded' },
args: {
groups: 1,
pointsPerGroup: 10,
sized: false,
xScale: ScatterAxisScale.Linear,
yScale: ScatterAxisScale.Linear,
withZeros: false,
shape: 'spread',
thresholds: false,
pointSize: 6,
fillOpacity: 0.7,
width: 800,
height: 420,
},
argTypes: {
shape: { control: 'radio', options: ['spread', 'single', 'sameX'] },
xScale: { control: 'radio', options: Object.values(ScatterAxisScale) },
yScale: { control: 'radio', options: Object.values(ScatterAxisScale) },
fillOpacity: { control: { type: 'range', min: 0, max: 1, step: 0.05 } },
pointSize: { control: { type: 'range', min: 2, max: 16, step: 1 } },
},
} satisfies Meta<ScatterStoryProps>;
export default meta;
type Story = StoryObj<ScatterStoryProps>;
/** One service, ten points: axes formatted with units, hover picks the right point. */
export const Basic: Story = {};
/** Five services, one legend entry each; toggling a row hides its points. */
export const Grouped: Story = {
args: { groups: 5, pointsPerGroup: 40 },
};
/** Error count as disc area, between the configured min and max diameters. */
export const Sized: Story = {
args: { groups: 5, pointsPerGroup: 40, sized: true, pointSize: 5 },
};
/** Log x; a fifth of the latencies are 0, so y falls back to the symmetric log. */
export const LogAxes: Story = {
args: {
groups: 5,
pointsPerGroup: 60,
xScale: ScatterAxisScale.Log,
yScale: ScatterAxisScale.Log,
withZeros: true,
},
};
/** A single point still gets a padded range rather than an empty plot. */
export const SinglePoint: Story = {
args: { shape: 'single' },
};
/** Fifty points sharing one x collide on nothing: no shared x array to align. */
export const SameX: Story = {
args: { groups: 3, pointsPerGroup: 50, shape: 'sameX' },
};
/** Labelled lines on both axes; each scale stretches to include its line. */
export const Thresholds: Story = {
args: { groups: 3, pointsPerGroup: 40, thresholds: true },
};
/** Perf harness: raise `pointsPerGroup` and read the draw time under the chart. */
export const Dense: Story = {
args: { groups: 5, pointsPerGroup: 1000, pointSize: 4, fillOpacity: 0.5 },
};

View File

@@ -0,0 +1,98 @@
import { useCallback, useRef } from 'react';
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
import ScatterTooltip from 'lib/uPlotV2/components/Tooltip/ScatterTooltip';
import {
ScatterTooltipProps,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { getSeriesStroke } from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { ScatterChartProps } from 'lib/visualization/charts/types';
import uPlot from 'uplot';
import { getCursorHit } from './utils';
// Faceted uPlot can't mount without series; empty aligned data shows no-data instead.
const EMPTY_ALIGNED_DATA: uPlot.AlignedData = [[]];
export default function Scatter(props: ScatterChartProps): JSX.Element {
const {
children,
customTooltip,
channels,
resolvePointGroupValues,
pinnedTooltipElement,
onPointClick,
plotRef,
...rest
} = props;
const plotInstanceRef = useRef<uPlot | null>(null);
const handlePlotRef = useCallback(
(plot: uPlot | null): void => {
plotInstanceRef.current = plot;
plotRef?.(plot);
},
[plotRef],
);
// Faceted plots have no shared x; take the hit from the cursor.
const handleClick = useCallback(
(click: ChartClickData): void => {
const plot = plotInstanceRef.current;
const hit = plot ? getCursorHit(plot) : null;
if (!plot || !hit || !onPointClick) {
return;
}
onPointClick({
...hit,
color: getSeriesStroke(plot, hit.seriesIndex),
coordinates: { x: click.absoluteMouseX, y: click.absoluteMouseY },
});
},
[onPointClick],
);
const renderTooltip = useCallback(
(args: TooltipRenderArgs): React.ReactNode => {
if (customTooltip) {
return customTooltip(args);
}
const tooltipProps: ScatterTooltipProps = {
...args,
id: rest.config.getId(),
channels,
resolvePointGroupValues,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <ScatterTooltip {...tooltipProps} />;
},
[
customTooltip,
channels,
resolvePointGroupValues,
rest.config,
rest.decimalPrecision,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
);
const hasSeries = rest.data.length > 1;
return (
<ChartWrapper
{...rest}
data={hasSeries ? rest.data : EMPTY_ALIGNED_DATA}
plotRef={handlePlotRef}
onClick={onPointClick ? handleClick : rest.onClick}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>
{children}
</ChartWrapper>
);
}

View File

@@ -0,0 +1,112 @@
import { DistributionType } from 'lib/uPlotV2/config/types';
import { resolveAxisDistribution } from '../axisDistribution';
import { ScatterAxisScale } from '../types';
describe('resolveAxisDistribution', () => {
it('is linear when asked, whatever the values', () => {
expect(
resolveAxisDistribution([1, 1e6], ScatterAxisScale.Linear),
).toStrictEqual({ distribution: DistributionType.Linear });
});
it('is a plain log when every value is positive', () => {
expect(resolveAxisDistribution([1, 100], ScatterAxisScale.Log)).toStrictEqual(
{ distribution: DistributionType.Logarithmic },
);
});
it('falls back from log to a symmetric log around the smallest magnitude when zero is present', () => {
expect(
resolveAxisDistribution([0, 0.05, 300], ScatterAxisScale.Log),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 0.01,
});
});
it('keeps an explicit log axis for a soft min it cannot place', () => {
expect(
resolveAxisDistribution([1, 100], ScatterAxisScale.Log, [-10]),
).toStrictEqual({ distribution: DistributionType.Logarithmic });
});
it('is a symmetric log when asked, even with only positive values', () => {
expect(
resolveAxisDistribution([2, 300], ScatterAxisScale.SymLog),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 1,
});
});
it('uses a unit threshold when nothing is positive', () => {
expect(
resolveAxisDistribution([0, -5], ScatterAxisScale.SymLog),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 1,
});
});
describe('auto', () => {
const SKEWED = [1, 2, 3, 5, 8, 10, 20, 50, 1000];
it.each([
['skewed positive values', SKEWED, DistributionType.Logarithmic],
[
'values spread evenly across decades',
[1, 250, 500, 750, 1000],
DistributionType.Linear,
],
[
'a single outlier among clustered values',
[0.001, 40, 45, 50, 55, 60],
DistributionType.Linear,
],
[
'skewed values spanning under two decades',
[10, 10, 10, 11, 50],
DistributionType.Linear,
],
['no finite values', [Number.NaN], DistributionType.Linear],
])('picks a scale for %s', (_, values, distribution) => {
expect(resolveAxisDistribution(values, ScatterAxisScale.Auto)).toStrictEqual(
{ distribution },
);
});
it('picks symlog for skewed values that include zero', () => {
expect(
resolveAxisDistribution(
[0, 0, 1, 2, 5, 10, 50, 100, 10_000],
ScatterAxisScale.Auto,
),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 1,
});
});
it('picks symlog when a soft limit reaches zero or below', () => {
expect(
resolveAxisDistribution(SKEWED, ScatterAxisScale.Auto, [0]),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 1,
});
});
it('counts a soft limit towards the decades it spans', () => {
expect(
resolveAxisDistribution([5, 50], ScatterAxisScale.Auto, [null, 5000]),
).toStrictEqual({ distribution: DistributionType.Logarithmic });
});
it('is the default', () => {
expect(resolveAxisDistribution(SKEWED)).toStrictEqual({
distribution: DistributionType.Logarithmic,
});
});
});
});

View File

@@ -0,0 +1,113 @@
import type uPlot from 'uplot';
import { ScatterAxisScale, type ScatterSeries } from '../types';
import { buildScatterConfig } from '../utils';
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
getStoredSeriesVisibility: jest.fn(),
}));
const SERIES: ScatterSeries[] = [
{ label: 'cart', xs: [10, 20], ys: [100, 200], sizes: [1, null] },
{ label: 'checkout', xs: [30], ys: [0] },
];
describe('buildScatterConfig', () => {
const build = (
overrides: Partial<Parameters<typeof buildScatterConfig>[0]> = {},
): ReturnType<typeof buildScatterConfig> =>
buildScatterConfig({
id: 'scatter',
series: SERIES,
isDarkMode: true,
x: { unit: 'reqps' },
y: { unit: 'ms', scale: ScatterAxisScale.Log },
...overrides,
});
it('draws a line along both plot edges', () => {
const [xAxis, yAxis] = build().getConfig().axes ?? [];
expect(xAxis?.border?.show).toBe(true);
expect(yAxis?.border?.show).toBe(true);
});
it('titles each axis with its label, and leaves an unlabelled one bare', () => {
const config = build({
x: { unit: 'reqps', label: 'Throughput' },
y: { unit: 'ms', label: '' },
}).getConfig();
const [xAxis, yAxis] = config.axes ?? [];
expect(xAxis?.label).toBe('Throughput');
expect(yAxis?.label).toBeUndefined();
});
it('emits a faceted plot with two value scales', () => {
const config = build().getConfig();
expect(config.mode).toBe(2);
expect(config.scales?.x).toMatchObject({ time: false, distr: 1 });
// The y column has a 0, so log becomes the symmetric variant.
expect(config.scales?.y).toMatchObject({ time: false, distr: 4 });
});
it('draws one faceted series per group with the plugin path builder', () => {
const config = build().getConfig();
const [, cart, checkout] = config.series ?? [];
expect(config.series).toHaveLength(3);
expect(cart).toMatchObject({
label: 'cart',
facets: [
{ scale: 'x', auto: true },
{ scale: 'y', auto: true },
],
});
expect(typeof cart?.paths).toBe('function');
expect(cart?.paths).toBe(checkout?.paths);
expect(cart?.points?.show).toBe(false);
});
it('formats both axes with their units', () => {
const config = build().getConfig();
const [xAxis, yAxis] = config.axes ?? [];
expect(xAxis).toMatchObject({ scale: 'x', side: 2, space: 90 });
expect(yAxis).toMatchObject({ scale: 'y', side: 3 });
expect(typeof xAxis?.values).toBe('function');
expect(typeof yAxis?.values).toBe('function');
});
it('registers a y threshold draw hook when thresholds are given', () => {
const config = build({
y: {
unit: 'ms',
thresholds: [{ thresholdValue: 300, thresholdUnit: 'ms' }],
},
}).getConfig();
expect(config.hooks?.draw).toHaveLength(1);
expect(build().getConfig().hooks?.draw).toBeUndefined();
});
it('registers one draw hook per axis with thresholds', () => {
const config = build({
x: { unit: 'reqps', thresholds: [{ thresholdValue: 15 }] },
y: { unit: 'ms', thresholds: [{ thresholdValue: 300 }] },
}).getConfig();
expect(config.hooks?.draw).toHaveLength(2);
});
it('stretches the x range to reach an x threshold', () => {
const config = build({
x: { unit: 'reqps', thresholds: [{ thresholdValue: 1000 }] },
}).getConfig();
const range = config.scales?.x?.range as uPlot.Range.Function;
const plot = { bbox: { width: 0, height: 0 } } as uPlot;
const [, max] = range(plot, 10, 30, 'x');
expect(max).toBeGreaterThanOrEqual(1000);
});
});

View File

@@ -0,0 +1,98 @@
import { DistributionType } from 'lib/uPlotV2/config/types';
import uPlot from 'uplot';
import { createScatterRange } from '../scatterRange';
import type { ScatterRangeOptions } from '../types';
/** A 220px-tall, 420px-wide plot at a device pixel ratio of 1. */
const plot = { bbox: { width: 420, height: 220 } } as unknown as uPlot;
function rangeOf(
options: Partial<ScatterRangeOptions>,
dataMin: number | null,
dataMax: number | null,
): [number, number] {
const range = createScatterRange({
dimension: 'y',
marginPx: 10,
distribution: DistributionType.Linear,
...options,
}) as (u: uPlot, min: number | null, max: number | null) => [number, number];
return range(plot, dataMin, dataMax);
}
describe('createScatterRange', () => {
beforeAll(() => {
Object.defineProperty(uPlot, 'pxRatio', { value: 1, configurable: true });
});
it('widens a linear range so the data sits marginPx inside each edge', () => {
// 200px of data inside 220px: 10px at each end is 1/20 of the span.
expect(rangeOf({}, 0, 100)).toStrictEqual([-5, 105]);
});
it('measures the margin along its own axis', () => {
// 400px of data inside 420px.
expect(rangeOf({ dimension: 'x' }, 0, 100)).toStrictEqual([-2.5, 102.5]);
});
it('pads a log range in decades, without snapping to the next power of ten', () => {
const [min, max] = rangeOf(
{ distribution: DistributionType.Logarithmic },
1,
10,
);
expect(Math.log10(min)).toBeCloseTo(-0.05);
expect(Math.log10(max)).toBeCloseTo(1.05);
});
it('pads a symmetric log range in its asinh space', () => {
const [min, max] = rangeOf(
{ distribution: DistributionType.SymmetricLog, asinhThreshold: 2 },
0,
20,
);
const span = Math.asinh(10);
expect(Math.asinh(min / 2)).toBeCloseTo(-span / 20);
expect(Math.asinh(max / 2)).toBeCloseTo(span + span / 20);
});
it('stretches to the soft limits before padding', () => {
expect(rangeOf({ softMin: 0, softMax: 200 }, 50, 100)).toStrictEqual([
-10, 210,
]);
});
it('ignores a soft min at or below zero on a log scale', () => {
const [min] = rangeOf(
{ distribution: DistributionType.Logarithmic, softMin: 0 },
10,
100,
);
expect(min).toBeGreaterThan(0);
});
it('spreads a single value around itself', () => {
const [min, max] = rangeOf({}, 5, 5);
expect(min).toBeLessThan(5);
expect(max).toBeGreaterThan(5);
expect(5 - min).toBeCloseTo(max - 5);
});
it('falls back to a unit range with no data', () => {
expect(rangeOf({}, null, null)).toStrictEqual([0, 1]);
expect(
rangeOf({ distribution: DistributionType.Logarithmic }, null, null),
).toStrictEqual([1, 10]);
});
it('skips the margin on a plot too small to hold it', () => {
const tiny = { bbox: { width: 15, height: 15 } } as unknown as uPlot;
const range = createScatterRange({
dimension: 'y',
marginPx: 10,
distribution: DistributionType.Linear,
}) as (u: uPlot, min: number, max: number) => [number, number];
expect(range(tiny, 0, 100)).toStrictEqual([0, 100]);
});
});

View File

@@ -0,0 +1,51 @@
import type uPlot from 'uplot';
import type { ScatterSeries } from '../types';
import { getCursorHit, prepareScatterChartData } from '../utils';
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
getStoredSeriesVisibility: jest.fn(),
}));
const SERIES: ScatterSeries[] = [
{ label: 'cart', xs: [10, 20], ys: [100, 200], sizes: [1, null] },
{ label: 'checkout', xs: [30], ys: [0] },
];
describe('prepareScatterChartData', () => {
it('lays series out as facets behind an empty x slot', () => {
expect(prepareScatterChartData(SERIES)).toStrictEqual([
null,
[
[10, 20],
[100, 200],
[1, null],
],
[[30], [0]],
]);
});
});
describe('getCursorHit', () => {
const plotWith = (idxs: Array<number | null>): uPlot =>
({ cursor: { idxs } }) as unknown as uPlot;
it('reads the series the scatter plugin answered for', () => {
expect(getCursorHit(plotWith([null, null, 4, null]))).toStrictEqual({
seriesIndex: 2,
dataIndex: 4,
});
});
it('takes a data index of 0 as a hit', () => {
expect(getCursorHit(plotWith([0, 0]))).toStrictEqual({
seriesIndex: 1,
dataIndex: 0,
});
});
it('is null off every dot', () => {
expect(getCursorHit(plotWith([null, null]))).toBeNull();
expect(getCursorHit({ cursor: {} } as unknown as uPlot)).toBeNull();
});
});

View File

@@ -0,0 +1,130 @@
import { DistributionType } from 'lib/uPlotV2/config/types';
import { getScaleTransform } from './scatterRange';
import { type AxisDistribution, ScatterAxisScale } from './types';
const LINEAR_DISTRIBUTION: AxisDistribution = {
distribution: DistributionType.Linear,
};
/** Orders of magnitude `auto` needs before it considers leaving linear. */
export const AUTO_MIN_DECADES = 2;
/** How many times more axis the middle half of the points must cover for `auto` to leave linear. */
export const AUTO_SPREAD_GAIN = 2;
function getMagnitudeRange(values: number[]): {
minMagnitude: number;
maxMagnitude: number;
hasNonPositive: boolean;
} {
let minMagnitude = Infinity;
let maxMagnitude = -Infinity;
let hasNonPositive = false;
for (const value of values) {
if (!Number.isFinite(value)) {
continue;
}
if (value <= 0) {
hasNonPositive = true;
}
if (value !== 0) {
minMagnitude = Math.min(minMagnitude, Math.abs(value));
maxMagnitude = Math.max(maxMagnitude, Math.abs(value));
}
}
return { minMagnitude, maxMagnitude, hasNonPositive };
}
/** The linear band ends at the smallest non-zero magnitude so small values still spread. */
function symmetricLogDistribution(minMagnitude: number): AxisDistribution {
const asinhThreshold = Number.isFinite(minMagnitude)
? 10 ** Math.floor(Math.log10(minMagnitude))
: 1;
return { distribution: DistributionType.SymmetricLog, asinhThreshold };
}
function quantile(sorted: number[], q: number): number {
const position = (sorted.length - 1) * q;
const lower = Math.floor(position);
const upper = Math.ceil(position);
return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
}
/** Share of the axis the middle half of the points covers. */
function middleHalfSpread(
sorted: number[],
[min, max]: [number, number],
distribution: AxisDistribution,
): number {
const { forward } = getScaleTransform(distribution);
const span = forward(max) - forward(min);
return span > 0
? (forward(quantile(sorted, 0.75)) - forward(quantile(sorted, 0.25))) / span
: 0;
}
/**
* Log (symlog with zero or negatives) when it spreads the points out clearly
* more than linear does; min/max alone would let one outlier decide.
*/
function resolveAutoDistribution(
values: number[],
softLimits: number[],
): AxisDistribution {
const sorted = values
.filter((value) => Number.isFinite(value))
.sort((a, b) => a - b);
if (sorted.length < 2) {
return LINEAR_DISTRIBUTION;
}
const { minMagnitude, maxMagnitude, hasNonPositive } = getMagnitudeRange([
...sorted,
...softLimits,
]);
if (!(Math.log10(maxMagnitude / minMagnitude) >= AUTO_MIN_DECADES)) {
return LINEAR_DISTRIBUTION;
}
const candidate = hasNonPositive
? symmetricLogDistribution(minMagnitude)
: { distribution: DistributionType.Logarithmic };
const extent: [number, number] = [
Math.min(sorted[0], ...softLimits),
Math.max(sorted[sorted.length - 1], ...softLimits),
];
const candidateSpread = middleHalfSpread(sorted, extent, candidate);
const linearSpread = middleHalfSpread(sorted, extent, LINEAR_DISTRIBUTION);
return candidateSpread > 0 &&
candidateSpread >= AUTO_SPREAD_GAIN * linearSpread
? candidate
: LINEAR_DISTRIBUTION;
}
/** `log` falls back to symlog rather than drop zero or negatives; callers filter first to drop them. */
export function resolveAxisDistribution(
values: number[],
scale: ScatterAxisScale = ScatterAxisScale.Auto,
/** Soft limits and thresholds the axis must reach; only `auto` weighs them. */
softLimits: Array<number | null | undefined> = [],
): AxisDistribution {
switch (scale) {
case ScatterAxisScale.Linear:
return LINEAR_DISTRIBUTION;
case ScatterAxisScale.SymLog:
return symmetricLogDistribution(getMagnitudeRange(values).minMagnitude);
case ScatterAxisScale.Log: {
const { minMagnitude, hasNonPositive } = getMagnitudeRange(values);
return hasNonPositive
? symmetricLogDistribution(minMagnitude)
: { distribution: DistributionType.Logarithmic };
}
case ScatterAxisScale.Auto:
default:
return resolveAutoDistribution(
values,
softLimits.filter(
(limit): limit is number => limit != null && Number.isFinite(limit),
),
);
}
}

View File

@@ -0,0 +1,92 @@
import { DistributionType } from 'lib/uPlotV2/config/types';
import { DEFAULT_ASINH_THRESHOLD } from 'lib/uPlotV2/utils/scale';
import uPlot from 'uplot';
import type {
AxisDistribution,
ScaleTransform,
ScatterRangeOptions,
} from './types';
export function getScaleTransform({
distribution,
asinhThreshold = DEFAULT_ASINH_THRESHOLD,
}: AxisDistribution): ScaleTransform {
switch (distribution) {
case DistributionType.Logarithmic:
return {
forward: (value): number => Math.log10(value),
inverse: (position): number => 10 ** position,
};
case DistributionType.SymmetricLog:
return {
forward: (value): number => Math.asinh(value / asinhThreshold),
inverse: (position): number => Math.sinh(position) * asinhThreshold,
};
default:
return {
forward: (value): number => value,
inverse: (position): number => position,
};
}
}
/** In transformed units. */
const SINGLE_VALUE_HALF_SPAN = 0.5;
/** The data's extent stretched to the soft limits; null with neither. */
function resolveExtent(
dataMin: number | null,
dataMax: number | null,
softMin: number | null | undefined,
softMax: number | null | undefined,
): [number, number] | null {
const lows = [dataMin, softMin].filter(
(value): value is number => value != null && Number.isFinite(value),
);
const highs = [dataMax, softMax].filter(
(value): value is number => value != null && Number.isFinite(value),
);
return lows.length > 0 && highs.length > 0
? [Math.min(...lows), Math.max(...highs)]
: null;
}
/** Data extent padded by `marginPx` so edge dots draw whole; log stays unsnapped (snapping after the margin can add an empty decade). */
export function createScatterRange({
dimension,
marginPx,
softMin,
softMax,
...distribution
}: ScatterRangeOptions): uPlot.Scale.Range {
const transform = getScaleTransform(distribution);
const isLog = distribution.distribution === DistributionType.Logarithmic;
// A log scale cannot place a soft limit at or below zero.
const usableSoftMin = isLog && (softMin ?? 0) <= 0 ? null : softMin;
return (u, dataMin, dataMax): uPlot.Range.MinMax => {
const extent = resolveExtent(dataMin, dataMax, usableSoftMin, softMax);
if (!extent) {
return isLog ? [1, 10] : [0, 1];
}
let start = transform.forward(extent[0]);
let end = transform.forward(extent[1]);
if (start === end) {
start -= SINGLE_VALUE_HALF_SPAN;
end += SINGLE_VALUE_HALF_SPAN;
}
const plotPx =
(dimension === 'x' ? u.bbox.width : u.bbox.height) / uPlot.pxRatio;
if (plotPx > 2 * marginPx) {
const pad = ((end - start) * marginPx) / (plotPx - 2 * marginPx);
start -= pad;
end += pad;
}
return [transform.inverse(start), transform.inverse(end)];
};
}

View File

@@ -0,0 +1,67 @@
import type { PrecisionOption } from 'components/Graph/types';
import type {
DistributionType,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import type { Threshold } from 'lib/uPlotV2/hooks/types';
import type { ScatterPointSize } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
export interface ScatterSeries {
label: string;
xs: number[];
ys: number[];
/** Optional third channel, in the caller's units. */
sizes?: Array<number | null>;
}
export enum ScatterAxisScale {
/** Log or symlog when it spreads the points out more than linear does. */
Auto = 'auto',
Linear = 'linear',
Log = 'log',
/** Log-like, but places zero and negatives. */
SymLog = 'symlog',
}
export interface ScatterAxisOptions {
label?: string;
unit?: string;
softMin?: number | null;
softMax?: number | null;
scale?: ScatterAxisScale;
/** Lines drawn across the plot at values on this axis. */
thresholds?: Threshold[];
}
export interface BuildScatterConfigArgs {
id: string;
series: ScatterSeries[];
isDarkMode: boolean;
x: ScatterAxisOptions;
y: ScatterAxisOptions;
pointSize?: ScatterPointSize;
/** 0–1. */
fillOpacity?: number;
colorMapping?: Record<string, string>;
decimalPrecision?: PrecisionOption;
selectionPreferencesSource?: SelectionPreferencesSource;
shouldSaveSelectionPreference?: boolean;
}
export interface AxisDistribution {
distribution: DistributionType;
asinhThreshold?: number;
}
export interface ScaleTransform {
forward: (value: number) => number;
inverse: (position: number) => number;
}
export interface ScatterRangeOptions extends AxisDistribution {
dimension: 'x' | 'y';
/** CSS pixels kept clear at each end so the largest dot draws whole. */
marginPx: number;
softMin?: number | null;
softMax?: number | null;
}

View File

@@ -0,0 +1,209 @@
import {
type AxisProps,
DistributionType,
DrawStyle,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import {
applyScatterPlugin,
createScatterPlugin,
SCATTER_FACETS,
} from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
import {
DEFAULT_SCATTER_POINT_SIZE,
ScatterChartData,
ScatterSeriesData,
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import {
logScaleSplits,
spacedLogLabels,
} from 'lib/uPlotV2/utils/logGridSplits';
import { adjustSoftLimitsWithThresholds } from 'lib/uPlotV2/utils/scale';
import uPlot from 'uplot';
import { resolveAxisDistribution } from './axisDistribution';
import { createScatterRange } from './scatterRange';
import type {
AxisDistribution,
BuildScatterConfigArgs,
ScatterAxisOptions,
ScatterSeries,
} from './types';
const POINT_STROKE_WIDTH = 1;
/** Unit-suffixed x labels are wider than uPlot's 50px default assumes. */
const X_AXIS_TICK_SPACE_PX = 90;
const X_AXIS_END_LABEL_PADDING_PX = 40;
/** The plugin's `dataIdx` answers only for the hit series. */
export function getCursorHit(
plot: uPlot,
): { seriesIndex: number; dataIndex: number } | null {
const idxs = plot.cursor.idxs ?? [];
for (let seriesIndex = 1; seriesIndex < idxs.length; seriesIndex++) {
const dataIndex = idxs[seriesIndex];
if (dataIndex != null) {
return { seriesIndex, dataIndex };
}
}
return null;
}
/** `[null, [xs, ys, sizes?], …]`: uPlot's faceted layout, series 0 empty. */
export function prepareScatterChartData(
series: ScatterSeries[],
): uPlot.AlignedData {
const data: ScatterChartData = [
null,
...series.map((entry): ScatterSeriesData =>
entry.sizes ? [entry.xs, entry.ys, entry.sizes] : [entry.xs, entry.ys],
),
];
return data as unknown as uPlot.AlignedData;
}
/** The scatter range is unsnapped, so a log axis places and labels its own ticks. */
function getLogAxisTicks({
distribution,
}: AxisDistribution): Pick<AxisProps, 'splits' | 'filter'> {
return distribution === DistributionType.Logarithmic
? { splits: logScaleSplits, filter: spacedLogLabels }
: {};
}
function getThresholdOptions(
scaleKey: 'x' | 'y',
{ thresholds, unit }: ScatterAxisOptions,
): ThresholdsDrawHookOptions | undefined {
return thresholds && thresholds.length > 0
? { scaleKey, thresholds, yAxisUnit: unit }
: undefined;
}
export function buildScatterConfig({
id,
series,
isDarkMode,
x,
y,
pointSize = DEFAULT_SCATTER_POINT_SIZE,
fillOpacity,
colorMapping = {},
decimalPrecision,
selectionPreferencesSource,
shouldSaveSelectionPreference,
}: BuildScatterConfigArgs): UPlotConfigBuilder {
const builder = new UPlotConfigBuilder({
id,
selectionPreferencesSource,
shouldSaveSelectionPreference,
});
const plugin = createScatterPlugin({ pointSize });
applyScatterPlugin(builder, plugin);
// The last x label is centred on the plot's right edge; room for its unit.
builder.setPadding([16, X_AXIS_END_LABEL_PADDING_PX, 8, 8]);
const xSoftLimits = adjustSoftLimitsWithThresholds(
x.softMin ?? null,
x.softMax ?? null,
x.thresholds,
x.unit,
);
const ySoftLimits = adjustSoftLimitsWithThresholds(
y.softMin ?? null,
y.softMax ?? null,
y.thresholds,
y.unit,
);
const xDistribution = resolveAxisDistribution(
series.flatMap((entry) => entry.xs),
x.scale,
[xSoftLimits.softMin, xSoftLimits.softMax],
);
const yDistribution = resolveAxisDistribution(
series.flatMap((entry) => entry.ys),
y.scale,
[ySoftLimits.softMin, ySoftLimits.softMax],
);
const xThresholds = getThresholdOptions('x', x);
const yThresholds = getThresholdOptions('y', y);
// The largest disc drawn, plus its outline, kept clear of each plot edge.
const largestDiameter = series.some((entry) => entry.sizes)
? pointSize.max
: pointSize.fixed;
const marginPx = largestDiameter / 2 + POINT_STROKE_WIDTH;
builder.addScale({
scaleKey: 'x',
time: false,
range: createScatterRange({
dimension: 'x',
marginPx,
...xSoftLimits,
...xDistribution,
}),
...xDistribution,
});
builder.addScale({
scaleKey: 'y',
time: false,
range: createScatterRange({
dimension: 'y',
marginPx,
...ySoftLimits,
...yDistribution,
}),
...yDistribution,
});
builder.addAxis({
scaleKey: 'x',
side: 2,
label: x.label || undefined,
isDarkMode,
isTimeAxis: false,
yAxisUnit: x.unit ?? '',
decimalPrecision,
isLogScale: xDistribution.distribution !== DistributionType.Linear,
...getLogAxisTicks(xDistribution),
space: X_AXIS_TICK_SPACE_PX,
});
builder.addAxis({
scaleKey: 'y',
side: 3,
label: y.label || undefined,
isDarkMode,
yAxisUnit: y.unit ?? '',
decimalPrecision,
isLogScale: yDistribution.distribution !== DistributionType.Linear,
...getLogAxisTicks(yDistribution),
});
series.forEach((entry) => {
builder.addSeries({
scaleKey: 'y',
label: entry.label,
colorMapping,
drawStyle: DrawStyle.Scatter,
pathBuilder: plugin.pathBuilder,
facets: SCATTER_FACETS,
lineWidth: POINT_STROKE_WIDTH,
pointSize: pointSize.fixed,
fillOpacity,
isDarkMode,
});
});
[xThresholds, yThresholds].forEach((options) => {
if (options) {
builder.addThresholds(options);
}
});
return builder;
}

View File

@@ -16,6 +16,10 @@ import type {
HeatmapColorOptions,
HeatmapSeries,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import type {
ScatterChannels,
ScatterPointLabel,
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
@@ -88,6 +92,26 @@ export interface HistogramChartProps extends ChartWrapperProps {
isQueriesMerged?: boolean;
}
/** The dot a click landed on; `seriesIndex` is uPlot's, so the first series is 1. */
export interface ScatterPointClick {
seriesIndex: number;
dataIndex: number;
color: string;
/** Viewport coordinates of the click. */
coordinates: { x: number; y: number };
}
/** `data` is mode-2 (`prepareScatterChartData`); `config` comes from `buildScatterConfig`. */
export interface ScatterChartProps extends ChartWrapperProps {
channels: ScatterChannels;
resolvePointGroupValues?: (
seriesIndex: number,
dataIndex: number,
) => ScatterPointLabel[];
/** Fires for a click on a dot; a click on empty plot space fires nothing. */
onPointClick?: (click: ScatterPointClick) => void;
}
/**
* Data arrives as the query response carries it — bucket bounds plus one series per
* group — and the chart pivots and sums it, so no caller has to get the transpose

View File

@@ -178,3 +178,8 @@ export const JiraServiceManagementOps: Story = {
export const IncidentIO: Story = {
args: { channelType: 'incidentio' },
};
/** A Telegram channel, with its bot token masked behind the password field. */
export const Telegram: Story = {
args: { channelType: 'telegram' },
};

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import { expect, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -106,6 +106,39 @@ export const ReadOnly: Story = {
},
};
/**
* The same denial on a Telegram channel. Its fields are the kind the form gained
* last, including the only toggle a settings panel renders, so this is where a
* gap in the gating would show first.
*
* The switch is checked by behaviour, not by an attribute: it is a button inside
* the disabled fieldset, so it carries no `disabled` of its own and an
* attribute check would pass whether or not the browser actually turned it off.
*/
export const TelegramReadOnly: Story = {
args: { channelType: 'telegram', revoked: [UPDATE, CREATE, DELETE] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectDisabled(
await canvas.findByTestId(
'telegram-bot-token-textbox',
undefined,
PAGE_LOAD,
),
);
await expectDisabled(await canvas.findByTestId('telegram-chat-id-textbox'));
await expectDisabled(await canvas.findByTestId('telegram-message-textarea'));
const toggle = await canvas.findByTestId(
'telegram-disable-notifications-switch',
);
const before = toggle.getAttribute('aria-checked');
await userEvent.click(toggle);
await expect(toggle.getAttribute('aria-checked')).toBe(before);
},
};
/** No channel permission at all: the page is denied before the form exists. */
export const NoChannelAccess: Story = {
args: { revoked: NOTIFICATION_CHANNEL },

View File

@@ -178,6 +178,38 @@ export const IncidentIO: Story = {
},
};
/** The Telegram form: the bot token, the chat it posts to and the message. */
export const Telegram: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Telegram$/);
},
};
/**
* Telegram's own required fields. The chat id is the form's first numeric field,
* and the API treats 0 as missing, so an empty box has to report the same error
* as a blank one rather than sending a zero.
*/
export const TelegramRequiredFields: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^Telegram$/);
await userEvent.type(
await canvas.findByTestId('channel-name-textbox'),
'alerts-telegram',
);
await userEvent.click(await canvas.findByTestId('save-channel-button'));
await screen.findByText(
'Bot token is mandatory for creating a telegram channel',
);
await screen.findByText(
'Chat ID is mandatory for creating a telegram channel',
);
},
};
/**
* The fields the API rejects a save without are marked before anything is
* submitted, so a long form does not have to be guessed at.

View File

@@ -10,7 +10,7 @@ const FIND = { timeout: 5000 };
interface PrefillCase {
kind: string;
spec: Record<string, unknown>;
fields: [string, string][];
fields: [string, string | number][];
}
const CASES: PrefillCase[] = [
@@ -186,6 +186,25 @@ const CASES: PrefillCase[] = [
['incidentio-description-textarea', 'incident description'],
],
},
{
kind: 'telegram',
spec: {
botToken: '123456789:AA-token',
chatId: -1001234567890,
messageThreadId: 42,
title: 'telegram title',
message: 'telegram message',
apiUrl: 'https://api.telegram.org',
},
fields: [
['telegram-bot-token-textbox', '123456789:AA-token'],
['telegram-chat-id-textbox', -1001234567890],
['telegram-message-thread-id-textbox', 42],
['telegram-title-textarea', 'telegram title'],
['telegram-message-textarea', 'telegram message'],
['telegram-api-url-textbox', 'https://api.telegram.org'],
],
},
];
describe('ChannelForm prefill', () => {

View File

@@ -81,6 +81,7 @@ export const CHANNEL_TYPES = [
'jira',
'jsmops',
'incidentio',
'telegram',
] as const;
export type ChannelType = (typeof CHANNEL_TYPES)[number];
@@ -847,6 +848,13 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
title: '{{ .CommonLabels.alertname }}',
sendResolved: true,
},
telegram: {
botToken: '123456789:AA-story-telegram-token',
chatId: -1001234567890,
title: '{{ .CommonLabels.alertname }}',
message: '{{ range .Alerts -}}{{ .Annotations.description }}\n{{ end }}',
sendResolved: true,
},
};
/** The v2 fetch by id, which is what the redesigned screens read. */

View File

@@ -40,6 +40,8 @@ interface ConfigPaneProps {
legendSeries: LegendSeries[];
/** Table panel's resolved value columns, for the table-only editors. */
tableColumns: TableColumnOption[];
/** Group-by columns of the joined scalar result. */
groupColumns?: string[];
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
stepInterval?: number;
/**
@@ -67,6 +69,7 @@ function ConfigPane({
queryType,
legendSeries,
tableColumns,
groupColumns,
stepInterval,
panel,
panelId,
@@ -133,6 +136,7 @@ function ConfigPane({
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
@@ -160,6 +164,7 @@ function ConfigPane({
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}

View File

@@ -12,6 +12,11 @@ import type { SectionEditorContext } from '../sectionContext';
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
import { isDifferent } from '../utils/changes';
import {
getScatterAxisColumnNames,
getScatterAxisColumnUnits,
} from '../utils/scatterAxisColumns';
import { getSizeColumnLabel } from '../utils/sizeColumnLabel';
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
type SectionSlotProps = {
@@ -63,6 +68,7 @@ function SectionSlot({
bare,
legendSeries,
tableColumns,
groupColumns,
signal,
panelKind,
onChangePanelKind,
@@ -74,7 +80,10 @@ function SectionSlot({
const editor = resolveSectionEditor(config.kind);
// Controlled so the header slot can expand on click; list sections open when populated.
const [open, setOpen] = useState(() => {
if (config.kind === SectionKind.Visualization) {
if (
config.kind === SectionKind.Visualization ||
config.kind === SectionKind.Dimensions
) {
return true;
}
const value = editor?.get(spec);
@@ -129,6 +138,10 @@ function SectionSlot({
legendSeries,
yAxisUnit,
tableColumns,
groupColumns,
sizeColumnLabel: getSizeColumnLabel(spec, tableColumns),
axisColumnNames: getScatterAxisColumnNames(spec, tableColumns),
axisColumnUnits: getScatterAxisColumnUnits(spec, tableColumns),
signal,
panelKind,
onChangePanelKind,

View File

@@ -0,0 +1,49 @@
import { ComboboxSimple } from '@signozhq/ui/combobox';
import styles from './ConfigMultiSelect.module.scss';
export interface ConfigMultiSelectItem<T extends string = string> {
value: T;
label: string;
}
interface ConfigMultiSelectProps<T extends string = string> {
testId: string;
value: T[];
placeholder?: string;
items: ConfigMultiSelectItem<T>[];
onChange: (value: T[]) => void;
disabled?: boolean;
}
/** Emits picks in items order, so the stored value is stable. */
function ConfigMultiSelect<T extends string = string>({
testId,
value,
placeholder,
items,
onChange,
disabled,
}: ConfigMultiSelectProps<T>): JSX.Element {
return (
<ComboboxSimple
multiple
className={styles.select}
testId={testId}
value={value}
placeholder={placeholder}
disabled={disabled}
items={items}
onChange={(next): void => {
const picked = Array.isArray(next) ? next : [];
onChange(
items
.map((item) => item.value)
.filter((candidate) => picked.includes(candidate)),
);
}}
/>
);
}
export default ConfigMultiSelect;

View File

@@ -0,0 +1,48 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ConfigMultiSelect from '../ConfigMultiSelect';
const ITEMS = [
{ value: 'a', label: 'alpha' },
{ value: 'b', label: 'beta' },
{ value: 'c', label: 'gamma' },
];
async function open(): Promise<ReturnType<typeof userEvent.setup>> {
const user = userEvent.setup();
await user.click(screen.getByTestId('multi'));
return user;
}
describe('ConfigMultiSelect', () => {
it('shows the placeholder while nothing is picked', () => {
render(
<ConfigMultiSelect
testId="multi"
value={[]}
placeholder="Every group key"
items={ITEMS}
onChange={jest.fn()}
/>,
);
expect(screen.getByText('Every group key')).toBeInTheDocument();
});
it('keeps picks in item order, not the order they were picked', async () => {
const onChange = jest.fn();
render(
<ConfigMultiSelect
testId="multi"
value={['c']}
items={ITEMS}
onChange={onChange}
/>,
);
const user = await open();
await user.click(await screen.findByRole('option', { name: 'alpha' }));
expect(onChange).toHaveBeenLastCalledWith(['a', 'c']);
});
});

View File

@@ -0,0 +1,21 @@
.row {
display: flex;
align-items: center;
gap: 12px;
}
.slider {
flex: 1;
min-width: 0;
// Cancel the slider's half-thumb track inset so it aligns with the other controls.
margin-inline: calc(var(--slider-thumb-width, 18px) / -2);
}
.value {
flex-shrink: 0;
min-width: 56px;
text-align: right;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,47 @@
import { Slider } from '@signozhq/ui/slider';
import styles from './ConfigRangeSlider.module.scss';
interface ConfigRangeSliderProps {
testId: string;
value: [number, number];
min: number;
max: number;
step: number;
formatValue?: (value: [number, number]) => string;
onChange: (value: [number, number]) => void;
}
function ConfigRangeSlider({
testId,
value,
min,
max,
step,
formatValue,
onChange,
}: ConfigRangeSliderProps): JSX.Element {
return (
<div className={styles.row}>
<Slider
range
testId={testId}
className={styles.slider}
value={value}
min={min}
max={max}
step={step}
onChange={(next): void => {
if (Array.isArray(next) && next.length === 2) {
onChange([Math.min(next[0], next[1]), Math.max(next[0], next[1])]);
}
}}
/>
<span className={styles.value}>
{formatValue ? formatValue(value) : `${value[0]}–${value[1]}`}
</span>
</div>
);
}
export default ConfigRangeSlider;

View File

@@ -0,0 +1,51 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ConfigRangeSlider from '../ConfigRangeSlider';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('ConfigRangeSlider', () => {
it('renders a thumb per end and the formatted range', () => {
render(
<ConfigRangeSlider
testId="range"
value={[4, 24]}
min={2}
max={40}
step={1}
formatValue={([min, max]): string => `${min}–${max} px`}
onChange={jest.fn()}
/>,
);
expect(screen.getAllByRole('slider')).toHaveLength(2);
expect(screen.getByText('4–24 px')).toBeInTheDocument();
});
it('moves one end and reports the pair low to high', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ConfigRangeSlider
testId="range"
value={[4, 24]}
min={2}
max={40}
step={1}
onChange={onChange}
/>,
);
const [, maxThumb] = screen.getAllByRole('slider');
maxThumb.focus();
await user.keyboard('{ArrowRight}');
expect(onChange).toHaveBeenLastCalledWith([4, 25]);
});
});

View File

@@ -13,6 +13,14 @@ import { EQueryType } from 'types/common/dashboard';
export interface SectionEditorContext {
legendSeries?: LegendSeries[];
tableColumns?: TableColumnOption[];
/** Group-by labels of the joined scalar result; the colour-by options. */
groupColumns?: string[];
/** Scatter Plot's size column label, if bound. */
sizeColumnLabel?: string;
/** Name of the column each Scatter Plot axis plots, for its label's placeholder. */
axisColumnNames?: { x?: string; y?: string };
/** Unit of the column each Scatter Plot axis plots. */
axisColumnUnits?: { x?: string; y?: string };
signal?: TelemetrytypesSignalDTO;
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;

View File

@@ -4,6 +4,8 @@ import type {
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesScatterPlotAxesDTO,
DashboardtypesScatterPlotDimensionsDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
SectionKind,
@@ -22,9 +24,11 @@ import AxesSection from './sections/AxesSection/AxesSection';
import BucketsSection from './sections/BucketsSection/BucketsSection';
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
import DimensionsSection from './sections/DimensionsSection/DimensionsSection';
import FormattingSection from './sections/FormattingSection/FormattingSection';
import LegendSection from './sections/LegendSection/LegendSection';
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
import ScatterAxesSection from './sections/ScatterAxesSection/ScatterAxesSection';
import TextLayoutSection from './sections/TextLayoutSection/TextLayoutSection';
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
import VisualizationSection from './sections/VisualizationSection/VisualizationSection';
@@ -86,6 +90,20 @@ export const SECTION_REGISTRY: {
getPluginSlice<PanelAxesSlice>(spec, 'axes'),
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
},
// Same `axes` key as above, in Scatter Plot's per-axis shape.
[SectionKind.ScatterAxes]: {
Component: ScatterAxesSection,
get: (spec): DashboardtypesScatterPlotAxesDTO | undefined =>
getPluginSlice<DashboardtypesScatterPlotAxesDTO>(spec, 'axes'),
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
},
[SectionKind.Dimensions]: {
Component: DimensionsSection,
get: (spec): DashboardtypesScatterPlotDimensionsDTO | undefined =>
getPluginSlice<DashboardtypesScatterPlotDimensionsDTO>(spec, 'dimensions'),
update: (spec, dimensions): PanelSpec =>
updatePluginSlice(spec, 'dimensions', dimensions),
},
[SectionKind.Legend]: {
Component: LegendSection,
get: (spec): DashboardtypesLegendDTO | undefined =>

View File

@@ -14,6 +14,7 @@ import type { SectionEditorContext } from '../../sectionContext';
import { createFieldResetter } from '../../utils/changes';
import DisconnectValuesField from './DisconnectValuesField';
import HeatmapColorsField from './HeatmapColorsField';
import PointsFields from './PointsFields';
import {
FILL_MODE_OPTIONS,
FILLED_FILL_MODE_OPTIONS,
@@ -39,8 +40,9 @@ function ChartAppearanceSection({
controls,
onChange,
stepInterval,
sizeColumnLabel,
}: SectionEditorProps<SectionKind.ChartAppearance> &
Pick<SectionEditorContext, 'stepInterval'>): JSX.Element {
Pick<SectionEditorContext, 'stepInterval' | 'sizeColumnLabel'>): JSX.Element {
const reset = createFieldResetter(value, savedValue, onChange);
const interpolation = value?.lineInterpolation;
@@ -122,6 +124,15 @@ function ChartAppearanceSection({
/>
)}
{controls.points && (
<PointsFields
value={value?.points}
savedValue={savedValue?.points}
sizeColumnLabel={sizeColumnLabel}
onChange={(points): void => onChange({ ...value, points })}
/>
)}
{controls.spanGaps && (
<DisconnectValuesField
testId="panel-editor-v2-span-gaps"

View File

@@ -0,0 +1,86 @@
import type { DashboardtypesScatterPlotPointsDTO } from 'api/generated/services/sigNoz.schemas';
import {
POINT_OPACITY_BOUNDS,
POINT_SIZE_BOUNDS,
resolvePointOpacity,
resolvePointSize,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/ScatterPlotPanel/utils/points';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigRangeSlider from '../../controls/ConfigRangeSlider/ConfigRangeSlider';
import ConfigSlider from '../../controls/ConfigSlider/ConfigSlider';
import { createFieldResetter } from '../../utils/changes';
import { formatOpacity, formatPointSize, formatPointSizeRange } from './utils';
const OPACITY_STEP = 0.01;
interface PointsFieldsProps {
value: DashboardtypesScatterPlotPointsDTO | undefined;
savedValue: DashboardtypesScatterPlotPointsDTO | undefined;
onChange: (next: DashboardtypesScatterPlotPointsDTO) => void;
sizeColumnLabel?: string;
}
function PointsFields({
value,
savedValue,
onChange,
sizeColumnLabel,
}: PointsFieldsProps): JSX.Element {
const reset = createFieldResetter(value, savedValue, onChange);
// Renderer defaults, so thumbs start where an unset field draws.
const size = resolvePointSize(value);
return (
<>
{sizeColumnLabel ? (
<ConfigField
label="Point size"
help={`Each dot's area scales with ${sizeColumnLabel} between min and max.`}
{...reset('minSize', 'maxSize')}
>
<ConfigRangeSlider
testId="panel-editor-v2-point-size-range"
value={[size.min, size.max]}
min={POINT_SIZE_BOUNDS.min}
max={POINT_SIZE_BOUNDS.max}
step={1}
formatValue={formatPointSizeRange}
onChange={([minSize, maxSize]): void =>
onChange({ ...value, minSize, maxSize })
}
/>
</ConfigField>
) : (
<ConfigField
label="Point size"
help="Every dot uses this size. Map a Size column to draw a bubble chart."
{...reset('size')}
>
<ConfigSlider
testId="panel-editor-v2-point-size"
value={size.fixed}
min={POINT_SIZE_BOUNDS.min}
max={POINT_SIZE_BOUNDS.max}
step={1}
formatValue={formatPointSize}
onChange={(next): void => onChange({ ...value, size: next })}
/>
</ConfigField>
)}
<ConfigField label="Fill opacity" {...reset('opacity')}>
<ConfigSlider
testId="panel-editor-v2-point-opacity"
value={resolvePointOpacity(value)}
min={POINT_OPACITY_BOUNDS.min}
max={POINT_OPACITY_BOUNDS.max}
step={OPACITY_STEP}
formatValue={formatOpacity}
onChange={(opacity): void => onChange({ ...value, opacity })}
/>
</ConfigField>
</>
);
}
export default PointsFields;

View File

@@ -0,0 +1,114 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ChartAppearanceSection from '../ChartAppearanceSection';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('ChartAppearanceSection points', () => {
it('offers one point size when nothing sizes the dots', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ points: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-point-size')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-point-size-range'),
).not.toBeInTheDocument();
expect(screen.getByText('6 px')).toBeInTheDocument();
expect(
screen.getByText(
'Every dot uses this size. Map a Size column to draw a bubble chart.',
),
).toBeInTheDocument();
expect(screen.getByText('70%')).toBeInTheDocument();
});
it('offers a min/max range named after the size column when one is bound', () => {
render(
<ChartAppearanceSection
value={{ points: { minSize: 5 } }}
controls={{ points: true }}
sizeColumnLabel="B.count()"
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-point-size-range'),
).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-point-size'),
).not.toBeInTheDocument();
expect(screen.getByText('5–24 px')).toBeInTheDocument();
expect(
screen.getByText(
"Each dot's area scales with B.count() between min and max.",
),
).toBeInTheDocument();
});
it('steps the fixed size by a whole pixel, keeping the range', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ points: { size: 6, minSize: 4, maxSize: 30 } }}
controls={{ points: true }}
onChange={onChange}
/>,
);
const [sizeThumb] = screen.getAllByRole('slider');
sizeThumb.focus();
await user.keyboard('{ArrowRight}');
expect(onChange).toHaveBeenLastCalledWith({
points: { size: 7, minSize: 4, maxSize: 30 },
});
});
it('stops the opacity at 10%', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ points: { opacity: 0.1 } }}
controls={{ points: true }}
onChange={onChange}
/>,
);
const thumbs = screen.getAllByRole('slider');
const opacityThumb = thumbs[thumbs.length - 1];
expect(opacityThumb).toHaveAttribute('aria-valuemin', '0.1');
opacityThumb.focus();
await user.keyboard('{ArrowLeft}');
expect(onChange).not.toHaveBeenCalled();
});
it('leaves the points out of a kind that does not declare them', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ lineStyle: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.queryByTestId('panel-editor-v2-point-size'),
).not.toBeInTheDocument();
expect(screen.queryByText('Fill opacity')).not.toBeInTheDocument();
});
});

View File

@@ -12,3 +12,11 @@ export function defaultDisconnectDuration(stepInterval?: number): string {
export function formatOpacity(opacity: number): string {
return `${Math.round(opacity * 100)}%`;
}
export function formatPointSize(size: number): string {
return `${size} px`;
}
export function formatPointSizeRange([min, max]: [number, number]): string {
return `${min}–${max} px`;
}

View File

@@ -0,0 +1,24 @@
.section {
display: flex;
flex-direction: column;
gap: 18px;
}
.labelWithInfo {
display: inline-flex;
align-items: center;
gap: 6px;
}
.info {
flex-shrink: 0;
color: var(--l3-foreground);
cursor: help;
}
.description {
margin: 0;
color: var(--l2-foreground);
font-size: 12px;
line-height: 17px;
}

View File

@@ -0,0 +1,111 @@
import { useMemo } from 'react';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigMultiSelect from '../../controls/ConfigMultiSelect/ConfigMultiSelect';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import type { SectionEditorContext } from '../../sectionContext';
import { resolveAutoAxes } from '../../utils/scatterAxisColumns';
import LabelWithInfo from './LabelWithInfo';
import {
buildColorKeyItems,
buildDimensionItems,
formatAutoOption,
formatColumnOption,
UNSET_DIMENSION,
} from './utils';
import styles from './DimensionsSection.module.scss';
type DimensionsSectionProps = SectionEditorProps<SectionKind.Dimensions> &
Pick<SectionEditorContext, 'tableColumns' | 'groupColumns'>;
const FIELD_INFO = {
x: 'The value column that places each dot left to right. Auto takes the first value column.',
y: "The value column that places each dot bottom to top. Auto takes the first value column X doesn't use.",
sizeBy:
"Optional. Scales each dot's area by this value column; a group without a value draws at the default size.",
colorBy:
'One color and legend entry per combination of the selected keys. Leave it empty to color by every group key.',
};
function DimensionsSection({
value,
onChange,
tableColumns = [],
groupColumns = [],
}: DimensionsSectionProps): JSX.Element {
const valueItems = useMemo(
() =>
tableColumns.map((column) => ({
value: column.key,
label: formatColumnOption(column),
})),
[tableColumns],
);
const autoAxes = resolveAutoAxes(tableColumns, {
x: value?.x,
y: value?.y,
});
const colorBy = value?.colorBy ?? [];
const colorItems = buildColorKeyItems(groupColumns, colorBy);
const fields = [
{ dimension: 'x', label: 'X axis', unsetLabel: formatAutoOption(autoAxes.x) },
{ dimension: 'y', label: 'Y axis', unsetLabel: formatAutoOption(autoAxes.y) },
{ dimension: 'sizeBy', label: 'Size by', unsetLabel: 'None' },
] as const;
return (
<div className={styles.section}>
{fields.map(({ dimension, label, unsetLabel }) => (
<ConfigField
key={dimension}
label={
<LabelWithInfo
label={label}
info={FIELD_INFO[dimension]}
testId={`panel-editor-v2-dimension-${dimension}-info`}
/>
}
>
<ConfigSelect
testId={`panel-editor-v2-dimension-${dimension}`}
value={value?.[dimension] || UNSET_DIMENSION}
items={buildDimensionItems(valueItems, value?.[dimension], unsetLabel)}
onChange={(next): void => onChange({ ...value, [dimension]: next })}
/>
</ConfigField>
))}
<ConfigField
label={
<LabelWithInfo
label="Color by"
info={FIELD_INFO.colorBy}
testId="panel-editor-v2-dimension-colorBy-info"
/>
}
>
<ConfigMultiSelect
testId="panel-editor-v2-dimension-colorBy"
value={colorBy}
items={colorItems}
placeholder={
colorItems.length > 0 ? 'Every group key' : 'No group-by labels'
}
disabled={colorItems.length === 0}
onChange={(next): void => onChange({ ...value, colorBy: next })}
/>
</ConfigField>
<p className={styles.description}>
Each dot is one group from the query. X and Y place it, Size by scales it,
and Color by groups it in the legend.
</p>
</div>
);
}
export default DimensionsSection;

View File

@@ -0,0 +1,27 @@
import { Info } from '@signozhq/icons';
import { Tooltip } from '@signozhq/ui/tooltip';
import styles from './DimensionsSection.module.scss';
interface LabelWithInfoProps {
label: string;
info: string;
testId: string;
}
function LabelWithInfo({
label,
info,
testId,
}: LabelWithInfoProps): JSX.Element {
return (
<span className={styles.labelWithInfo}>
{label}
<Tooltip title={info}>
<Info size={12} className={styles.info} data-testid={testId} />
</Tooltip>
</span>
);
}
export default LabelWithInfo;

View File

@@ -0,0 +1,184 @@
import { render, screen, userEvent } from 'tests/test-utils';
import DimensionsSection from '../DimensionsSection';
const TABLE_COLUMNS = [
{ key: 'A', label: 'A.Request rate', name: 'Request rate' },
{ key: 'B.p99(duration_nano)', label: 'B.p99 latency', name: 'p99 latency' },
{ key: 'C.count()', label: 'C.count()', name: 'count()' },
];
async function pick(dimension: string, label: string): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId(`panel-editor-v2-dimension-${dimension}`);
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
await user.click(await screen.findByRole('option', { name: label }));
}
describe('DimensionsSection', () => {
it('names the column each unset axis falls back to', () => {
render(
<DimensionsSection
value={undefined}
onChange={jest.fn()}
tableColumns={TABLE_COLUMNS}
groupColumns={['service.name']}
/>,
);
expect(screen.getByTestId('panel-editor-v2-dimension-x')).toHaveTextContent(
'Auto (A · Request rate)',
);
expect(screen.getByTestId('panel-editor-v2-dimension-y')).toHaveTextContent(
'Auto (B.p99(duration_nano) · p99 latency)',
);
expect(
screen.getByTestId('panel-editor-v2-dimension-sizeBy'),
).toHaveTextContent('None');
});
it('falls Y back to a column other than the bound X', () => {
render(
<DimensionsSection
value={{ x: 'B.p99(duration_nano)' }}
onChange={jest.fn()}
tableColumns={TABLE_COLUMNS}
/>,
);
expect(screen.getByTestId('panel-editor-v2-dimension-y')).toHaveTextContent(
'Auto (A · Request rate)',
);
});
it('shows a column by its key alone when the name only repeats it', async () => {
const onChange = jest.fn();
render(
<DimensionsSection
value={undefined}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
/>,
);
await pick('sizeBy', 'C.count()');
expect(onChange).toHaveBeenCalledWith({ sizeBy: 'C.count()' });
});
it('binds an axis to a value column by its key', async () => {
const onChange = jest.fn();
render(
<DimensionsSection
value={{ x: 'A' }}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
/>,
);
await pick('y', 'B.p99(duration_nano) · p99 latency');
expect(onChange).toHaveBeenCalledWith({
x: 'A',
y: 'B.p99(duration_nano)',
});
});
it('clears the size back to none', async () => {
const onChange = jest.fn();
render(
<DimensionsSection
value={{ sizeBy: 'A' }}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
/>,
);
await pick('sizeBy', 'None');
expect(onChange).toHaveBeenCalledWith({ sizeBy: '' });
});
it('keeps a binding the results no longer have visible', () => {
render(
<DimensionsSection
value={{ x: 'D' }}
onChange={jest.fn()}
tableColumns={TABLE_COLUMNS}
/>,
);
expect(screen.getByTestId('panel-editor-v2-dimension-x')).toHaveTextContent(
'D (not in results)',
);
});
it('colours by any combination of group-by keys, kept in result order', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<DimensionsSection
value={{ colorBy: ['service.name'] }}
onChange={onChange}
groupColumns={['k8s.namespace.name', 'service.name']}
/>,
);
await user.click(screen.getByTestId('panel-editor-v2-dimension-colorBy'));
await user.click(
await screen.findByRole('option', { name: 'k8s.namespace.name' }),
);
expect(onChange).toHaveBeenLastCalledWith({
colorBy: ['k8s.namespace.name', 'service.name'],
});
});
it('explains every field from an info icon', () => {
render(<DimensionsSection value={undefined} onChange={jest.fn()} />);
['x', 'y', 'sizeBy', 'colorBy'].forEach((dimension) => {
expect(
screen.getByTestId(`panel-editor-v2-dimension-${dimension}-info`),
).toBeInTheDocument();
});
});
it('says what an empty colour selection means', () => {
render(
<DimensionsSection
value={undefined}
onChange={jest.fn()}
groupColumns={['service.name']}
/>,
);
expect(screen.getByText('Every group key')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-dimension-colorBy-info'),
).toBeInTheDocument();
});
it('keeps a selected colour key the results no longer have', () => {
render(
<DimensionsSection
value={{ colorBy: ['host.name'] }}
onChange={jest.fn()}
groupColumns={['service.name']}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-dimension-colorBy'),
).toHaveTextContent('host.name (not in results)');
});
it('disables colour when the query has no group by', () => {
render(<DimensionsSection value={undefined} onChange={jest.fn()} />);
expect(screen.getByText('No group-by labels')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-dimension-colorBy'),
).toHaveAttribute('aria-disabled', 'true');
});
});

View File

@@ -0,0 +1,45 @@
import type { ConfigMultiSelectItem } from '../../controls/ConfigMultiSelect/ConfigMultiSelect';
import type { ConfigSelectItem } from '../../controls/ConfigSelect/ConfigSelect';
import type { TableColumnOption } from '../../../hooks/useTableColumns';
/** The select value for an unset dimension; the spec stores it as `''`. */
export const UNSET_DIMENSION = '';
/** `A · Request rate`, or the key alone when the name only repeats it (`A.count()`). */
export function formatColumnOption({
key,
name,
}: Pick<TableColumnOption, 'key' | 'name'>): string {
return key === name || key.endsWith(`.${name}`) ? key : `${key} · ${name}`;
}
export function formatAutoOption(
column: TableColumnOption | undefined,
): string {
return column ? `Auto (${formatColumnOption(column)})` : 'Auto';
}
/** Keeps a bound key the result lacks, so the select never shows a bare value. */
export function buildDimensionItems(
columns: ConfigSelectItem[],
current: string | undefined,
unsetLabel: string,
): ConfigSelectItem[] {
const items = [{ value: UNSET_DIMENSION, label: unsetLabel }, ...columns];
if (current && !columns.some((column) => column.value === current)) {
items.push({ value: current, label: `${current} (not in results)` });
}
return items;
}
export function buildColorKeyItems(
groupColumns: string[],
selected: string[],
): ConfigMultiSelectItem[] {
return [
...groupColumns.map((column) => ({ value: column, label: column })),
...selected
.filter((key) => !groupColumns.includes(key))
.map((key) => ({ value: key, label: `${key} (not in results)` })),
];
}

View File

@@ -152,7 +152,7 @@ describe('FormattingSection', () => {
<FormattingSection
value={{ columnUnits: { A: 'By' } }}
controls={{ columnUnits: true }}
tableColumns={[{ key: 'A', label: 'A' }]}
tableColumns={[{ key: 'A', label: 'A', name: 'A' }]}
metricUnit="s"
onChange={jest.fn()}
/>,
@@ -166,7 +166,7 @@ describe('FormattingSection', () => {
<FormattingSection
value={{ columnUnits: { A: 's' } }}
controls={{ columnUnits: true }}
tableColumns={[{ key: 'A', label: 'A' }]}
tableColumns={[{ key: 'A', label: 'A', name: 'A' }]}
metricUnit="s"
onChange={jest.fn()}
/>,

View File

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

View File

@@ -0,0 +1,42 @@
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigField from '../../controls/ConfigField/ConfigField';
import type { SectionEditorContext } from '../../sectionContext';
import { createFieldResetter } from '../../utils/changes';
import ScatterAxisFields from './ScatterAxisFields';
import styles from './ScatterAxesSection.module.scss';
type ScatterAxesSectionProps = SectionEditorProps<SectionKind.ScatterAxes> &
Pick<SectionEditorContext, 'axisColumnNames'>;
function ScatterAxesSection({
value,
savedValue,
onChange,
axisColumnNames,
}: ScatterAxesSectionProps): JSX.Element {
const reset = createFieldResetter(value, savedValue, onChange);
return (
<div className={styles.axes}>
{(['x', 'y'] as const).map((axis) => (
<div key={axis} className={styles.axis}>
<ConfigField label={`${axis.toUpperCase()} axis`} {...reset(axis)}>
<ScatterAxisFields
axis={axis}
value={value?.[axis]}
columnName={axisColumnNames?.[axis]}
onChange={(next): void => onChange({ ...value, [axis]: next })}
/>
</ConfigField>
</div>
))}
</div>
);
}
export default ScatterAxesSection;

View File

@@ -0,0 +1,64 @@
import { Input } from '@signozhq/ui/input';
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
import ConfigInlineField from '../../controls/ConfigInlineField/ConfigInlineField';
import AxisFields from '../AxesSection/AxisFields';
import AxisRangeField from '../AxesSection/AxisRangeField';
import AxisScaleField from '../AxesSection/AxisScaleField';
import { LOG_RANGE_IGNORED_HELP, SCALE_HELP, SCALE_OPTIONS } from './options';
import { hasBoundALogAxisDrops } from './utils';
interface ScatterAxisFieldsProps {
axis: 'x' | 'y';
value: DashboardtypesScatterPlotAxisDTO | undefined;
onChange: (next: DashboardtypesScatterPlotAxisDTO) => void;
columnName?: string;
}
function ScatterAxisFields({
axis,
value,
onChange,
columnName,
}: ScatterAxisFieldsProps): JSX.Element {
const scale = value?.scale ?? DashboardtypesAxisScaleDTO.auto;
const name = axis.toUpperCase();
return (
<AxisFields>
<ConfigInlineField label="Label">
<Input
testId={`panel-editor-v2-${axis}-label`}
aria-label={`${name}-axis label`}
placeholder={columnName ?? 'None'}
value={value?.label ?? ''}
onChange={(event): void =>
onChange({ ...value, label: event.target.value })
}
/>
</ConfigInlineField>
<AxisRangeField
testIdPrefix={`panel-editor-v2-${axis}`}
value={value}
onChange={(bounds): void => onChange({ ...value, ...bounds })}
help={
hasBoundALogAxisDrops(scale, value) ? LOG_RANGE_IGNORED_HELP : undefined
}
helpTestId={`panel-editor-v2-${axis}-range-help`}
/>
<AxisScaleField
testId={`panel-editor-v2-${axis}-scale`}
aria-label={`${name}-axis scale`}
value={scale}
items={SCALE_OPTIONS}
help={SCALE_HELP}
onChange={(next): void => onChange({ ...value, scale: next })}
/>
</AxisFields>
);
}
export default ScatterAxisFields;

View File

@@ -0,0 +1,171 @@
import { DashboardtypesAxisScaleDTO } from 'api/generated/services/sigNoz.schemas';
import { render, screen, userEvent } from 'tests/test-utils';
import ScatterAxesSection from '../ScatterAxesSection';
describe('ScatterAxesSection', () => {
it('renders bounds and scale for both axes', () => {
render(<ScatterAxesSection value={undefined} onChange={jest.fn()} />);
['x', 'y'].forEach((axis) => {
expect(
screen.getByTestId(`panel-editor-v2-${axis}-soft-min`),
).toBeInTheDocument();
expect(
screen.getByTestId(`panel-editor-v2-${axis}-soft-max`),
).toBeInTheDocument();
expect(
screen.getByTestId(`panel-editor-v2-${axis}-scale`),
).toBeInTheDocument();
});
});
it('writes a soft bound to its own axis', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection
value={{ x: { scale: DashboardtypesAxisScaleDTO.log } }}
onChange={onChange}
/>,
);
await user.type(screen.getByTestId('panel-editor-v2-y-soft-max'), '5');
expect(onChange).toHaveBeenCalledWith({
x: { scale: DashboardtypesAxisScaleDTO.log },
y: { softMax: 5 },
});
});
it('shows the plotted column as each label placeholder', () => {
render(
<ScatterAxesSection
value={undefined}
onChange={jest.fn()}
axisColumnNames={{ x: 'Request rate', y: 'p99 latency' }}
/>,
);
expect(screen.getByTestId('panel-editor-v2-x-label')).toHaveAttribute(
'placeholder',
'Request rate',
);
expect(screen.getByTestId('panel-editor-v2-y-label')).toHaveAttribute(
'placeholder',
'p99 latency',
);
});
it('resets one axis without touching the other', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection
value={{
x: { scale: DashboardtypesAxisScaleDTO.log, label: 'Throughput' },
y: { scale: DashboardtypesAxisScaleDTO.symlog },
}}
savedValue={{}}
onChange={onChange}
/>,
);
const [resetX] = screen.getAllByTestId('config-field-reset');
await user.click(resetX);
expect(onChange).toHaveBeenCalledWith({
x: undefined,
y: { scale: DashboardtypesAxisScaleDTO.symlog },
});
});
it('says a log axis ignores a bound at or below zero', () => {
render(
<ScatterAxesSection
value={{
x: { scale: DashboardtypesAxisScaleDTO.log, softMin: -10 },
y: { scale: DashboardtypesAxisScaleDTO.symlog, softMin: -10 },
}}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-x-range-help')).toHaveTextContent(
"A log axis can't reach 0 or below",
);
expect(screen.getByTestId('panel-editor-v2-y-range-help')).toHaveTextContent(
'The axis always shows at least this range.',
);
});
it('flags a soft min above the soft max on that axis only', () => {
render(
<ScatterAxesSection
value={{ x: { softMin: 23, softMax: 12 }, y: { softMin: 1, softMax: 2 } }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-x-range-help')).toHaveTextContent(
"Min can't be greater than Max.",
);
expect(screen.getByTestId('panel-editor-v2-y-range-help')).toHaveTextContent(
'The axis always shows at least this range.',
);
});
it('writes an axis label', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<ScatterAxesSection value={undefined} onChange={onChange} />);
await user.type(screen.getByTestId('panel-editor-v2-y-label'), 'p');
expect(onChange).toHaveBeenCalledWith({ y: { label: 'p' } });
});
it('clears a soft bound to null when the field is emptied', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection value={{ x: { softMin: 10 } }} onChange={onChange} />,
);
await user.clear(screen.getByTestId('panel-editor-v2-x-soft-min'));
expect(onChange).toHaveBeenCalledWith({ x: { softMin: null } });
});
it.each([
DashboardtypesAxisScaleDTO.linear,
DashboardtypesAxisScaleDTO.log,
DashboardtypesAxisScaleDTO.symlog,
])('sets the x scale to %s', async (scale) => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<ScatterAxesSection value={undefined} onChange={onChange} />);
await user.click(screen.getByTestId(`panel-editor-v2-x-scale-${scale}`));
expect(onChange).toHaveBeenCalledWith({ x: { scale } });
});
it('marks a changed scale against the saved value and resets it', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection
value={{ y: { scale: DashboardtypesAxisScaleDTO.log } }}
savedValue={{ y: { scale: DashboardtypesAxisScaleDTO.auto } }}
onChange={onChange}
/>,
);
await user.click(screen.getByRole('button', { name: /reset/i }));
expect(onChange).toHaveBeenCalledWith({
y: { scale: DashboardtypesAxisScaleDTO.auto },
});
});
});

View File

@@ -0,0 +1,24 @@
import { DashboardtypesAxisScaleDTO } from 'api/generated/services/sigNoz.schemas';
import {
pickScaleOptions,
SCALE_HELP as AXIS_SCALE_HELP,
} from '../AxesSection/options';
export const SCALE_OPTIONS = pickScaleOptions([
DashboardtypesAxisScaleDTO.auto,
DashboardtypesAxisScaleDTO.linear,
DashboardtypesAxisScaleDTO.log,
DashboardtypesAxisScaleDTO.symlog,
]);
export const LOG_RANGE_IGNORED_HELP =
"A log axis can't reach 0 or below, so that bound is ignored. Use Symlog to include it.";
export const SCALE_HELP: Record<DashboardtypesAxisScaleDTO, string> = {
...AXIS_SCALE_HELP,
[DashboardtypesAxisScaleDTO.auto]:
'Logarithmic (Symlog with zero or negatives) when that spreads the points out more than linear does; otherwise linear.',
[DashboardtypesAxisScaleDTO.log]:
"Spreads out values that span orders of magnitude. Groups with a value of 0 or less can't be placed and are left out.",
};

View File

@@ -0,0 +1,16 @@
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
export function hasBoundALogAxisDrops(
scale: DashboardtypesAxisScaleDTO,
axis: DashboardtypesScatterPlotAxisDTO | undefined,
): boolean {
return (
scale === DashboardtypesAxisScaleDTO.log &&
[axis?.softMin, axis?.softMax].some(
(bound) => typeof bound === 'number' && bound <= 0,
)
);
}

View File

@@ -4,9 +4,10 @@ import { Button } from '@signozhq/ui/button';
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesComparisonThresholdDTO,
type DashboardtypesScatterPlotThresholdDTO,
type DashboardtypesTableThresholdDTO,
DashboardtypesThresholdAxisDTO,
DashboardtypesThresholdFormatDTO,
type DashboardtypesThresholdWithLabelDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
AnyThreshold,
@@ -44,6 +45,7 @@ const ADD_TESTID: Record<ThresholdVariant, string> = {
function defaultThreshold(
variant: ThresholdVariant,
tableColumns: TableColumnOption[],
withAxis: boolean,
): AnyThreshold {
switch (variant) {
case 'comparison':
@@ -62,18 +64,23 @@ function defaultThreshold(
format: DashboardtypesThresholdFormatDTO.background,
};
default:
return { value: 0, color: DEFAULT_THRESHOLD_COLOR, label: '' };
return {
value: 0,
color: DEFAULT_THRESHOLD_COLOR,
label: '',
...(withAxis && { axis: DashboardtypesThresholdAxisDTO.y }),
};
}
}
type ThresholdsSectionProps = {
value: AnyThreshold[] | undefined;
/** `variant` picks the row editor + element shape; defaults to `label`. */
controls?: { variant?: ThresholdVariant };
controls?: { variant?: ThresholdVariant; axis?: boolean };
onChange: (next: AnyThreshold[]) => void;
} & Pick<
SectionEditorContext,
'yAxisUnit' | 'tableColumns' | 'registerHeaderAction'
'yAxisUnit' | 'tableColumns' | 'axisColumnUnits' | 'registerHeaderAction'
>;
/**
@@ -88,9 +95,11 @@ function ThresholdsSection({
onChange,
yAxisUnit,
tableColumns = [],
axisColumnUnits,
registerHeaderAction,
}: ThresholdsSectionProps): JSX.Element {
const variant = controls?.variant ?? ThresholdVariant.LABEL;
const withAxis = !!controls?.axis;
const thresholds = value ?? [];
// Which row is being edited, and whether it was just added (so Discard removes it).
const [editingIndex, setEditingIndex] = useState<number | null>(null);
@@ -107,10 +116,10 @@ function ThresholdsSection({
const addThreshold = useCallback((): void => {
const current = value ?? [];
onChange([...current, defaultThreshold(variant, tableColumns)]);
onChange([...current, defaultThreshold(variant, tableColumns, withAxis)]);
setEditingIndex(current.length);
setUnsavedIndex(current.length);
}, [value, onChange, variant, tableColumns]);
}, [value, onChange, variant, tableColumns, withAxis]);
useEffect(() => {
registerHeaderAction?.(addThreshold);
@@ -189,7 +198,9 @@ function ThresholdsSection({
<LabelThresholdRow
// eslint-disable-next-line react/no-array-index-key
key={index}
threshold={threshold as DashboardtypesThresholdWithLabelDTO}
threshold={threshold as DashboardtypesScatterPlotThresholdDTO}
showAxis={withAxis}
axisUnits={axisColumnUnits}
{...common}
/>
);

View File

@@ -161,3 +161,52 @@ describe('ThresholdsSection', () => {
).not.toBeInTheDocument();
});
});
describe('ThresholdsSection with an axis picker', () => {
function AxisHarness({
onChange,
}: {
onChange: (next: AnyThreshold[]) => void;
}): JSX.Element {
const [value, setValue] = useState<AnyThreshold[]>([]);
return (
<ThresholdsSection
value={value}
controls={{ axis: true }}
axisColumnUnits={{ x: 'reqps', y: 'ms' }}
onChange={(next): void => {
setValue(next);
onChange(next);
}}
/>
);
}
it('adds a y threshold and saves the picked axis', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<AxisHarness onChange={onChange} />);
await user.click(screen.getByTestId('panel-editor-v2-add-threshold'));
expect(onChange).toHaveBeenLastCalledWith([
expect.objectContaining({ axis: 'y' }),
]);
await user.click(screen.getByTestId('threshold-axis-0-x'));
await user.click(screen.getByTestId('threshold-save-0'));
expect(onChange).toHaveBeenLastCalledWith([
expect.objectContaining({ axis: 'x' }),
]);
expect(screen.getByText('X axis')).toBeInTheDocument();
});
it('hides the axis picker without the control', async () => {
const user = userEvent.setup();
render(<Harness />);
await user.click(screen.getByTestId('panel-editor-v2-add-threshold'));
expect(screen.queryByTestId('threshold-axis-0')).not.toBeInTheDocument();
});
});

View File

@@ -1,10 +1,14 @@
import { useCallback } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardtypesThresholdWithLabelDTO } from 'api/generated/services/sigNoz.schemas';
import {
type DashboardtypesScatterPlotThresholdDTO,
DashboardtypesThresholdAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
import { formatPanelValue } from 'pages/DashboardPage/DashboardContainer/Panels/utils/formatPanelValue';
import ThresholdAmountField from './shared/ThresholdAmountField';
import ThresholdAxisField from './shared/ThresholdAxisField';
import ThresholdColorField from './shared/ThresholdColorField';
import ThresholdMarker from './shared/ThresholdMarker';
import ThresholdRowShell from './shared/ThresholdRowShell';
@@ -12,16 +16,22 @@ import { useThresholdDraft } from './shared/useThresholdDraft';
import styles from '../ThresholdsSection.module.scss';
/** `axis` is set only where the row picks one (Scatter Plot). */
type LabelThreshold = DashboardtypesScatterPlotThresholdDTO;
interface LabelThresholdRowProps {
index: number;
threshold: DashboardtypesThresholdWithLabelDTO;
threshold: LabelThreshold;
/** Panel formatting unit — scopes the unit picker to its category (V1 parity). */
yAxisUnit?: string;
/** Shows the axis picker; the unit picker then scopes to the picked axis's unit. */
showAxis?: boolean;
axisUnits?: { x?: string; y?: string };
isEditing: boolean;
isNew: boolean;
onEdit: () => void;
onSave: (next: DashboardtypesThresholdWithLabelDTO) => void;
onLiveChange: (next: DashboardtypesThresholdWithLabelDTO) => void;
onSave: (next: LabelThreshold) => void;
onLiveChange: (next: LabelThreshold) => void;
onDiscard: () => void;
onRemove: () => void;
}
@@ -31,6 +41,8 @@ function LabelThresholdRow({
index,
threshold,
yAxisUnit,
showAxis,
axisUnits,
isEditing,
isNew,
onEdit,
@@ -50,6 +62,17 @@ function LabelThresholdRow({
onSave({ ...draft, label: draft.label ?? '' });
}, [onSave, draft]);
const draftAxis = draft.axis ?? DashboardtypesThresholdAxisDTO.y;
const scopeUnit = showAxis ? axisUnits?.[draftAxis] : yAxisUnit;
const subtitle = showAxis
? [
`${(threshold.axis ?? DashboardtypesThresholdAxisDTO.y).toUpperCase()} axis`,
threshold.label,
]
.filter(Boolean)
.join(' · ')
: threshold.label;
return (
<ThresholdRowShell
index={index}
@@ -58,12 +81,19 @@ function LabelThresholdRow({
isEditing={isEditing}
isNew={isNew}
title={`Line at ${formatPanelValue(threshold.value, threshold.unit)}`}
subtitle={threshold.label}
subtitle={subtitle}
onEdit={onEdit}
onSave={handleSave}
onDiscard={onDiscard}
onRemove={onRemove}
>
{showAxis && (
<ThresholdAxisField
testId={`threshold-axis-${index}`}
value={draftAxis}
onChange={(axis): void => setDraft((d) => ({ ...d, axis }))}
/>
)}
<ThresholdAmountField
label="Draw a line at"
testIdPrefix="threshold"
@@ -71,8 +101,8 @@ function LabelThresholdRow({
value={draft.value}
onValueChange={setValue}
unit={draft.unit}
scopeUnit={yAxisUnit}
scopeLabel="y-axis unit"
scopeUnit={scopeUnit}
scopeLabel={`${showAxis ? draftAxis : 'y'}-axis unit`}
onUnitChange={(unit): void => setDraft((d) => ({ ...d, unit }))}
/>
<ThresholdColorField

View File

@@ -0,0 +1,41 @@
import { Typography } from '@signozhq/ui/typography';
import { DashboardtypesThresholdAxisDTO } from 'api/generated/services/sigNoz.schemas';
import ConfigTiles, {
type ConfigTileItem,
} from '../../../../controls/ConfigTiles/ConfigTiles';
import styles from '../../ThresholdsSection.module.scss';
const AXIS_TILES: ConfigTileItem<DashboardtypesThresholdAxisDTO>[] = [
{ value: DashboardtypesThresholdAxisDTO.x, label: 'X axis' },
{ value: DashboardtypesThresholdAxisDTO.y, label: 'Y axis' },
];
interface ThresholdAxisFieldProps {
testId: string;
value: DashboardtypesThresholdAxisDTO;
onChange: (axis: DashboardtypesThresholdAxisDTO) => void;
}
function ThresholdAxisField({
testId,
value,
onChange,
}: ThresholdAxisFieldProps): JSX.Element {
return (
<div className={styles.field}>
<Typography.Text className={styles.fieldLabel}>Axis</Typography.Text>
<ConfigTiles
compact
testId={testId}
aria-label="Axis"
value={value}
items={AXIS_TILES}
onChange={onChange}
/>
</div>
);
}
export default ThresholdAxisField;

View File

@@ -0,0 +1,63 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import {
getScatterAxisColumnNames,
getScatterAxisColumnUnits,
} from '../scatterAxisColumns';
const COLUMNS = [
{ key: 'A', label: 'A.Request rate', name: 'Request rate' },
{ key: 'B', label: 'B.p99 latency', name: 'p99 latency', unit: 'ms' },
{ key: 'C', label: 'C.Errors', name: 'Errors' },
];
const scatterSpec = (dimensions: {
x?: string;
y?: string;
}): DashboardtypesPanelSpecDTO =>
({
plugin: { kind: 'signoz/ScatterPlotPanel', spec: { dimensions } },
queries: [],
}) as unknown as DashboardtypesPanelSpecDTO;
describe('getScatterAxisColumnNames', () => {
it('names the first two value columns when nothing is bound', () => {
expect(getScatterAxisColumnNames(scatterSpec({}), COLUMNS)).toStrictEqual({
x: 'Request rate',
y: 'p99 latency',
});
});
it('follows the bound columns, with Y avoiding the column X took', () => {
expect(
getScatterAxisColumnNames(scatterSpec({ x: 'A', y: 'C' }), COLUMNS),
).toStrictEqual({ x: 'Request rate', y: 'Errors' });
expect(
getScatterAxisColumnNames(scatterSpec({ x: 'B' }), COLUMNS),
).toStrictEqual({ x: 'p99 latency', y: 'Request rate' });
});
it('is empty before results load and for other kinds', () => {
expect(getScatterAxisColumnNames(scatterSpec({}), [])).toStrictEqual({
x: undefined,
y: undefined,
});
expect(
getScatterAxisColumnNames(
{
plugin: { kind: 'signoz/TablePanel', spec: {} },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO,
COLUMNS,
),
).toStrictEqual({});
});
});
describe('getScatterAxisColumnUnits', () => {
it('reads the unit of the column each axis plots', () => {
expect(
getScatterAxisColumnUnits(scatterSpec({ x: 'B', y: 'A' }), COLUMNS),
).toStrictEqual({ x: 'ms', y: undefined });
});
});

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