Compare commits

..

53 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
202 changed files with 9021 additions and 1775 deletions

View File

@@ -105,13 +105,6 @@ is there to look at. Shooting two narrow runs and diffing those against each
other has the same problem. Either shoot the full set twice, or skip `--noise`
and explain the few movers by hand.
**`--noise` ignores how far a pair moved.** A pair is marked flaky when the
noise diff does not show it as unchanged, so a 70-pixel flicker in the noise run
hides a real 8% change in the real one. Compare the two numbers before trusting
the mark. When most of the flaky marks are hiding real changes, run the diff
without `--noise` and tag the genuinely noisy pairs by hand instead, so nothing
real is filtered out of the reviewer's view.
That is the trade: a second full capture costs about as long as the first, so on
a small diff it is often quicker to crop the two or three movers and say what
each one is. Use `--noise` when the diff is large, or when wiring `--fail-over`
@@ -145,15 +138,6 @@ eye.
- A full capture is hundreds of shots. Scope with `--title "Pages/Alerts"` or
`--stories "a,b,c"` (substring match on id or Title/Name, repeatable).
- **On macOS a capture can pin itself to one story at a time.** sbshot picks how
many to shoot at once by sampling free memory, and off Linux it reads
`os.freemem()`, which counts only truly free pages. macOS parks almost
everything in "inactive" instead, which is reclaimable on demand, so the
sampler can read ~100 MB on a 25 GB machine and shrink to one forever.
`--jobs` does not help: it is only an upper cap. Check `parallelism` in
`<run>/events.ndjson`; if it sits at 1 while `vm_stat` shows gigabytes
inactive, that is this. A 124-shot run takes about 90 minutes pinned and about
6 minutes at four at a time, so it is worth spotting before a long capture.
- A fresh worktree cannot build Storybook: `i18n-translations-hash.json` is
gitignored and generated. Run `node ./i18-generate-hash.cjs` in
`<worktree>/frontend`, and symlink `node_modules` from the main checkout.

View File

@@ -3783,6 +3783,13 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4525,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'
@@ -4540,6 +4548,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4553,6 +4562,7 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4638,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:
@@ -4717,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:
@@ -4905,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
@@ -5018,6 +5148,11 @@ components:
- value
- name
type: object
DashboardtypesThresholdAxis:
enum:
- x
- "y"
type: string
DashboardtypesThresholdFormat:
enum:
- text

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

@@ -12,7 +12,6 @@
"section_connection": "Connection",
"section_message": "Message",
"section_advanced": "Advanced",
"description_slack_advanced": "The rest of the Slack attachment. Every box here is optional.",
"section_recipients": "Recipients",
"field_channel_name": "Name",
"help_channel_name_immutable": "Renaming is blocked while alert rules and routing policies still reference a channel by name.",
@@ -187,32 +186,8 @@
"pager_details_invalid_json": "Enter valid JSON, or clear the box to send no details",
"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",
"tooltip_send_resolved": "Also notify this channel when an alert stops firing.",
"tooltip_channel_type": "Where the alert is delivered. It cannot be changed once the channel is saved.",
"button_discard_channel": "Discard",
"button_discard_confirm": "Discard",
"discard_changes_title": "Discard your changes?",
"discard_changes_body": "This form has unsaved changes. If you leave now, they are gone and the channel stays as it is.",
"tooltip_save_no_changes": "Nothing has changed yet.",
"entry_slack_field": "Field",
"entry_slack_action": "Action",
"entry_incidentio_metadata": "Entry",
"label_slack_field_title": "Title",
"tooltip_slack_field_title": "The bold label Slack shows above the value, for example Severity.",
"label_slack_field_value": "Value",
"tooltip_slack_field_value": "What Slack shows under the title. Templates are allowed, so `{{ .CommonLabels.severity }}` fills in from the alert.",
"label_incidentio_metadata_key": "Key",
"label_incidentio_metadata_value": "Value",
"tooltip_slack_fields": "Extra entries Slack renders as a small table inside the alert attachment.",
"tooltip_slack_actions": "Buttons Slack renders under the attachment. A button with a URL links out; the rest are for a Slack app to handle.",
"tooltip_slack_action_text": "The label on the button.",
"tooltip_slack_action_url": "Where the button sends the reader. Leave empty for a button a Slack app handles instead.",
"tooltip_slack_action_name": "Identifies the button to a Slack app. Not needed for a button that only links out.",
"tooltip_slack_action_value": "Sent to a Slack app along with the name, so one app can tell its buttons apart.",
"tooltip_slack_action_style": "default, primary or danger. Slack colours the button accordingly.",
"tooltip_slack_action_confirm": "Text for a confirmation prompt shown before the action runs. Leave empty for no prompt.",
"to_required": "To field 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.",

View File

@@ -5062,6 +5062,12 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export enum DashboardtypesAxisScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -5818,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
@@ -5828,7 +5954,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6810,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

@@ -15,12 +15,12 @@
.title {
font-size: 16px;
font-weight: 500;
color: var(--l1-foreground);
color: var(--text-vanilla-100);
}
.subtitle {
font-size: 14px;
color: var(--l2-foreground);
color: var(--text-vanilla-400);
max-width: 400px;
}

View File

@@ -9,18 +9,18 @@
}
.icon {
color: var(--l2-foreground);
color: var(--text-vanilla-400);
}
.title {
font-size: 16px;
font-weight: 500;
color: var(--l1-foreground);
color: var(--text-vanilla-100);
}
.subtitle {
font-size: 14px;
color: var(--l2-foreground);
color: var(--text-vanilla-400);
max-width: 400px;
}

View File

@@ -1,24 +0,0 @@
/* The space sits between the way back and the title, rather than above both of
them, so the two read as separate steps. */
.header {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-4);
}
.title {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-height: 26px;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--l1-foreground);
}
.divider {
--divider-color: var(--l1-border);
--divider-margin: 10px 0 16px 0;
}

View File

@@ -1,56 +0,0 @@
import { ReactNode } from 'react';
import { ArrowLeft } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Divider } from '@signozhq/ui/divider';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isModifierKeyPressed } from 'utils/app';
import styles from './BackHeader.module.scss';
export interface BackHeaderProps {
/** Where the page came from, named as the place rather than the action. */
backLabel: string;
backRoute: string;
title: ReactNode;
testId?: string;
}
/**
* A way back on its own line, then the page's own title. The two rows keep the
* title clear of whatever sits beside it, such as a copy button.
*/
function BackHeader({
backLabel,
backRoute,
title,
testId,
}: BackHeaderProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
return (
<>
<div className={styles.header} data-testid={testId}>
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={<ArrowLeft size={12} />}
onClick={(e: React.MouseEvent): void => {
safeNavigate(backRoute, { newTab: isModifierKeyPressed(e) });
}}
testId="back-link"
>
{backLabel}
</Button>
<div className={styles.title}>{title}</div>
</div>
<Divider className={styles.divider} />
</>
);
}
BackHeader.defaultProps = {
testId: undefined,
};
export default BackHeader;

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

@@ -2,7 +2,6 @@ import { TanStackHoverTooltip } from './TanStackHoverTooltip';
import { TanStackTableBase } from './TanStackTable';
import TanStackTableText from './TanStackTableText';
export { default as ColumnSelector } from './ColumnSelector';
export * from './TanStackHoverTooltip';
export * from './TanStackTableStateContext';
export * from './types';

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

@@ -101,16 +101,6 @@ const SPECS: Record<string, Record<string, unknown>> = {
metadata: { team: 'ops' },
sendResolved: true,
},
telegram: {
apiUrl: 'https://api.telegram.org',
botToken: '123456789:AA-token',
chatId: -1001234567890,
messageThreadId: 42,
title: 't',
message: 'm',
disableNotifications: true,
sendResolved: true,
},
};
describe('load then save round trip', () => {

View File

@@ -22,26 +22,13 @@
min-width: 0;
}
/* Laid out like the alert pages: discard on the left, test and save together on
the right. Sticky rather than fixed, so it stays inside the page's own scroll
instead of adding another offset over the bottom strip. */
.actions {
position: sticky;
bottom: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: var(--spacing-3);
padding: var(--spacing-4) 0;
border-top: 1px solid var(--l2-border);
background: var(--l1-background);
}
.actionGroup {
display: flex;
align-items: center;
gap: var(--spacing-3);
border-top: 1px solid var(--l1-border);
}
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
@@ -49,21 +36,3 @@
opacity: 0.5;
cursor: not-allowed;
}
/* The @signozhq inputs leave their surface transparent and take the card's
colour. antd paints its textareas instead, so beside them they read as a
different control, most visibly in light mode. */
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
.fields :global(textarea.ant-input),
.fields :global(textarea.ant-input:hover),
.fields :global(textarea.ant-input:focus),
.fields :global(textarea.ant-input-focused) {
background: var(--input-background, transparent);
border-width: var(--input-border-width, 1px);
border-style: var(--input-border-style, solid);
border-color: var(--input-border-color, var(--border));
border-radius: var(--input-border-radius, calc(var(--radius) - 2px));
font-size: var(--input-font-size, var(--periscope-font-size-base, 13px));
box-shadow: var(--input-box-shadow, none);
color: inherit;
}

View File

@@ -1,8 +1,8 @@
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
margin-bottom: var(--spacing-6);
gap: var(--spacing-1);
margin-bottom: var(--spacing-4);
}
.label {

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { Plus } from '@signozhq/icons';
import { Minus, Plus } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { Button } from '@signozhq/ui/button';
import { Input as AntdInput } from 'antd';
@@ -10,7 +10,6 @@ import { Typography } from '@signozhq/ui/typography';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
import RowEntry from './RowEntry';
import styles from './RowEditor.module.scss';
const { TextArea } = AntdInput;
@@ -140,19 +139,10 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
help={t('help_incidentio_metadata')}
>
{metadataRows.map((row, index) => (
<RowEntry
// rows have no stable identity beyond their position
// eslint-disable-next-line react/no-array-index-key
key={index}
index={index}
noun={t('entry_incidentio_metadata')}
removeLabel={t('button_incidentio_remove_metadata')}
removeTestId={`incidentio-metadata-remove-${index}`}
onRemove={(): void =>
syncMetadata(metadataRows.filter((_, i) => i !== index))
}
>
<FormField label={t('label_incidentio_metadata_key')}>
// rows have no stable identity beyond their position
// eslint-disable-next-line react/no-array-index-key
<div key={index} className={styles.row}>
<div className={styles.field}>
<Input
placeholder={t('placeholder_incidentio_metadata_key')}
value={row.key}
@@ -165,9 +155,8 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
}
data-testid={`incidentio-metadata-key-${index}`}
/>
</FormField>
<FormField label={t('label_incidentio_metadata_value')}>
</div>
<div className={styles.field}>
<Input
placeholder={t('placeholder_incidentio_metadata_value')}
value={row.value}
@@ -180,10 +169,22 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
}
data-testid={`incidentio-metadata-value-${index}`}
/>
</FormField>
</RowEntry>
</div>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('button_incidentio_remove_metadata')}
onClick={(): void =>
syncMetadata(metadataRows.filter((_, i) => i !== index))
}
testId={`incidentio-metadata-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
))}
<div className={styles.add}>
<Button
variant="dashed"

View File

@@ -1,10 +1,3 @@
/* One height for every control a repeating row renders, so the remove button,
the add button and the inputs beside them agree. */
.entry,
.add {
--row-control-height: 28px;
}
.row {
display: flex;
align-items: center;
@@ -30,9 +23,7 @@
}
.add {
/* Sits with the blocks it adds to, not against the next field's label. */
margin-top: var(--spacing-2);
--button-height: var(--row-control-height);
margin-top: var(--spacing-1);
}
@media (max-width: 720px) {
@@ -44,52 +35,3 @@
max-width: none;
}
}
/* One entry per block, every input labelled. A row of bare boxes gave no way to
tell which column was which once a value was typed. The block keeps a readable
width while the rest of the form fills the page, so its two columns do not
stretch into a row of six. */
.entry {
max-width: 760px;
border: 1px solid var(--border);
border-radius: var(--radius-2);
background: var(--l2-background);
padding: var(--spacing-6) var(--spacing-7);
margin-bottom: var(--spacing-4);
}
.entryTop {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-4);
/* The remove control is an icon button, so give it the height the design
system gives a small control rather than letting the glyph set it. */
--button-height: var(--row-control-height);
--button-icon-size: 14px;
}
.entryIndex {
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.entryGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0 var(--spacing-6);
}
/* The toggle reads as a sentence, so it takes the whole width rather than
sitting in a column sized for an input. */
.entryWide {
grid-column: 1 / -1;
}
@media (max-width: 720px) {
.entryGrid {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -1,50 +0,0 @@
import { ReactNode } from 'react';
import { Minus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './RowEditor.module.scss';
interface RowEntryProps {
/** Zero-based; shown to the reader as a count from one. */
index: number;
/** Singular noun for one entry, such as "Field" or "Action". */
noun: string;
removeLabel: string;
removeTestId: string;
onRemove: () => void;
children: ReactNode;
}
function RowEntry({
index,
noun,
removeLabel,
removeTestId,
onRemove,
children,
}: RowEntryProps): JSX.Element {
return (
<div className={styles.entry}>
<div className={styles.entryTop}>
<Typography.Text size="sm" className={styles.entryIndex}>
{noun} {index + 1}
</Typography.Text>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={removeLabel}
onClick={onRemove}
testId={removeTestId}
>
<Minus size={14} />
</Button>
</div>
<div className={styles.entryGrid}>{children}</div>
</div>
);
}
export default RowEntry;

View File

@@ -42,9 +42,7 @@ function Slack(): JSX.Element {
>
<Input {...register('channel')} data-testid="slack-channel-textbox" />
</FormField>
</FormCard>
<FormCard title={t('section_message')}>
<FormField label={t('field_slack_title')} error={errors.title?.message}>
<Controller
control={control}
@@ -84,12 +82,7 @@ function Slack(): JSX.Element {
)}
/>
</FormField>
</FormCard>
<FormCard
title={t('section_advanced')}
description={t('description_slack_advanced')}
>
<FormField
label={t('field_slack_color')}
help={t('help_slack_color')}

View File

@@ -1,13 +1,13 @@
import { useTranslation } from 'react-i18next';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { Plus } from '@signozhq/icons';
import { Minus, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { AlertmanagertypesChannelSlackActionDTO } from 'api/generated/services/sigNoz.schemas';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
import RowEntry from './RowEntry';
import styles from './RowEditor.module.scss';
/** `confirm` is an object on the DTO, so its one editable field is reached by path. */
@@ -29,37 +29,49 @@ function SlackActions(): JSX.Element {
const { control, register } = useFormContext<ChannelSpecFormValues>();
const { fields, append, remove } = useFieldArray({ control, name: 'actions' });
const keyFor = (column: ActionColumn): string => column.replace('.text', '');
const labelFor = (column: ActionColumn): string =>
t(`label_slack_action_${column.replace('.text', '')}`);
return (
<FormField
label={t('field_slack_actions')}
tooltip={t('tooltip_slack_actions')}
>
<FormField label={t('field_slack_actions')} help={t('help_slack_actions')}>
<div>
{fields.map((row, index) => (
<RowEntry
key={row.id}
index={index}
noun={t('entry_slack_action')}
removeLabel={t('remove_slack_action')}
removeTestId={`slack-action-remove-${index}`}
onRemove={(): void => remove(index)}
>
{fields.length > 0 && (
<div className={styles.row}>
{COLUMNS.map((column) => (
<FormField
key={column}
label={t(`label_slack_action_${keyFor(column)}`)}
tooltip={t(`tooltip_slack_action_${keyFor(column)}`)}
>
<div key={column} className={styles.field}>
<Typography.Text size="sm" className={styles.heading}>
{labelFor(column)}
</Typography.Text>
</div>
))}
</div>
)}
{fields.map((row, index) => (
<div key={row.id} className={styles.row}>
{COLUMNS.map((column) => (
<div key={column} className={styles.field}>
<Input
{...register(`actions.${index}.${column}` as const)}
placeholder={t(`placeholder_slack_action_${keyFor(column)}`)}
testId={`slack-action-${keyFor(column)}-${index}`}
placeholder={t(
`placeholder_slack_action_${column.replace('.text', '')}`,
)}
testId={`slack-action-${column.replace('.text', '')}-${index}`}
/>
</FormField>
</div>
))}
</RowEntry>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('remove_slack_action')}
onClick={(): void => remove(index)}
testId={`slack-action-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
))}
</div>

View File

@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { Plus } from '@signozhq/icons';
import { Minus, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Input } from '@signozhq/ui/input';
@@ -8,7 +8,6 @@ import { Typography } from '@signozhq/ui/typography';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormField from '../FormField';
import RowEntry from './RowEntry';
import styles from './RowEditor.module.scss';
// Slack renders these as the attachment's table of short or full-width entries.
@@ -18,61 +17,52 @@ function SlackFields(): JSX.Element {
const { fields, append, remove } = useFieldArray({ control, name: 'fields' });
return (
<FormField
label={t('field_slack_fields')}
tooltip={t('tooltip_slack_fields')}
>
<FormField label={t('field_slack_fields')} help={t('help_slack_fields')}>
<div>
{fields.map((row, index) => (
<RowEntry
key={row.id}
index={index}
noun={t('entry_slack_field')}
removeLabel={t('remove_slack_field')}
removeTestId={`slack-field-remove-${index}`}
onRemove={(): void => remove(index)}
>
<FormField
label={t('label_slack_field_title')}
tooltip={t('tooltip_slack_field_title')}
>
<div key={row.id} className={styles.row}>
<div className={styles.field}>
<Input
{...register(`fields.${index}.title`)}
placeholder={t('placeholder_slack_field_title')}
testId={`slack-field-title-${index}`}
/>
</FormField>
<FormField
label={t('label_slack_field_value')}
tooltip={t('tooltip_slack_field_value')}
>
</div>
<div className={styles.field}>
<Input
{...register(`fields.${index}.value`)}
placeholder={t('placeholder_slack_field_value')}
testId={`slack-field-value-${index}`}
/>
</FormField>
<div className={styles.entryWide}>
<Controller
control={control}
name={`fields.${index}.short`}
render={({ field }): JSX.Element => (
<Checkbox
color="primary"
value={!!field.value}
onChange={(checked): void => field.onChange(checked === true)}
testId={`slack-field-short-${index}`}
>
<Typography.Text size="sm">
{t('field_slack_field_short')}
</Typography.Text>
</Checkbox>
)}
/>
</div>
</RowEntry>
<Controller
control={control}
name={`fields.${index}.short`}
render={({ field }): JSX.Element => (
<Checkbox
color="primary"
value={!!field.value}
onChange={(checked): void => field.onChange(checked === true)}
testId={`slack-field-short-${index}`}
>
<Typography.Text size="sm">
{t('field_slack_field_short')}
</Typography.Text>
</Checkbox>
)}
/>
<Button
variant="ghost"
color="secondary"
size="sm"
icon
aria-label={t('remove_slack_field')}
onClick={(): void => remove(index)}
testId={`slack-field-remove-${index}`}
>
<Minus size={14} />
</Button>
</div>
))}
</div>

View File

@@ -24,7 +24,6 @@ describe('FormAlertChannels', () => {
onTypeChangeHandler={jest.fn()}
onSaveHandler={jest.fn()}
onTestHandler={jest.fn()}
onDiscardHandler={jest.fn()}
testingState={false}
savingState={false}
editing

View File

@@ -1,7 +1,6 @@
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { Controller, FormProvider, UseFormReturn } from 'react-hook-form';
import { X } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Switch } from '@signozhq/ui/switch';
@@ -49,12 +48,10 @@ function FormAlertChannels({
onTypeChangeHandler,
onTestHandler,
onSaveHandler,
onDiscardHandler,
savingState,
testingState,
editing = false,
readOnly = false,
hasChanges = true,
saveChecks = [],
testChecks = [],
}: FormAlertChannelsProps): JSX.Element {
@@ -118,10 +115,7 @@ function FormAlertChannels({
/>
</FormField>
<FormField
label={t('field_send_resolved')}
tooltip={t('tooltip_send_resolved')}
>
<FormField label={t('field_send_resolved')}>
<Controller
control={control}
name="sendResolved"
@@ -137,11 +131,7 @@ function FormAlertChannels({
/>
</FormField>
<FormField
label={t('field_channel_type')}
tooltip={t('tooltip_channel_type')}
htmlFor="channel-type"
>
<FormField label={t('field_channel_type')} htmlFor="channel-type">
<SelectSimple
id="channel-type"
items={CHANNEL_TYPE_OPTIONS}
@@ -158,45 +148,30 @@ function FormAlertChannels({
</fieldset>
<div className={styles.actions}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="discard-channel-button"
prefix={<X size={14} />}
onClick={onDiscardHandler}
>
{t('button_discard_channel')}
</Button>
<div className={styles.actionGroup}>
<AuthZTooltip checks={testChecks}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="test-channel-button"
loading={testingState}
onClick={onTestHandler}
>
{t('button_test_channel')}
</Button>
</AuthZTooltip>
<AuthZTooltip checks={saveChecks}>
<Button
variant="solid"
color="primary"
size="md"
testId="save-channel-button"
loading={savingState}
disabled={!hasChanges}
disabledTooltip={t('tooltip_save_no_changes')}
onClick={onSaveHandler}
>
{t('button_save_channel')}
</Button>
</AuthZTooltip>
</div>
<AuthZTooltip checks={saveChecks}>
<Button
variant="solid"
color="primary"
size="md"
testId="save-channel-button"
loading={savingState}
onClick={onSaveHandler}
>
{t('button_save_channel')}
</Button>
</AuthZTooltip>
<AuthZTooltip checks={testChecks}>
<Button
variant="outlined"
color="secondary"
size="md"
testId="test-channel-button"
loading={testingState}
onClick={onTestHandler}
>
{t('button_test_channel')}
</Button>
</AuthZTooltip>
</div>
</form>
</FormProvider>
@@ -209,15 +184,12 @@ interface FormAlertChannelsProps {
onTypeChangeHandler: (value: ChannelKind) => void;
onSaveHandler: () => void;
onTestHandler: () => void;
onDiscardHandler: () => void;
testingState: boolean;
savingState: boolean;
// editing indicates if the form is opened in edit mode
editing?: boolean;
/** The reader has `read` but not `update`, so the fields render disabled. */
readOnly?: boolean;
/** Nothing edited yet, so there is nothing to save and nothing to discard. */
hasChanges?: boolean;
/** Gates saving; a denial disables the button and names the missing scope. */
saveChecks?: BrandedPermission[];
/** Gates a test send, which the API allows with `create`. */
@@ -227,7 +199,6 @@ interface FormAlertChannelsProps {
FormAlertChannels.defaultProps = {
editing: undefined,
readOnly: false,
hasChanges: true,
saveChecks: [],
testChecks: [],
};

View File

@@ -3,21 +3,23 @@ import { Columns3 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import type { TableColumnDef } from 'components/TanStackTableView';
import {
hideColumn,
showColumn,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import styles from './ColumnSelector.module.scss';
import type { TableColumnDef } from './types';
import { hideColumn, showColumn, useHiddenColumnIds } from './useColumnStore';
interface ColumnSelectorProps<TData> {
columns: TableColumnDef<TData>[];
/** Shared with the table's `columnStorageKey`, which is what it writes to. */
storageKey: string;
testId?: string;
}
function ColumnSelector<TData>({
columns,
storageKey,
testId = 'column-selector-button',
}: ColumnSelectorProps<TData>): JSX.Element {
const hiddenColumnIds = useHiddenColumnIds(storageKey);
@@ -45,7 +47,7 @@ function ColumnSelector<TData>({
size="sm"
color="secondary"
prefix={<Columns3 size={14} />}
testId={testId}
testId="alert-columns-button"
>
Columns
</Button>
@@ -75,8 +77,4 @@ function ColumnSelector<TData>({
);
}
ColumnSelector.defaultProps = {
testId: 'column-selector-button',
};
export default ColumnSelector;

View File

@@ -1 +1,2 @@
export { default as ActionsMenu } from './ActionsMenu';
export { default as ColumnSelector } from './ColumnSelector';

View File

@@ -4,7 +4,7 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import useComponentPermission from 'hooks/useComponentPermission';
@@ -15,7 +15,7 @@ import { useTimezone } from 'providers/Timezone';
import TextToolTip from 'components/TextToolTip';
import { AlertsEmptyState } from './AlertsEmptyState/AlertsEmptyState';
import { ActionsMenu } from './components';
import { ActionsMenu, ColumnSelector } from './components';
import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
import styles from './ListAlertRules.module.scss';
import { getAlertRuleColumns } from './table.config';
@@ -134,11 +134,7 @@ function ListAlertRules(): JSX.Element {
{!isEmptyNoRules && (
<div className={styles.header}>
<div className={styles.refreshRow}>
<ColumnSelector
columns={columns}
storageKey="alert-rules-columns"
testId="alert-columns-button"
/>
<ColumnSelector columns={columns} storageKey="alert-rules-columns" />
{addNewAlert && (
<Button
variant="solid"

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

@@ -22,17 +22,12 @@ export function defineJsonEditorTheme(monaco: Monaco, el: HTMLElement): void {
const keyColor = token(el, '--bg-vanilla-400');
const valueColor = token(el, '--bg-robin-400');
// Both token colours are picked to read on a dark surface, so on a light one
// they wash out, the keys worst of all. Monaco's own `vs` palette is built
// for that surface, so let it keep its colours there, as the role viewer does.
const rules: { token: string; foreground: string }[] = [];
if (isDark(background)) {
if (keyColor) {
rules.push({ token: 'string.key.json', foreground: keyColor });
}
if (valueColor) {
rules.push({ token: 'string.value.json', foreground: valueColor });
}
if (keyColor) {
rules.push({ token: 'string.key.json', foreground: keyColor });
}
if (valueColor) {
rules.push({ token: 'string.value.json', foreground: valueColor });
}
const colors: Record<string, string> = {};

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

@@ -58,12 +58,6 @@ export const Default: Story = {};
/** A workspace with nowhere to send an alert yet. */
export const NoChannels: Story = {
args: { channels: 0 },
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByTestId('channels-empty-state', undefined, UNTIL_LOADED);
await canvas.findByTestId('channels-empty-create');
},
};
/** Data: the channel list's existing loading spinner. */
@@ -111,24 +105,10 @@ export const KindFilter: Story = {
UNTIL_LOADED,
);
await canvas.findByText('sre-email');
// the heading and the create button both name the filtered type
await canvas.findByText('All Email channels');
},
};
/** Interaction: the column menu, which turns table columns off and on. */
export const ColumnSelector: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
await userEvent.click(canvas.getByTestId('channels-columns'));
await screen.findByText('Toggle Columns');
},
};
/** Interaction: a search that matches no channel. */
/** Interaction: a search matching nothing offers to clear itself. */
export const NoResults: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
@@ -140,21 +120,6 @@ export const NoResults: Story = {
);
await canvas.findByText('No matching channels', undefined, UNTIL_LOADED);
},
};
/** Interaction: clearing the search from the empty state brings the list back. */
export const ClearSearch: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
await userEvent.type(
canvas.getByTestId('channels-search'),
'no-such-channel',
);
await canvas.findByText('No matching channels', undefined, UNTIL_LOADED);
await userEvent.click(canvas.getByRole('button', { name: 'Clear Search' }));
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
},

View File

@@ -90,16 +90,13 @@ export const SaveFailure: Story = {
// the point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
// Save is off until the form is edited, so the failure is reached through
// a real change rather than an empty submit.
await userEvent.type(
await canvas.findByTestId('slack-channel-textbox', undefined, untilLoaded),
'-edited',
await userEvent.click(
await within(canvasElement).findByTestId(
'save-channel-button',
undefined,
untilLoaded,
),
);
await userEvent.click(canvas.getByTestId('save-channel-button'));
await screen.findByText('Storybook forced channel failure');
},
};

View File

@@ -82,12 +82,6 @@ export const NoCreate: Story = {
await expectDisabled(
await canvas.findByTestId('test-channel-button', undefined, PAGE_LOAD),
);
// Save is also off until the form is edited, so the edit comes first and
// what is left is the permission.
await userEvent.type(
await canvas.findByTestId('slack-channel-textbox', undefined, PAGE_LOAD),
'-edited',
);
await expectDisabled(
await canvas.findByTestId('save-channel-button', undefined, PAGE_LOAD),
false,
@@ -174,13 +168,12 @@ export const CheckFailed: Story = {
// point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(
await canvas.findByTestId('slack-channel-textbox', undefined, PAGE_LOAD),
'-edited',
const save = await within(canvasElement).findByTestId(
'save-channel-button',
undefined,
PAGE_LOAD,
);
await expectDisabled(canvas.getByTestId('save-channel-button'), false);
await expectDisabled(save, false);
},
};

View File

@@ -16,11 +16,6 @@ import {
} from '../../stories/__story_mockdata__/alerts';
const ACTIONS = 'Channel · actions';
const ROUTE = 'Channel · route';
/** The `?kind=` values worth reaching: a known type, and one the form rejects. */
const URL_KINDS = ['none', 'jira', 'not-a-kind'] as const;
type UrlKind = (typeof URL_KINDS)[number];
const resolveChannelCreated: MockResolver = (_req, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: null }));
@@ -34,8 +29,7 @@ const rejectChannelAction: MockResolver = (_req, res, ctx) =>
/**
* The form holds the channel type in component state, so the type itself is
* stories with a `play` that picks one rather than a control; saving and
* testing the channel are. The type can also arrive in the URL, which the list
* uses to carry its filter over, so that is a control on the route.
* testing the channel are.
*/
export const channelsNewMocks = defineStoryMocks({
controls: {
@@ -49,11 +43,6 @@ export const channelsNewMocks = defineStoryMocks({
options: CHANNEL_ACTION_OUTCOMES,
value: 'succeeds',
}),
kindInUrl: choiceControl<UrlKind>('Channel type in the URL', {
group: ROUTE,
options: URL_KINDS,
value: 'none',
}),
},
handlers: (values) => [
rest.post(
@@ -66,10 +55,5 @@ export const channelsNewMocks = defineStoryMocks({
values.testOutcome === 'fails' ? rejectChannelAction : resolveChannelTested,
),
],
config: (values) => ({
route:
values.kindInUrl === 'none'
? '/alerts/channels/new'
: `/alerts/channels/new?kind=${values.kindInUrl}`,
}),
config: () => ({ route: '/alerts/channels/new' }),
});

View File

@@ -58,16 +58,9 @@ export const ChannelTypeSelectOpen: Story = {
/** Mutation: saving without a channel name shows the form's required-field feedback. */
export const InvalidRequiredFields: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
// Save stays off until the form is touched, so filling the webhook first is
// how a missing name is reached.
await userEvent.type(
await canvas.findByTestId('webhook-url-textbox'),
'https://hooks.slack.com/services/T/B/X',
await userEvent.click(
await within(canvasElement).findByTestId('save-channel-button'),
);
await userEvent.click(canvas.getByTestId('save-channel-button'));
await screen.findByText('Channel name is mandatory');
},
};
@@ -238,38 +231,3 @@ export const RequiredFields: Story = {
);
},
};
/**
* The type carried in the URL. Filtering the list by a type and pressing its
* create button lands here with `?kind=`, so the form opens on that type with
* nothing to pick.
*/
export const KindFromUrl: Story = {
args: { kindInUrl: 'jira' },
play: async ({ canvasElement }): Promise<void> => {
const trigger = await within(canvasElement).findByTestId(
'channel-type-select',
undefined,
{ timeout: 15_000 },
);
await within(trigger).findByText(/^Jira$/);
},
};
/**
* A `?kind=` the app does not have. The form opens on its own default rather
* than on an empty type, so a stale or hand-edited link still works.
*/
export const UnknownKindInUrl: Story = {
args: { kindInUrl: 'not-a-kind' },
play: async ({ canvasElement }): Promise<void> => {
const trigger = await within(canvasElement).findByTestId(
'channel-type-select',
undefined,
{ timeout: 15_000 },
);
await within(trigger).findByText(/^Slack$/);
},
};

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, within } from 'storybook/test';
import { expect, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -81,13 +81,12 @@ export const CheckFailed: Story = {
// point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(
await canvas.findByTestId('channel-name-textbox', undefined, PAGE_LOAD),
'check-failed',
const save = await within(canvasElement).findByTestId(
'save-channel-button',
undefined,
PAGE_LOAD,
);
await expectDisabled(canvas.getByTestId('save-channel-button'), false);
await expectDisabled(save, false);
},
};

View File

@@ -11,12 +11,13 @@
--select-trigger-height: var(--control-height);
--input-height: var(--control-height);
--input-wrapper-height: var(--control-height);
--button-height: var(--control-height);
display: flex;
flex-direction: column;
gap: var(--spacing-6);
flex-shrink: 0;
padding: 0 var(--spacing-8) var(--spacing-6) var(--spacing-8);
padding: var(--spacing-6) var(--spacing-8);
}
/* Title and count on the left, the create action at the far end, the way the
@@ -24,7 +25,7 @@
.commandHeader {
display: flex;
align-items: center;
gap: var(--spacing-4);
gap: 12px;
}
.headingBlock {

View File

@@ -7,11 +7,10 @@ import { Skeleton } from 'antd';
import logEvent from 'api/common/logEvent';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import TextToolTip from 'components/TextToolTip';
import { ChannelKind } from 'container/CreateAlertChannels/types';
import { useUrlSearchState } from 'hooks/useUrlSearchState';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
@@ -26,9 +25,7 @@ import { useTimezone } from 'providers/Timezone';
import ChannelActionsMenu from './components/ChannelActionsMenu/ChannelActionsMenu';
import ChannelJsonDialog from './components/ChannelJsonDialog/ChannelJsonDialog';
import ChannelsEmptyState from './components/ChannelsEmptyState/ChannelsEmptyState';
import {
CHANNEL_KIND_LABEL,
KIND_FILTER_ALL,
KIND_FILTER_OPTIONS,
KIND_KEY,
@@ -159,10 +156,6 @@ function NotificationChannels(): JSX.Element {
);
const hasActiveFilters = searchText.length > 0 || kind !== KIND_FILTER_ALL;
// With a kind picked, creating one of that kind is the likely next step, so
// the button offers it rather than making the type be chosen again.
const filteredKind =
kind !== KIND_FILTER_ALL ? (kind as ChannelKind) : undefined;
const isEmpty = !isFetching && channels.length === 0;
const handleClearFilters = useCallback((): void => {
@@ -176,38 +169,27 @@ function NotificationChannels(): JSX.Element {
<div className={styles.commandHeader}>
<div className={styles.headingBlock}>
<Typography.Title className={styles.title}>
{filteredKind
? `All ${CHANNEL_KIND_LABEL[filteredKind]} channels`
: 'All notification channels'}
All notification channels
</Typography.Title>
<span className={styles.countPill}>{total}</span>
</div>
<div className={styles.grow} />
{!isEmpty && (
<ColumnSelector
columns={columnsWithActions}
storageKey="notification-channels-columns"
testId="channels-columns"
/>
)}
<AuthZButton
checks={CREATE_CHECKS}
variant="solid"
color="primary"
size="sm"
prefix={<Plus size={14} />}
onClick={(): void => formView.openCreate(filteredKind)}
testId="channels-create"
>
{filteredKind
? `New ${CHANNEL_KIND_LABEL[filteredKind]} Channel`
: 'New Channel'}
</AuthZButton>
<TextToolTip
text="More details on how to configure notification channels"
url="https://signoz.io/docs/setup-alerts-notification/"
urlText="Learn More"
/>
<AuthZButton
checks={CREATE_CHECKS}
variant="solid"
color="primary"
size="md"
prefix={<Plus size={14} />}
onClick={formView.openCreate}
testId="channels-create"
>
New channel
</AuthZButton>
</div>
<div className={styles.filtersRow}>
@@ -233,8 +215,12 @@ function NotificationChannels(): JSX.Element {
</header>
<div ref={containerRef} className={styles.tableContainer}>
{/* Fails closed, unlike the guard's default: the fetch is gated on the
same check, so falling open would render an empty table shell with
no reason given. The header and filters stay, as on dashboards. */}
<AuthZGuardContent
checks={LIST_CHECKS}
onFailRenderContent={false}
fallback={({ deniedPermissions }): JSX.Element => (
<PermissionDeniedCallout
deniedPermissions={deniedPermissions}
@@ -259,10 +245,6 @@ function NotificationChannels(): JSX.Element {
onClear={handleClearFilters}
clearButtonText="Clear Search"
/>
) : isEmpty ? (
<ChannelsEmptyState
onCreate={(): void => formView.openCreate(filteredKind)}
/>
) : (
<TanStackTable<Channel>
data={channels}

View File

@@ -72,52 +72,6 @@ describe('ChannelForm save', () => {
TEST_TIMEOUT,
);
it(
'sends no telegram message, so the backend keeps its own template',
async () => {
let body: Record<string, unknown> | undefined;
server.use(
rest.post(
'http://localhost/api/v2/notification_channels',
async (req, res, ctx) => {
body = await req.json();
return res(ctx.status(201), ctx.json({ status: 'success', data: {} }));
},
),
);
render(<ChannelForm onDone={jest.fn()} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.click(await screen.findByTestId('channel-type-select', {}, FIND));
await user.click(await screen.findByRole('option', { name: /^Telegram$/ }));
await user.type(
screen.getByTestId('channel-name-textbox'),
'telegram alerts',
);
await user.type(screen.getByTestId('telegram-bot-token-textbox'), '123:abc');
await user.type(screen.getByTestId('telegram-chat-id-textbox'), '-100123');
// the box is empty on screen, and an empty box must not become an empty
// string in the request: that would overwrite the backend's default
expect(screen.getByTestId('telegram-message-textarea')).toHaveValue('');
const save = screen.getByTestId('save-channel-button');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
await user.click(save);
await waitFor(() => expect(body).toBeDefined(), FIND);
const spec = (body as { config: { spec: Record<string, unknown> } }).config
.spec;
expect(spec).not.toHaveProperty('message');
},
TEST_TIMEOUT,
);
it(
'updates with the config only, since both names are immutable',
async () => {
@@ -208,20 +162,15 @@ describe('ChannelForm save', () => {
render(<ChannelForm channelId="3" onDone={jest.fn()} />);
const channel = await screen.findByTestId('slack-channel-textbox', {}, FIND);
await waitFor(() => expect(channel).toBeEnabled(), FIND);
// Save is off until something changes, so one edit is what tells a
// settled failure apart from a denial
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.type(channel, '-edited');
const save = screen.getByTestId('save-channel-button');
// Save is disabled while the checks are in flight and settles once they
// resolve, so waiting on it means the failure has landed
const save = await screen.findByTestId('save-channel-button', {}, FIND);
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
expect(screen.getByTestId('slack-channel-textbox')).toBeEnabled();
expect(
screen.queryByTestId('channel-read-only-notice'),
).not.toBeInTheDocument();
@@ -229,69 +178,6 @@ describe('ChannelForm save', () => {
TEST_TIMEOUT,
);
it(
'keeps save off until something is edited',
async () => {
render(<ChannelForm channelId="3" onDone={jest.fn()} />);
// Save is also off while the permission checks are in flight. Test is
// gated on those checks alone, so it settling means Save is only off
// because nothing has changed.
const testButton = await screen.findByTestId(
'test-channel-button',
{},
FIND,
);
await waitFor(
() => expect(testButton).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
const save = screen.getByTestId('save-channel-button');
expect(save).toHaveAttribute('aria-disabled', 'true');
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.type(screen.getByTestId('slack-channel-textbox'), '-edited');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
},
TEST_TIMEOUT,
);
it(
'asks before discarding an edited form, and leaves straight away otherwise',
async () => {
const onDone = jest.fn();
render(<ChannelForm channelId="3" onDone={onDone} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const discard = await screen.findByTestId(
'discard-channel-button',
{},
FIND,
);
await user.click(discard);
expect(onDone).toHaveBeenCalledTimes(1);
await user.type(screen.getByTestId('slack-channel-textbox'), '-edited');
await user.click(discard);
// the test harness stubs `t`, so the modal renders the keys themselves
await screen.findByText('discard_changes_title', {}, FIND);
expect(onDone).toHaveBeenCalledTimes(1);
await user.click(
screen.getByRole('button', { name: 'button_discard_confirm' }),
);
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(2));
},
TEST_TIMEOUT,
);
it(
'explains a channel the v2 API cannot model instead of showing a blank form',
async () => {

View File

@@ -6,27 +6,16 @@ import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/pe
import ChannelFormBody from './ChannelFormBody';
import styles from './ChannelForm.module.scss';
import { ChannelKind } from 'container/CreateAlertChannels/types';
import { useChannelDraft } from './useChannelDraft';
interface ChannelFormProps {
channelId?: string;
/** Preselects the type when creating, so a filtered list can offer its kind. */
initialKind?: ChannelKind;
onDone: () => void;
}
function ChannelForm({
channelId,
initialKind,
onDone,
}: ChannelFormProps): JSX.Element {
function ChannelForm({ channelId, onDone }: ChannelFormProps): JSX.Element {
const { t } = useTranslation('channels');
const { draft, isLoading, loadError } = useChannelDraft(
channelId,
initialKind,
);
const { draft, isLoading, loadError } = useChannelDraft(channelId);
const {
canEdit,

View File

@@ -1,6 +1,3 @@
import { useTranslation } from 'react-i18next';
import { Typography } from '@signozhq/ui/typography';
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
import FormAlertChannels from 'container/FormAlertChannels';
import { ChannelFormState } from 'container/CreateAlertChannels/channelFormValues';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
@@ -27,54 +24,23 @@ function ChannelFormBody({
saveChecks,
testChecks,
}: ChannelFormBodyProps): JSX.Element {
const { t } = useTranslation('channels');
const { contextHolder, confirmDelete } = useDeleteConfirm();
const {
form,
type,
isDirty,
onTypeChange,
onSave,
onTest,
isSaving,
isTesting,
} = useChannelFormState({ draft, target, onDone });
const onDiscard = (): void => {
if (!isDirty) {
onDone();
return;
}
confirmDelete({
title: (
<Typography.Title level={5}>{t('discard_changes_title')}</Typography.Title>
),
content: t('discard_changes_body'),
confirmText: t('button_discard_confirm'),
onConfirm: onDone,
});
};
const { form, type, onTypeChange, onSave, onTest, isSaving, isTesting } =
useChannelFormState({ draft, target, onDone });
return (
<>
<FormAlertChannels
form={form}
type={type}
onTypeChangeHandler={onTypeChange}
onTestHandler={onTest}
onSaveHandler={onSave}
onDiscardHandler={onDiscard}
savingState={isSaving}
testingState={isTesting}
editing={target.mode === 'edit'}
readOnly={readOnly}
hasChanges={isDirty}
saveChecks={saveChecks}
testChecks={testChecks}
/>
{contextHolder}
</>
<FormAlertChannels
form={form}
type={type}
onTypeChangeHandler={onTypeChange}
onTestHandler={onTest}
onSaveHandler={onSave}
savingState={isSaving}
testingState={isTesting}
editing={target.mode === 'edit'}
readOnly={readOnly}
saveChecks={saveChecks}
testChecks={testChecks}
/>
);
}

View File

@@ -10,7 +10,6 @@ import {
ChannelInitialConfig,
DEFAULT_CHANNEL_KIND,
} from 'container/CreateAlertChannels/defaults';
import { ChannelKind } from 'container/CreateAlertChannels/types';
import { toAPIError } from 'utils/errorUtils';
export interface ChannelDraftResult {
@@ -23,10 +22,7 @@ export interface ChannelDraftResult {
* Kept apart from the form so the form is built once its values are known,
* rather than mounted empty and reset.
*/
export function useChannelDraft(
channelId?: string,
initialKind?: ChannelKind,
): ChannelDraftResult {
export function useChannelDraft(channelId?: string): ChannelDraftResult {
const isEditing = !!channelId;
const { data, error: loadFailure } = useGetNotificationChannel(
@@ -38,10 +34,10 @@ export function useChannelDraft(
channelId
? null
: {
kind: initialKind ?? DEFAULT_CHANNEL_KIND,
kind: DEFAULT_CHANNEL_KIND,
values: {
sendResolved: true,
...ChannelInitialConfig[initialKind ?? DEFAULT_CHANNEL_KIND],
...ChannelInitialConfig[DEFAULT_CHANNEL_KIND],
},
},
);

View File

@@ -31,8 +31,6 @@ import { toAPIError } from 'utils/errorUtils';
export interface ChannelFormStateResult {
form: UseFormReturn<ChannelFormValues>;
type: ChannelKind;
/** False until something is edited, which is what gates saving. */
isDirty: boolean;
onTypeChange: (value: string) => void;
onSave: () => void;
onTest: () => void;
@@ -86,13 +84,10 @@ export function useChannelFormState({
setTypeState(nextType);
// shouldDirty, because the type lives outside the form: without it
// picking a different type leaves the form pristine and unsavable.
Object.entries(ChannelInitialConfig[nextType]).forEach(([field, value]) => {
form.setValue(
field as keyof ChannelFormValues,
value as ChannelFormValues[keyof ChannelFormValues],
{ shouldDirty: true },
);
});
},
@@ -182,7 +177,6 @@ export function useChannelFormState({
return {
form,
type,
isDirty: form.formState.isDirty,
onTypeChange,
onSave: form.handleSubmit(submitSave),
onTest: form.handleSubmit(submitTest),

View File

@@ -1,48 +0,0 @@
/* The same shape as the page's no-matches state, so the two empty screens read
as the same product rather than two designs. */
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-3);
padding: 48px 24px;
text-align: center;
}
.icon {
color: var(--l2-foreground);
}
.title {
color: var(--l1-foreground);
font-size: 16px;
font-weight: 500;
line-height: 24px;
}
.subtitle {
color: var(--l2-foreground);
font-size: 14px;
line-height: 20px;
max-width: 420px;
}
/* The button sizes to its label here, so "New channel" is never clipped. */
.actions {
display: flex;
align-items: center;
gap: var(--spacing-2);
margin-top: var(--spacing-1);
}
.docsLink {
color: var(--l2-foreground);
font-size: 13px;
text-decoration: none;
&:hover {
color: var(--l1-foreground);
text-decoration: underline;
}
}

View File

@@ -1,54 +0,0 @@
import { Bell, Plus } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
import styles from './ChannelsEmptyState.module.scss';
const CREATE_CHECKS = [NotificationChannelCreatePermission];
const DOCS_URL = 'https://signoz.io/docs/setup-alerts-notification/';
interface ChannelsEmptyStateProps {
onCreate: () => void;
}
function ChannelsEmptyState({
onCreate,
}: ChannelsEmptyStateProps): JSX.Element {
return (
<div className={styles.emptyState} data-testid="channels-empty-state">
<Bell className={styles.icon} size={16} />
<Typography.Text className={styles.title}>
No notification channels yet
</Typography.Text>
<Typography.Text className={styles.subtitle}>
A channel is where an alert ends up. Create one and your alert rules and
routing policies can point at it.
</Typography.Text>
<div className={styles.actions}>
<AuthZButton
checks={CREATE_CHECKS}
variant="solid"
color="primary"
size="md"
prefix={<Plus size={14} />}
onClick={onCreate}
testId="channels-empty-create"
>
New channel
</AuthZButton>
</div>
<a
className={styles.docsLink}
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
data-testid="channels-empty-docs"
>
Read the setup guide
</a>
</div>
);
}
export default ChannelsEmptyState;

View File

@@ -4,7 +4,7 @@ import history from 'lib/history';
import { openInNewTab } from 'utils/navigation';
export interface ChannelFormView {
openCreate: (kind?: string) => void;
openCreate: () => void;
openEdit: (channelId: string, options?: { newTab?: boolean }) => void;
}
@@ -22,10 +22,7 @@ export function useChannelFormView(): ChannelFormView {
};
return {
openCreate: (kind?: string): void =>
history.push(
kind ? `${ROUTES.CHANNELS_NEW}?kind=${kind}` : ROUTES.CHANNELS_NEW,
),
openCreate: (): void => history.push(ROUTES.CHANNELS_NEW),
openEdit,
};
}

View File

@@ -791,22 +791,6 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
channel: '#ops-alerts',
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
text: '{{ range .Alerts -}}*Alert:* {{ .Labels.alertname }}\n{{ end }}',
// one of each so the repeating row editors render with content
fields: [
{ title: 'Severity', value: '{{ .CommonLabels.severity }}', short: true },
{
title: 'Service',
value: '{{ .CommonLabels.service_name }}',
short: false,
},
],
actions: [
{
type: 'button',
text: 'Runbook',
url: 'https://runbook.example.com/{{ .CommonLabels.alertname }}',
},
],
sendResolved: true,
},
webhook: {

View File

@@ -2,6 +2,12 @@
padding: var(--spacing-6);
}
.page {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
.content {
padding: 0 var(--spacing-6) var(--spacing-6);
}
.crumbName {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
}

View File

@@ -1,7 +1,6 @@
import { Spinner } from '@signozhq/ui/spinner';
import { toast } from '@signozhq/ui/sonner';
import { useGetNotificationChannel } from 'api/generated/services/channels';
import BackHeader from 'components/BackHeader/BackHeader';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import ChannelForm from 'pages/AlertList/NotificationChannels/components/ChannelForm/ChannelForm';
@@ -14,45 +13,42 @@ interface ChannelsEditPageProps {
}
function ChannelsEditPage({ channelId }: ChannelsEditPageProps): JSX.Element {
const { data, isLoading } = useGetNotificationChannel({ id: channelId });
const { data } = useGetNotificationChannel({ id: channelId });
return (
<div className={styles.page}>
<BackHeader
backLabel="All channels"
backRoute={ROUTES.ALL_CHANNELS}
testId="channel-edit-header"
title={
isLoading ? (
<Spinner
size={16}
aria-label="Loading channel"
testId="channel-name-loading"
/>
) : (
<>
<span className="translate-safe">
{data?.data?.displayName || 'Edit Channel'}
<>
<AlertBreadcrumb
items={[
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
{
title: (
<span className={styles.crumbName}>
<span className="translate-safe">
{data?.data?.displayName || 'Edit Channel'}
</span>
<CopyButton
value={channelId}
ariaLabel="Copy channel ID"
testId="copy-channel-id"
onCopy={(): void => {
toast.success('Channel ID copied to clipboard');
}}
/>
</span>
<CopyButton
value={channelId}
ariaLabel="Copy channel ID"
testId="copy-channel-id"
onCopy={(): void => {
toast.success('Channel ID copied to clipboard');
}}
/>
</>
)
}
),
isLast: true,
},
]}
/>
<ChannelForm
channelId={channelId}
onDone={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
/>
</div>
<div className={styles.content}>
<ChannelForm
channelId={channelId}
onDone={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
/>
</div>
</>
);
}

View File

@@ -1,46 +1,30 @@
import { useLocation } from 'react-router-dom';
import { Skeleton } from 'antd';
import BackHeader from 'components/BackHeader/BackHeader';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
import { ChannelKind } from 'container/CreateAlertChannels/types';
import ChannelForm from 'pages/AlertList/NotificationChannels/components/ChannelForm/ChannelForm';
import { KIND_KEY } from 'pages/AlertList/NotificationChannels/constants';
import styles from './styles.module.scss';
function ChannelsNew(): JSX.Element {
const requested = new URLSearchParams(useLocation().search).get('kind');
// an unknown kind in the URL falls back to the form's own default
const initialKind = (Object.values(ChannelKind) as string[]).includes(
requested ?? '',
)
? (requested as ChannelKind)
: undefined;
// Leaving goes back to the list the way it was left: filtered by the kind
// that opened this form.
const listRoute = initialKind
? `${ROUTES.ALL_CHANNELS}&${KIND_KEY}=${initialKind}`
: ROUTES.ALL_CHANNELS;
return (
<div className={styles.page}>
<BackHeader
backLabel="All channels"
backRoute={listRoute}
title="New channel"
testId="channel-new-header"
<>
<AlertBreadcrumb
items={[
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
{ title: 'New channel', isLast: true },
]}
/>
<ChannelForm
initialKind={initialKind}
onDone={(): void => {
history.replace(listRoute);
}}
/>
</div>
<div className={styles.content}>
<ChannelForm
onDone={(): void => {
history.replace(ROUTES.ALL_CHANNELS);
}}
/>
</div>
</>
);
}

View File

@@ -2,6 +2,6 @@
padding: var(--spacing-6);
}
.page {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
.content {
padding: 0 var(--spacing-6) var(--spacing-6);
}

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}

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