mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-08 03:00:41 +01:00
Compare commits
72 Commits
feat/trace
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96766440a0 | ||
|
|
3f75c09e68 | ||
|
|
60e783780e | ||
|
|
08ec6031ef | ||
|
|
a116dae04f | ||
|
|
de4fc38719 | ||
|
|
3af854c952 | ||
|
|
66bf2fb0b7 | ||
|
|
d6e337f870 | ||
|
|
587e0971ed | ||
|
|
7689c604e7 | ||
|
|
47a0e08860 | ||
|
|
d1b580605c | ||
|
|
40e63381e3 | ||
|
|
446e9b8df6 | ||
|
|
9f2a2d5704 | ||
|
|
49bf93eb06 | ||
|
|
62d4d5a616 | ||
|
|
1734056d43 | ||
|
|
ad30a2bbb6 | ||
|
|
3e73775138 | ||
|
|
cba3c22dac | ||
|
|
7e94a42ada | ||
|
|
66a13a2faf | ||
|
|
dd9de5917f | ||
|
|
7f90c1adf6 | ||
|
|
ec0320f1a2 | ||
|
|
130e35b556 | ||
|
|
e8eadc3453 | ||
|
|
ac50165f55 | ||
|
|
eb694e5a36 | ||
|
|
87e0194303 | ||
|
|
b032aaf828 | ||
|
|
2249367b2e | ||
|
|
79d7fb0ef7 | ||
|
|
2be3488bda | ||
|
|
bf3a36099f | ||
|
|
14833699d5 | ||
|
|
ddf847d343 | ||
|
|
ed65aa29cc | ||
|
|
954988d401 | ||
|
|
dff09e6950 | ||
|
|
4132972bb3 | ||
|
|
3348ee3bd2 | ||
|
|
b01ea8eb06 | ||
|
|
08731f448d | ||
|
|
330fb7c985 | ||
|
|
3b3462a2ed | ||
|
|
375879ea2b | ||
|
|
7cfd4f6d9e | ||
|
|
40bdb1e9e5 | ||
|
|
c9974d4a68 | ||
|
|
bb491a932b | ||
|
|
faf665b6af | ||
|
|
5997038816 | ||
|
|
aa530894d9 | ||
|
|
687b20a5c2 | ||
|
|
3b02ee9815 | ||
|
|
ea7282973b | ||
|
|
86f9ca51ab | ||
|
|
a94786c589 | ||
|
|
c370d2cc84 | ||
|
|
0c5f5cbee1 | ||
|
|
64b624aaac | ||
|
|
d53eaf436d | ||
|
|
1c612342e1 | ||
|
|
5d389e10f9 | ||
|
|
c2691226f2 | ||
|
|
3833623a0a | ||
|
|
c83e1dac83 | ||
|
|
b5dddfef17 | ||
|
|
eae11aeee7 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -68,7 +68,6 @@ jobs:
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- tracedetail
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
|
||||
@@ -3425,6 +3425,13 @@ components:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesAxisScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
@@ -4167,6 +4174,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'
|
||||
@@ -4182,6 +4190,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -4195,6 +4204,7 @@ components:
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -4280,6 +4290,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:
|
||||
@@ -4359,6 +4381,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:
|
||||
@@ -4532,6 +4564,87 @@ 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/DashboardtypesThresholdWithLabel'
|
||||
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
|
||||
DashboardtypesSource:
|
||||
enum:
|
||||
- user
|
||||
@@ -9844,53 +9957,6 @@ components:
|
||||
required:
|
||||
- aggregations
|
||||
type: object
|
||||
SpantypesGettableTraceSummary:
|
||||
properties:
|
||||
ai:
|
||||
$ref: '#/components/schemas/SpantypesTraceAISummary'
|
||||
endTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
hasMissingSpans:
|
||||
type: boolean
|
||||
rootServiceEntryPoint:
|
||||
type: string
|
||||
rootServiceName:
|
||||
type: string
|
||||
rootSpanStatusCode:
|
||||
type: string
|
||||
startTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalErrorSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- startTimestampMillis
|
||||
- endTimestampMillis
|
||||
- rootServiceName
|
||||
- rootServiceEntryPoint
|
||||
- rootSpanStatusCode
|
||||
- totalSpansCount
|
||||
- totalErrorSpansCount
|
||||
- hasMissingSpans
|
||||
type: object
|
||||
SpantypesGettableTraceThread:
|
||||
properties:
|
||||
nextCursor:
|
||||
type: string
|
||||
prevCursor:
|
||||
type: string
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesThreadSpan'
|
||||
type: array
|
||||
required:
|
||||
- spans
|
||||
type: object
|
||||
SpantypesGettableWaterfallTrace:
|
||||
properties:
|
||||
endTimestampMillis:
|
||||
@@ -10205,94 +10271,6 @@ components:
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
SpantypesThreadSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
duration_nano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
events:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesEvent'
|
||||
type: array
|
||||
has_error:
|
||||
type: boolean
|
||||
kind_string:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
parent_span_id:
|
||||
type: string
|
||||
references:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesOtelSpanRef'
|
||||
type: array
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
span_id:
|
||||
type: string
|
||||
status_code_string:
|
||||
type: string
|
||||
status_message:
|
||||
type: string
|
||||
time_unix:
|
||||
minimum: 0
|
||||
type: integer
|
||||
trace_id:
|
||||
type: string
|
||||
required:
|
||||
- span_id
|
||||
- trace_id
|
||||
- parent_span_id
|
||||
- name
|
||||
- kind_string
|
||||
- time_unix
|
||||
- duration_nano
|
||||
- has_error
|
||||
- status_code_string
|
||||
- status_message
|
||||
- resource
|
||||
- attributes
|
||||
- events
|
||||
- references
|
||||
type: object
|
||||
SpantypesTraceAISummary:
|
||||
properties:
|
||||
tokens:
|
||||
$ref: '#/components/schemas/SpantypesTraceAITokens'
|
||||
totalCost:
|
||||
type: number
|
||||
required:
|
||||
- tokens
|
||||
type: object
|
||||
SpantypesTraceAITokens:
|
||||
properties:
|
||||
cacheRead:
|
||||
minimum: 0
|
||||
type: integer
|
||||
cacheWrite:
|
||||
minimum: 0
|
||||
type: integer
|
||||
input:
|
||||
minimum: 0
|
||||
type: integer
|
||||
output:
|
||||
minimum: 0
|
||||
type: integer
|
||||
reasoning:
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- input
|
||||
- output
|
||||
- cacheRead
|
||||
- cacheWrite
|
||||
- reasoning
|
||||
type: object
|
||||
SpantypesUpdatableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -16017,163 +15995,6 @@ paths:
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/traces/{traceID}/summary:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the trace's time range, root span, span and error counts,
|
||||
and whether any spans are missing. AI traces also include token and cost totals.
|
||||
operationId: GetTraceSummary
|
||||
parameters:
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableTraceSummary'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get summary for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/traces/{traceID}/thread:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the spans carrying gen_ai input or output messages in timestamp
|
||||
order. Pass nextCursor as after or prevCursor as before to page, or spanId
|
||||
to open the page around a span.
|
||||
operationId: GetTraceThread
|
||||
parameters:
|
||||
- description: Page size, at most 100. 0 means 20.
|
||||
in: query
|
||||
name: limit
|
||||
schema:
|
||||
description: Page size, at most 100. 0 means 20.
|
||||
type: integer
|
||||
- description: The nextCursor of a page; returns the spans after it. Set only
|
||||
one of after, before and spanId.
|
||||
in: query
|
||||
name: after
|
||||
schema:
|
||||
description: The nextCursor of a page; returns the spans after it. Set only
|
||||
one of after, before and spanId.
|
||||
type: string
|
||||
- description: The prevCursor of a page; returns the spans before it. Set only
|
||||
one of after, before and spanId.
|
||||
in: query
|
||||
name: before
|
||||
schema:
|
||||
description: The prevCursor of a page; returns the spans before it. Set
|
||||
only one of after, before and spanId.
|
||||
type: string
|
||||
- description: Returns the page around this span. Set only one of after, before
|
||||
and spanId.
|
||||
in: query
|
||||
name: spanId
|
||||
schema:
|
||||
description: Returns the page around this span. Set only one of after, before
|
||||
and spanId.
|
||||
type: string
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableTraceThread'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get thread view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/user/me:
|
||||
get:
|
||||
deprecated: true
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -4372,6 +4372,12 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
|
||||
visualization?: DashboardtypesAreaChartVisualizationDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesAxisScaleDTO {
|
||||
auto = 'auto',
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
symlog = 'symlog',
|
||||
}
|
||||
export interface DashboardtypesBarChartVisualizationDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -5372,6 +5378,101 @@ 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 interface DashboardtypesScatterPlotPanelSpecDTO {
|
||||
axes?: DashboardtypesScatterPlotAxesDTO;
|
||||
chartAppearance?: DashboardtypesScatterPlotChartAppearanceDTO;
|
||||
dimensions?: DashboardtypesScatterPlotDimensionsDTO;
|
||||
formatting?: DashboardtypesTableFormattingDTO;
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
|
||||
visualization?: DashboardtypesBasicVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/ScatterPlotPanel
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind;
|
||||
spec: DashboardtypesScatterPlotPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -5382,7 +5483,8 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -6309,6 +6411,7 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/ListPanel' = 'signoz/ListPanel',
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
@@ -11335,82 +11438,6 @@ export interface SpantypesGettableTraceAggregationsDTO {
|
||||
aggregations: SpantypesSpanAggregationResultDTO[];
|
||||
}
|
||||
|
||||
export interface SpantypesTraceAITokensDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
cacheRead: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
cacheWrite: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
input: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
output: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
reasoning: number;
|
||||
}
|
||||
|
||||
export interface SpantypesTraceAISummaryDTO {
|
||||
tokens: SpantypesTraceAITokensDTO;
|
||||
/**
|
||||
* @type number
|
||||
*/
|
||||
totalCost?: number;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableTraceSummaryDTO {
|
||||
ai?: SpantypesTraceAISummaryDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
endTimestampMillis: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMissingSpans: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceEntryPoint: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootSpanStatusCode: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
startTimestampMillis: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalErrorSpansCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalSpansCount: number;
|
||||
}
|
||||
|
||||
export interface SpantypesOtelSpanRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -11426,86 +11453,6 @@ export interface SpantypesOtelSpanRefDTO {
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
export type SpantypesThreadSpanDTOAttributes = { [key: string]: unknown };
|
||||
|
||||
export type SpantypesThreadSpanDTOResource = { [key: string]: string };
|
||||
|
||||
export interface SpantypesThreadSpanDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
attributes: SpantypesThreadSpanDTOAttributes;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
duration_nano: number;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
events: SpantypesEventDTO[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
has_error: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind_string: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
parent_span_id: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
references: SpantypesOtelSpanRefDTO[];
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
resource: SpantypesThreadSpanDTOResource;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
span_id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_code_string: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_message: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
time_unix: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
trace_id: string;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableTraceThreadDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
nextCursor?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
prevCursor?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
spans: SpantypesThreadSpanDTO[];
|
||||
}
|
||||
|
||||
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -13179,51 +13126,6 @@ export type GetTraceAggregations200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetTraceSummaryPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetTraceSummary200 = {
|
||||
data: SpantypesGettableTraceSummaryDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetTraceThreadPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetTraceThreadParams = {
|
||||
/**
|
||||
* @type integer
|
||||
* @description Page size, at most 100. 0 means 20.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
* @description The nextCursor of a page; returns the spans after it. Set only one of after, before and spanId.
|
||||
*/
|
||||
after?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The prevCursor of a page; returns the spans before it. Set only one of after, before and spanId.
|
||||
*/
|
||||
before?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Returns the page around this span. Set only one of after, before and spanId.
|
||||
*/
|
||||
spanId?: string;
|
||||
};
|
||||
|
||||
export type GetTraceThread200 = {
|
||||
data: SpantypesGettableTraceThreadDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUserPreferences200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -4,17 +4,11 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
@@ -22,11 +16,6 @@ import type {
|
||||
GetFlamegraphPathParameters,
|
||||
GetTraceAggregations200,
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetTraceSummary200,
|
||||
GetTraceSummaryPathParameters,
|
||||
GetTraceThread200,
|
||||
GetTraceThreadParams,
|
||||
GetTraceThreadPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -38,26 +27,6 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
const withQueryKey = <T extends object, K>(
|
||||
query: T,
|
||||
queryKey: K,
|
||||
): T & { queryKey: K } => {
|
||||
const result = { queryKey } as T & { queryKey: K };
|
||||
for (const key of Object.keys(query)) {
|
||||
// The explicit queryKey always wins, matching the previous
|
||||
// `{ ...query, queryKey }` spread where it was set last.
|
||||
if (key === 'queryKey') {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(result, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (query as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes span aggregations grouped by requested field.
|
||||
* @summary Get aggregations for a trace
|
||||
@@ -158,223 +127,6 @@ export const useGetTraceAggregations = <
|
||||
> => {
|
||||
return useMutation(getGetTraceAggregationsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the trace's time range, root span, span and error counts, and whether any spans are missing. AI traces also include token and cost totals.
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
export const getTraceSummary = (
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetTraceSummary200>({
|
||||
url: `/api/v1/traces/${traceID}/summary`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetTraceSummaryQueryKey = ({
|
||||
traceID,
|
||||
}: GetTraceSummaryPathParameters) => {
|
||||
return [`/api/v1/traces/${traceID}/summary`] as const;
|
||||
};
|
||||
|
||||
export const getGetTraceSummaryQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetTraceSummaryQueryKey({ traceID });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceSummary>>> = ({
|
||||
signal,
|
||||
}) => getTraceSummary({ traceID }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: traceID !== null && traceID !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetTraceSummaryQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>
|
||||
>;
|
||||
export type GetTraceSummaryQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
|
||||
export function useGetTraceSummary<
|
||||
TData = Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetTraceSummaryQueryOptions({ traceID }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
export const invalidateGetTraceSummary = async (
|
||||
queryClient: QueryClient,
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetTraceSummaryQueryKey({ traceID }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const getTraceThread = (
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetTraceThread200>({
|
||||
url: `/api/v1/traces/${traceID}/thread`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetTraceThreadQueryKey = (
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/traces/${traceID}/thread`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetTraceThreadQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetTraceThreadQueryKey({ traceID }, params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceThread>>> = ({
|
||||
signal,
|
||||
}) => getTraceThread({ traceID }, params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: traceID !== null && traceID !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetTraceThreadQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getTraceThread>>
|
||||
>;
|
||||
export type GetTraceThreadQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
|
||||
export function useGetTraceThread<
|
||||
TData = Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetTraceThreadQueryOptions(
|
||||
{ traceID },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const invalidateGetTraceThread = async (
|
||||
queryClient: QueryClient,
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetTraceThreadQueryKey({ traceID }, params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the flamegraph view of spans for a given trace ID.
|
||||
* @summary Get flamegraph view for a trace
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
@@ -278,6 +279,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);
|
||||
|
||||
|
||||
@@ -42,9 +42,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 16px;
|
||||
// OverlayScrollbar owns the scroll. A native scrollbar here changes the
|
||||
// body width when it toggles and shifts the header grid.
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -72,7 +69,7 @@
|
||||
}
|
||||
|
||||
.log-detail-drawer__content {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.log-detail-drawer__log {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Drawer, Tooltip } from 'antd';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
@@ -289,150 +288,148 @@ function LogDetailInner({
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
|
||||
>
|
||||
<OverlayScrollbar style={{ height: '100%' }}>
|
||||
<div
|
||||
className="log-detail-drawer__content"
|
||||
data-log-detail-ignore="true"
|
||||
data-testid="log-detail-drawer"
|
||||
>
|
||||
<div className="log-detail-drawer__log">
|
||||
<LogStateIndicator
|
||||
severityText={log.severity_text}
|
||||
severityNumber={log.severity_number}
|
||||
fontSize={options?.fontSize ?? FontSize.MEDIUM}
|
||||
/>
|
||||
<Tooltip
|
||||
title={removeEscapeCharacters(logBody)}
|
||||
placement="left"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
|
||||
</Tooltip>
|
||||
<div
|
||||
className="log-detail-drawer__content"
|
||||
data-log-detail-ignore="true"
|
||||
data-testid="log-detail-drawer"
|
||||
>
|
||||
<div className="log-detail-drawer__log">
|
||||
<LogStateIndicator
|
||||
severityText={log.severity_text}
|
||||
severityNumber={log.severity_number}
|
||||
fontSize={options?.fontSize ?? FontSize.MEDIUM}
|
||||
/>
|
||||
<Tooltip
|
||||
title={removeEscapeCharacters(logBody)}
|
||||
placement="left"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
|
||||
</Tooltip>
|
||||
|
||||
<div className="log-overflow-shadow"> </div>
|
||||
</div>
|
||||
|
||||
<LogHighlights log={log} />
|
||||
|
||||
<div className="log-detail-drawer__section-divider" />
|
||||
|
||||
<div className="tabs-and-search">
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
className="views-tabs"
|
||||
onChange={handleModeChange}
|
||||
value={selectedView}
|
||||
items={[
|
||||
{
|
||||
value: VIEW_TYPES.OVERVIEW,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: VIEW_TYPES.CONTEXT,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<TextSelect size={14} />
|
||||
Context
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: VIEW_TYPES.INFRAMETRICS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Histogram size="md" />
|
||||
Metrics
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="log-detail-drawer__actions">
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<Tooltip
|
||||
title="Show Filters"
|
||||
placement="topLeft"
|
||||
aria-label="Show Filters"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Filter size="lg" />}
|
||||
onClick={handleFilterVisible}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isFilterVisible && contextQuery?.builder.queryData[0] && (
|
||||
<div className="log-detail-drawer-query-container">
|
||||
<QuerySearch
|
||||
onChange={(value): void => handleQueryExpressionChange(value, 0)}
|
||||
dataSource={DataSource.LOGS}
|
||||
queryData={contextQuery?.builder.queryData[0]}
|
||||
onRun={handleRunQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.OVERVIEW && (
|
||||
<Overview
|
||||
logData={log}
|
||||
isListViewPanel={isListViewPanel}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
onApplyLogFilter={onApplyLogFilter}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<ContextView
|
||||
log={log}
|
||||
filters={filters}
|
||||
contextQuery={contextQuery}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.INFRAMETRICS && (
|
||||
<InfraMetrics
|
||||
clusterName={log.resources_string?.[RESOURCE_KEYS.CLUSTER_NAME] || ''}
|
||||
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
|
||||
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
|
||||
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
|
||||
timestamp={log.timestamp.toString()}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.OVERVIEW && (
|
||||
<div className="log-detail-drawer__footer-hint">
|
||||
<div className="log-detail-drawer__footer-hint-content">
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
className="log-detail-drawer__footer-hint-text"
|
||||
>
|
||||
Use
|
||||
</Typography.Text>
|
||||
<ArrowUp size={14} className="log-detail-drawer__footer-hint-icon" />
|
||||
<span>/</span>
|
||||
<ArrowDown size={14} className="log-detail-drawer__footer-hint-icon" />
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
className="log-detail-drawer__footer-hint-text"
|
||||
>
|
||||
to view previous/next log
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="log-overflow-shadow"> </div>
|
||||
</div>
|
||||
</OverlayScrollbar>
|
||||
|
||||
<LogHighlights log={log} />
|
||||
|
||||
<div className="log-detail-drawer__section-divider" />
|
||||
|
||||
<div className="tabs-and-search">
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
className="views-tabs"
|
||||
onChange={handleModeChange}
|
||||
value={selectedView}
|
||||
items={[
|
||||
{
|
||||
value: VIEW_TYPES.OVERVIEW,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: VIEW_TYPES.CONTEXT,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<TextSelect size={14} />
|
||||
Context
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: VIEW_TYPES.INFRAMETRICS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Histogram size="md" />
|
||||
Metrics
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="log-detail-drawer__actions">
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<Tooltip
|
||||
title="Show Filters"
|
||||
placement="topLeft"
|
||||
aria-label="Show Filters"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Filter size="lg" />}
|
||||
onClick={handleFilterVisible}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isFilterVisible && contextQuery?.builder.queryData[0] && (
|
||||
<div className="log-detail-drawer-query-container">
|
||||
<QuerySearch
|
||||
onChange={(value): void => handleQueryExpressionChange(value, 0)}
|
||||
dataSource={DataSource.LOGS}
|
||||
queryData={contextQuery?.builder.queryData[0]}
|
||||
onRun={handleRunQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.OVERVIEW && (
|
||||
<Overview
|
||||
logData={log}
|
||||
isListViewPanel={isListViewPanel}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
onApplyLogFilter={onApplyLogFilter}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<ContextView
|
||||
log={log}
|
||||
filters={filters}
|
||||
contextQuery={contextQuery}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.INFRAMETRICS && (
|
||||
<InfraMetrics
|
||||
clusterName={log.resources_string?.[RESOURCE_KEYS.CLUSTER_NAME] || ''}
|
||||
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
|
||||
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
|
||||
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
|
||||
timestamp={log.timestamp.toString()}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.OVERVIEW && (
|
||||
<div className="log-detail-drawer__footer-hint">
|
||||
<div className="log-detail-drawer__footer-hint-content">
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
className="log-detail-drawer__footer-hint-text"
|
||||
>
|
||||
Use
|
||||
</Typography.Text>
|
||||
<ArrowUp size={14} className="log-detail-drawer__footer-hint-icon" />
|
||||
<span>/</span>
|
||||
<ArrowDown size={14} className="log-detail-drawer__footer-hint-icon" />
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
className="log-detail-drawer__footer-hint-text"
|
||||
>
|
||||
to view previous/next log
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -518,7 +518,10 @@ function QueryAddOns({
|
||||
type="number"
|
||||
onChange={handleChangeLimit}
|
||||
initialValue={query?.limit ?? undefined}
|
||||
placeholder="Enter limit"
|
||||
placeholder={
|
||||
resolvedFields.get(QueryBuilderField.Limit)?.placeholder ??
|
||||
'Enter limit'
|
||||
}
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
|
||||
closeIcon={<ChevronUp size={16} />}
|
||||
/>
|
||||
|
||||
@@ -43,7 +43,9 @@ function QueryAggregationOptions({
|
||||
|
||||
if (
|
||||
isNonMetricSource &&
|
||||
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.SCATTER ||
|
||||
panelType === PANEL_TYPES.PIE)
|
||||
) {
|
||||
return { hidden: true, disabled: false, reason: undefined };
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
() =>
|
||||
dataSource === DataSource.METRICS &&
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.SCATTER ||
|
||||
panelType === PANEL_TYPES.PIE ||
|
||||
panelType === PANEL_TYPES.VALUE),
|
||||
[dataSource, panelType],
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from 'tests/test-utils';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderField } from '../queryBuilderFields.types';
|
||||
import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
|
||||
|
||||
// Mocks: only what is required for this component to render and for us to assert handler calls
|
||||
@@ -169,6 +170,33 @@ describe('QueryAddOns', () => {
|
||||
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
|
||||
});
|
||||
|
||||
it('shows the configured limit placeholder', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery()}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.SCATTER}
|
||||
index={0}
|
||||
fieldsConfig={{
|
||||
[QueryBuilderField.Limit]: {
|
||||
state: 'defaulted',
|
||||
placeholder: 'Default 10,000',
|
||||
},
|
||||
}}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('query-add-on-limit'));
|
||||
|
||||
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
|
||||
'placeholder',
|
||||
'Default 10,000',
|
||||
);
|
||||
});
|
||||
|
||||
it('auto-opens Order By and Limit when present in query', () => {
|
||||
const query = baseQuery({
|
||||
orderBy: [{ columnName: 'duration', order: 'desc' }],
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import QueryAggregationOptions from '../QueryV2/QueryAggregation/QueryAggregation';
|
||||
|
||||
jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
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', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,19 @@ describe('resolveQueryBuilderField', () => {
|
||||
expect(resolved.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a defaulted field as is, carrying its placeholder', () => {
|
||||
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
|
||||
[QueryBuilderField.Limit]: { state: 'defaulted', placeholder: 'Default 10' },
|
||||
});
|
||||
|
||||
expect(resolved).toStrictEqual({
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
pinned: false,
|
||||
placeholder: 'Default 10',
|
||||
});
|
||||
});
|
||||
|
||||
it('only ever resolves one state at a time', () => {
|
||||
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
|
||||
[QueryBuilderField.Limit]: { state: 'disabled', reason: 'why' },
|
||||
|
||||
@@ -22,8 +22,12 @@ export enum QueryBuilderField {
|
||||
AdditionalQueries = 'additional_queries',
|
||||
}
|
||||
|
||||
/** `reason` is required on `disabled`: an inert control the user can see has to explain itself. */
|
||||
/**
|
||||
* `reason` is required on `disabled`: an inert control the user can see has to explain itself.
|
||||
* `defaulted` leaves the field as is; its placeholder names the value an empty field falls back to.
|
||||
*/
|
||||
export type QueryBuilderFieldRule =
|
||||
| { state: 'defaulted'; placeholder: string }
|
||||
| { state: 'hidden' }
|
||||
| { state: 'disabled'; reason: string }
|
||||
| { state: 'pinned' };
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ResolvedQueryBuilderField {
|
||||
reason?: string;
|
||||
/** Rendered open, not dismissable, and kept out of the add-on toggle bar. */
|
||||
pinned: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const AVAILABLE: ResolvedQueryBuilderField = {
|
||||
@@ -20,6 +21,8 @@ const AVAILABLE: ResolvedQueryBuilderField = {
|
||||
|
||||
function fromRule(rule: QueryBuilderFieldRule): ResolvedQueryBuilderField {
|
||||
switch (rule.state) {
|
||||
case 'defaulted':
|
||||
return { ...AVAILABLE, placeholder: rule.placeholder };
|
||||
case 'hidden':
|
||||
return { hidden: true, disabled: false, pinned: false };
|
||||
case 'disabled':
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -337,6 +337,7 @@ export enum PANEL_TYPES {
|
||||
TRACE = 'trace',
|
||||
BAR = 'bar',
|
||||
AREA = 'area',
|
||||
SCATTER = 'scatter',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
HEATMAP = 'heatmap',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
@@ -149,6 +150,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),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Surface matches the shared Tooltip: same tokens, same radius, no shadow.
|
||||
// Padding lives on the sections so a footer can reach the container edges.
|
||||
.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;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
// The group values name the point; the channels are what it says.
|
||||
.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;
|
||||
}
|
||||
114
frontend/src/lib/uPlotV2/components/Tooltip/ScatterTooltip.tsx
Normal file
114
frontend/src/lib/uPlotV2/components/Tooltip/ScatterTooltip.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Pin } from '@signozhq/icons';
|
||||
|
||||
import { ScatterTooltipProps } from '../types';
|
||||
import { buildChannelRows, resolveHoveredPoint } from './scatterTooltipContent';
|
||||
|
||||
import Styles from './ScatterTooltip.module.scss';
|
||||
|
||||
/**
|
||||
* One point, its channels, then the group values that name it. Purpose-built
|
||||
* rather than composed from the shared `Tooltip`, whose list is one row per
|
||||
* series at a shared x; a scatter point has no such neighbours.
|
||||
*/
|
||||
export default function ScatterTooltip({
|
||||
uPlotInstance,
|
||||
dataIndexes,
|
||||
seriesIndex,
|
||||
channels,
|
||||
resolvePointLabels,
|
||||
decimalPrecision,
|
||||
isPinned,
|
||||
dismiss,
|
||||
renderTooltipFooter,
|
||||
}: ScatterTooltipProps): JSX.Element | null {
|
||||
const point = useMemo(
|
||||
() => resolveHoveredPoint(uPlotInstance, seriesIndex, dataIndexes),
|
||||
[uPlotInstance, seriesIndex, dataIndexes],
|
||||
);
|
||||
|
||||
const rows = useMemo(
|
||||
() => (point ? buildChannelRows(point, channels, decimalPrecision) : []),
|
||||
[point, channels, decimalPrecision],
|
||||
);
|
||||
|
||||
const labels = useMemo(
|
||||
() =>
|
||||
point
|
||||
? (resolvePointLabels?.(point.seriesIndex, point.dataIndex) ?? [])
|
||||
: [],
|
||||
[point, resolvePointLabels],
|
||||
);
|
||||
|
||||
if (!point) {
|
||||
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: point.color, borderColor: point.color }}
|
||||
/>
|
||||
<span
|
||||
className={Styles.title}
|
||||
title={point.label}
|
||||
data-testid="scatter-tooltip-title"
|
||||
>
|
||||
{point.label}
|
||||
</span>
|
||||
{isPinned && (
|
||||
<span className={Styles.status} data-testid="scatter-tooltip-status">
|
||||
<Pin size={12} />
|
||||
<span>Pinned</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} />
|
||||
|
||||
<div className={Styles.rows}>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={Styles.row}
|
||||
data-testid="scatter-tooltip-row"
|
||||
>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowValue}>{row.value}</span>
|
||||
</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"
|
||||
>
|
||||
<span className={Styles.rowLabel} title={label.key}>
|
||||
{label.key}
|
||||
</span>
|
||||
<span className={Styles.rowValue} title={label.value}>
|
||||
{label.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/** The point the cursor resolved to: the focused series' own index into its columns. */
|
||||
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;
|
||||
}
|
||||
@@ -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,17 @@ export interface HeatmapTooltipProps
|
||||
groupColor: string;
|
||||
}
|
||||
|
||||
/** Not part of `TooltipProps`: it describes one point's channels, not a series list. */
|
||||
export interface ScatterTooltipProps
|
||||
extends BaseTooltipProps, TooltipRenderArgs {
|
||||
channels: ScatterChannels;
|
||||
/** The group values behind a point, e.g. `service.name` → `cart`. */
|
||||
resolvePointLabels?: (
|
||||
seriesIndex: number,
|
||||
dataIndex: number,
|
||||
) => ScatterPointLabel[];
|
||||
}
|
||||
|
||||
export type TooltipProps =
|
||||
| TimeSeriesTooltipProps
|
||||
| BarTooltipProps
|
||||
|
||||
@@ -39,6 +39,16 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
};
|
||||
}
|
||||
|
||||
/** The axis line reads as the outermost grid line, so it takes the grid's style. */
|
||||
private buildBorderConfig(
|
||||
grid: uPlot.Axis.Grid | undefined,
|
||||
): uPlot.Axis.Border | undefined {
|
||||
if (this.props.showBorder === false || !grid) {
|
||||
return undefined;
|
||||
}
|
||||
return { show: true, stroke: grid.stroke, width: grid.width };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ticks configuration
|
||||
*/
|
||||
@@ -58,32 +68,49 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build values formatter for X-axis (time)
|
||||
* Build values formatter for X-axis: time, or a value axis when a unit or
|
||||
* precision is given (scatter). Neither leaves uPlot's numeric default.
|
||||
*/
|
||||
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)
|
||||
* Build values formatter for a value axis (values with units). A split outside
|
||||
* the scale's range gets no label: uPlot's arcsinh splits always include
|
||||
* ±threshold, and it would draw that label past the plot's edge.
|
||||
*/
|
||||
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 +128,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
return scaleKey === 'x'
|
||||
? this.buildXAxisValuesFormatter()
|
||||
: scaleKey === 'y'
|
||||
? this.buildYAxisValuesFormatter()
|
||||
? this.buildValueAxisFormatter()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -149,10 +176,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 +206,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 +221,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
if (space !== undefined) {
|
||||
axisConfig.space = space;
|
||||
}
|
||||
if (filter) {
|
||||
axisConfig.filter = filter;
|
||||
}
|
||||
if (size) {
|
||||
axisConfig.size = size;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ConfigBuilder,
|
||||
ConfigBuilderProps,
|
||||
LegendItem,
|
||||
PlotMode,
|
||||
SelectionPreferencesSource,
|
||||
StackMode,
|
||||
} from './types';
|
||||
@@ -65,6 +66,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
|
||||
private stackMode: StackMode = StackMode.None;
|
||||
|
||||
private mode: PlotMode = PlotMode.Aligned;
|
||||
|
||||
private cursor: Cursor | undefined;
|
||||
|
||||
private hooks: Hooks.Arrays = {};
|
||||
@@ -160,6 +163,15 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
return this.stackMode;
|
||||
}
|
||||
|
||||
/** Faceted series carry their own x column each; see `SeriesProps.facets`. */
|
||||
setMode(mode: PlotMode): void {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
getMode(): PlotMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or merge a scale configuration
|
||||
*/
|
||||
@@ -512,6 +524,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;
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export class UPlotScaleBuilder extends ConfigBuilder<
|
||||
time,
|
||||
distr,
|
||||
logBase,
|
||||
asinhThreshold: this.props.asinhThreshold,
|
||||
});
|
||||
|
||||
const { rangeConfig, hardMinOnly, hardMaxOnly, hasFixedRange } =
|
||||
|
||||
@@ -87,6 +87,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) {
|
||||
@@ -122,7 +124,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
return { paths: pathBuilder };
|
||||
}
|
||||
|
||||
if (drawStyle === DrawStyle.Points) {
|
||||
// Scatter without a `pathBuilder` has nothing to draw its discs with.
|
||||
if (drawStyle === DrawStyle.Points || drawStyle === DrawStyle.Scatter) {
|
||||
return { paths: (): null => null };
|
||||
}
|
||||
|
||||
@@ -194,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;
|
||||
}
|
||||
|
||||
@@ -218,7 +225,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
}
|
||||
|
||||
getConfig(): ExtendedSeries {
|
||||
const { scaleKey, label, spanGaps, show = true, metric } = this.props;
|
||||
const { scaleKey, label, spanGaps, show = true, metric, facets } = this.props;
|
||||
|
||||
const resolvedLineColor = this.getLineColor();
|
||||
|
||||
@@ -246,6 +253,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
...pathConfig,
|
||||
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
|
||||
metric,
|
||||
...(facets && { facets }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,35 @@ 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('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 +405,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', '']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -651,3 +656,15 @@ describe('UPlotConfigBuilder stacking', () => {
|
||||
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
|
||||
});
|
||||
});
|
||||
|
||||
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.getMode()).toBe(PlotMode.Faceted);
|
||||
expect(faceted.getConfig().mode).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -399,3 +399,39 @@ describe('UPlotSeriesBuilder', () => {
|
||||
expect(builder.getConfig().fill).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,8 +78,12 @@ export interface AxisProps {
|
||||
};
|
||||
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
|
||||
values?: uPlot.Axis.Values;
|
||||
/** Explicit axis splits, overriding the default tick calculation. */
|
||||
/** Explicit tick positions, replacing uPlot's per-distribution default. */
|
||||
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,8 @@ 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 value ticks are formatted in (`spec.formatting.unit`). Named for the
|
||||
* y axis, the only value axis until scatter; 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 +114,15 @@ export interface AxisProps {
|
||||
export enum DistributionType {
|
||||
Linear = 'linear',
|
||||
Logarithmic = 'logarithmic',
|
||||
/** arcsinh: linear within ±`asinhThreshold`, logarithmic beyond. Takes zero and
|
||||
* negatives, which a plain log cannot place. */
|
||||
SymmetricLog = 'symlog',
|
||||
}
|
||||
|
||||
/** uPlot's data layout: one shared x per chart, or per-series x/y columns. */
|
||||
export enum PlotMode {
|
||||
Aligned = 1,
|
||||
Faceted = 2,
|
||||
}
|
||||
|
||||
export interface ScaleProps {
|
||||
@@ -125,6 +139,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 +162,8 @@ export enum DrawStyle {
|
||||
Points = 'points',
|
||||
Bar = 'bar',
|
||||
Histogram = 'histogram',
|
||||
/** Faceted (mode 2) discs at per-series x/y, drawn by the caller's `pathBuilder`. */
|
||||
Scatter = 'scatter',
|
||||
}
|
||||
|
||||
export enum LineInterpolation {
|
||||
@@ -229,6 +247,8 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
isDarkMode?: boolean;
|
||||
stepInterval?: number;
|
||||
metric?: { [key: string]: string };
|
||||
/** Mode 2 only: the scales the series' own x and y columns are read against. */
|
||||
facets?: Series.Facet[];
|
||||
}
|
||||
|
||||
export interface LegendItem {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
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();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { PlotMode } from '../../../config/types';
|
||||
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;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
(uPlot.orient as jest.Mock).mockImplementation(orientWithIdentityScales);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(uPlot.orient as jest.Mock).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(builder.getMode()).toBe(PlotMode.Faceted);
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
96
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/geometry.ts
Normal file
96
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/geometry.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extent of the size column across every series, so equal values draw equal
|
||||
* discs whichever group they belong to. `null` when nothing carries a size.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disc diameter for a size value. Area, not diameter, follows the value: a
|
||||
* point worth twice as much should look twice as big.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest disc under the cursor, or `null`. Overlapping discs resolve to the one
|
||||
* whose centre is closest; `tolerance` widens every disc so thin points stay
|
||||
* hoverable.
|
||||
*/
|
||||
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 left = hit.x - tolerance;
|
||||
const top = hit.y - tolerance;
|
||||
const right = hit.x + hit.w + tolerance;
|
||||
const bottom = hit.y + hit.h + tolerance;
|
||||
if (cx < left || cx > right || cy < top || cy > bottom) {
|
||||
return;
|
||||
}
|
||||
const dx = cx - (hit.x + hit.w / 2);
|
||||
const dy = cy - (hit.y + hit.h / 2);
|
||||
const distance = dx * dx + dy * dy;
|
||||
if (distance < bestDistance) {
|
||||
best = hit;
|
||||
bestDistance = distance;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return best;
|
||||
}
|
||||
234
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin.ts
Normal file
234
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
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';
|
||||
|
||||
/** Every scatter series reads its own x and y columns against the shared scales. */
|
||||
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 `ceil`s a cursor point's left/top but not its size, which pulls a ring
|
||||
* up to 1px right and down of the disc. Snapping the edge to a whole pixel and
|
||||
* growing the span by twice the snap keeps the centre exact.
|
||||
*/
|
||||
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 {
|
||||
/** Draws every point of a series as one path and indexes the discs for hover. */
|
||||
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;
|
||||
|
||||
// The domain spans every series, so it is resolved once per dataset rather than
|
||||
// once per series path.
|
||||
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 };
|
||||
},
|
||||
// An outline around the disc, so the point itself stays readable.
|
||||
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 only knows what the path builder last drew, so cached paths
|
||||
// must be rebuilt alongside it.
|
||||
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);
|
||||
}
|
||||
58
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/types.ts
Normal file
58
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* One faceted series: parallel columns, one point per index. Sizes are in the
|
||||
* caller's units and mapped to `pointSize` at draw time; `null` 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;
|
||||
}
|
||||
|
||||
/** What each visual channel plots, for the tooltip and axes. */
|
||||
export interface ScatterChannels {
|
||||
x: ScatterChannel;
|
||||
y: ScatterChannel;
|
||||
size?: ScatterChannel;
|
||||
}
|
||||
|
||||
export interface ScatterPointLabel {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -174,6 +174,25 @@ 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,
|
||||
};
|
||||
|
||||
// A faceted plot (scatter) has no shared x series, so `data[0]` is null and
|
||||
// `posToIdx` would throw; the hit is the plot's own to resolve.
|
||||
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 +212,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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('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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
70
frontend/src/lib/uPlotV2/utils/__tests__/quadtree.test.ts
Normal file
70
frontend/src/lib/uPlotV2/utils/__tests__/quadtree.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
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);
|
||||
});
|
||||
|
||||
it('clear empties the tree', () => {
|
||||
const tree = new Quadtree<Item>(0, 0, 100, 100);
|
||||
tree.add({ id: 1, x: 10, y: 10, w: 5, h: 5 });
|
||||
tree.clear();
|
||||
|
||||
expect(collect(tree, 0, 0, 100, 100).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -195,3 +195,40 @@ 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);
|
||||
});
|
||||
|
||||
it('ranges a distr 4 scale through uPlot.rangeAsinh', () => {
|
||||
const rangeAsinh = jest.fn(() => [-10, 1000] as uPlot.Range.MinMax);
|
||||
Object.assign(uPlot, { rangeAsinh });
|
||||
|
||||
const rangeFn = scaleUtils.createRangeFunction({
|
||||
rangeConfig: {} as uPlot.Range.Config,
|
||||
hardMinOnly: false,
|
||||
hardMaxOnly: false,
|
||||
hasFixedRange: false,
|
||||
min: null,
|
||||
max: null,
|
||||
});
|
||||
const u = { scales: { y: { distr: 4, log: 10 } } } as unknown as uPlot;
|
||||
|
||||
expect(rangeFn(u, -3, 700, 'y')).toStrictEqual([-10, 1000]);
|
||||
expect(rangeAsinh).toHaveBeenCalledWith(-3, 700, 10, true);
|
||||
});
|
||||
});
|
||||
|
||||
69
frontend/src/lib/uPlotV2/utils/logGridSplits.ts
Normal file
69
frontend/src/lib/uPlotV2/utils/logGridSplits.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
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];
|
||||
|
||||
/**
|
||||
* Splits at 1, 2 and 5 × 10ⁿ inside the scale. uPlot's own log splits step
|
||||
* from the scale's minimum, so a range not snapped to a power of ten would
|
||||
* label 10.79, 21.58, … instead.
|
||||
*/
|
||||
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) => {
|
||||
// Rounded through toPrecision so 0.1 × 3 noise can't fall outside [min, max].
|
||||
const split = Number((multiple * 10 ** exponent).toPrecision(12));
|
||||
if (split >= min && split <= max) {
|
||||
splits.push(split);
|
||||
}
|
||||
});
|
||||
}
|
||||
return splits;
|
||||
};
|
||||
|
||||
/**
|
||||
* Labels for `logScaleSplits`: every power of ten, then any 2 or 5 that sits
|
||||
* at least `space` px from the labels already kept, so a range spanning under
|
||||
* one decade still reads at both ends.
|
||||
*/
|
||||
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));
|
||||
};
|
||||
110
frontend/src/lib/uPlotV2/utils/quadtree.ts
Normal file
110
frontend/src/lib/uPlotV2/utils/quadtree.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
export interface QuadtreeRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
const MAX_OBJECTS = 10;
|
||||
const MAX_LEVELS = 4;
|
||||
|
||||
/**
|
||||
* Spatial index over axis-aligned rectangles, for answering "what is under the
|
||||
* cursor" on charts whose marks have no shared x order to binary-search. An item
|
||||
* straddling a quadrant boundary lives in every quadrant it touches, so `get` can
|
||||
* report it more than once.
|
||||
*/
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.items = [];
|
||||
this.quadrants = null;
|
||||
}
|
||||
|
||||
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;
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -197,6 +210,33 @@ function getLogScaleRange(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the arcsinh-scale range using uPlot.rangeAsinh, which pads to whole
|
||||
* magnitudes on either side of zero and pins an edge that sits exactly on zero.
|
||||
*/
|
||||
function getAsinhScaleRange(
|
||||
minMax: Range.MinMax,
|
||||
params: RangeFunctionParams,
|
||||
dataMin: number | null,
|
||||
dataMax: number | null,
|
||||
logBase?: uPlot.Scale['log'],
|
||||
): Range.MinMax {
|
||||
const { min, max } = params;
|
||||
const resolvedMin = min ?? dataMin;
|
||||
const resolvedMax = max ?? dataMax;
|
||||
|
||||
if (resolvedMin == null || resolvedMax == null) {
|
||||
return minMax;
|
||||
}
|
||||
|
||||
return uPlot.rangeAsinh(
|
||||
resolvedMin,
|
||||
resolvedMax,
|
||||
(logBase ?? 10) as 2 | 10,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snaps log-scale [min, max] to exact powers of logBase (nearest magnitude below/above).
|
||||
* If min and max would be equal after snapping, max is increased by one magnitude so the range is valid.
|
||||
@@ -299,6 +339,8 @@ export function createRangeFunction(
|
||||
minMax = getLogScaleRange(minMax, params, dataMin, dataMax, logBase);
|
||||
const logFn = scale.log === 2 ? Math.log2 : Math.log10;
|
||||
minMax = adjustLogRange(minMax, (logBase ?? 10) as number, logFn);
|
||||
} else if (scale.distr === 4) {
|
||||
minMax = getAsinhScaleRange(minMax, params, dataMin, dataMax, logBase);
|
||||
}
|
||||
|
||||
minMax = applyHardLimits(minMax, params, scale.distr ?? 1);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useMemo, useState } 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 {
|
||||
buildScatterConfig,
|
||||
prepareScatterChartData,
|
||||
ScatterAxisScale,
|
||||
ScatterSeries,
|
||||
} 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 THRESHOLDS: Threshold[] = [
|
||||
{
|
||||
thresholdValue: 300,
|
||||
thresholdUnit: 'ms',
|
||||
thresholdColor: '#E5484D',
|
||||
thresholdLabel: 'p99 SLO',
|
||||
},
|
||||
];
|
||||
|
||||
function ScatterStory(props: ScatterStoryProps): JSX.Element {
|
||||
const {
|
||||
xScale,
|
||||
yScale,
|
||||
sized,
|
||||
thresholds,
|
||||
pointSize,
|
||||
fillOpacity,
|
||||
width,
|
||||
height,
|
||||
} = props;
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [drawMs, setDrawMs] = useState<number | null>(null);
|
||||
|
||||
const series = useMemo(() => buildSeries(props), [props]);
|
||||
const pointCount = series.reduce((sum, entry) => sum + entry.xs.length, 0);
|
||||
const drawLabel = drawMs === null ? '—' : `${drawMs.toFixed(1)} ms`;
|
||||
|
||||
const config = useMemo(() => {
|
||||
const builder = buildScatterConfig({
|
||||
id: 'scatter-story',
|
||||
series,
|
||||
isDarkMode,
|
||||
x: { unit: 'reqps', scale: xScale },
|
||||
y: { unit: 'ms', scale: yScale },
|
||||
pointSize: { fixed: pointSize, min: 4, max: pointSize * 4 },
|
||||
fillOpacity,
|
||||
thresholds: thresholds ? THRESHOLDS : undefined,
|
||||
});
|
||||
let started = 0;
|
||||
builder.addHook('drawClear', (): void => {
|
||||
started = performance.now();
|
||||
});
|
||||
builder.addHook('draw', (): void => {
|
||||
setDrawMs(performance.now() - started);
|
||||
});
|
||||
return builder;
|
||||
}, [series, isDarkMode, xScale, yScale, pointSize, fillOpacity, thresholds]);
|
||||
|
||||
const data = useMemo(() => prepareScatterChartData(series), [series]);
|
||||
|
||||
const resolvePointLabels = (
|
||||
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' } }),
|
||||
}}
|
||||
resolvePointLabels={resolvePointLabels}
|
||||
canPinTooltip
|
||||
/>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', fontSize: 12, opacity: 0.7 }}>
|
||||
{`${pointCount.toLocaleString()} points · last draw ${drawLabel}`}
|
||||
</p>
|
||||
</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' },
|
||||
};
|
||||
|
||||
/** Horizontal line with label on the y axis; the scale stretches to include it. */
|
||||
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 },
|
||||
};
|
||||
102
frontend/src/lib/visualization/charts/Scatter/Scatter.tsx
Normal file
102
frontend/src/lib/visualization/charts/Scatter/Scatter.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
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 type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { ScatterChartProps } from 'lib/visualization/charts/types';
|
||||
|
||||
import { getSeriesStroke } from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
|
||||
|
||||
import { getCursorHit } from './utils';
|
||||
|
||||
// Faceted uPlot reads series 1's facets at init, so a chart with no series cannot
|
||||
// mount; empty aligned data makes the shell show its no-data state instead.
|
||||
const EMPTY_ALIGNED_DATA: uPlot.AlignedData = [[]];
|
||||
|
||||
export default function Scatter(props: ScatterChartProps): JSX.Element {
|
||||
const {
|
||||
children,
|
||||
customTooltip,
|
||||
channels,
|
||||
resolvePointLabels,
|
||||
pinnedTooltipElement,
|
||||
onPointClick,
|
||||
plotRef,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
|
||||
const handlePlotRef = useCallback(
|
||||
(plot: uPlot | null): void => {
|
||||
plotInstanceRef.current = plot;
|
||||
plotRef?.(plot);
|
||||
},
|
||||
[plotRef],
|
||||
);
|
||||
|
||||
// The shared click data finds its series by x position, which a faceted plot
|
||||
// has no single axis for; the hit comes from the cursor instead.
|
||||
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,
|
||||
resolvePointLabels,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
renderTooltipFooter: rest.renderTooltipFooter,
|
||||
};
|
||||
return <ScatterTooltip {...tooltipProps} />;
|
||||
},
|
||||
[
|
||||
customTooltip,
|
||||
channels,
|
||||
resolvePointLabels,
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { DistributionType } from 'lib/uPlotV2/config/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { createScatterRange, type ScatterRangeOptions } from '../scatterRange';
|
||||
|
||||
/** 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]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import type uPlot from 'uplot';
|
||||
import { DistributionType } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import {
|
||||
buildScatterConfig,
|
||||
getCursorHit,
|
||||
prepareScatterChartData,
|
||||
resolveAxisDistribution,
|
||||
ScatterAxisScale,
|
||||
ScatterSeries,
|
||||
} 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();
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it.each([
|
||||
[
|
||||
'positive values spanning three decades',
|
||||
[1, 1000],
|
||||
DistributionType.Logarithmic,
|
||||
],
|
||||
['positive values spanning less', [1, 999], DistributionType.Linear],
|
||||
['a wide span that includes zero', [0, 1, 1e6], DistributionType.Linear],
|
||||
['no finite values', [Number.NaN], DistributionType.Linear],
|
||||
])('picks a scale for %s', (_, values, distribution) => {
|
||||
expect(resolveAxisDistribution(values, ScatterAxisScale.Auto)).toStrictEqual(
|
||||
{ distribution },
|
||||
);
|
||||
});
|
||||
|
||||
it('stays linear when a soft limit reaches zero or below', () => {
|
||||
expect(
|
||||
resolveAxisDistribution([0.02, 5000], ScatterAxisScale.Auto, [-10]),
|
||||
).toStrictEqual({ distribution: DistributionType.Linear });
|
||||
});
|
||||
|
||||
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([1, 1e4])).toStrictEqual({
|
||||
distribution: DistributionType.Logarithmic,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
thresholds: [{ thresholdValue: 300, thresholdUnit: 'ms' }],
|
||||
}).getConfig();
|
||||
|
||||
expect(config.hooks?.draw).toHaveLength(1);
|
||||
expect(build().getConfig().hooks?.draw).toBeUndefined();
|
||||
});
|
||||
});
|
||||
108
frontend/src/lib/visualization/charts/Scatter/scatterRange.ts
Normal file
108
frontend/src/lib/visualization/charts/Scatter/scatterRange.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { DistributionType } from 'lib/uPlotV2/config/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { AxisDistribution } from './utils';
|
||||
|
||||
interface ScaleTransform {
|
||||
forward: (value: number) => number;
|
||||
inverse: (position: number) => number;
|
||||
}
|
||||
|
||||
/** The space uPlot lays the scale out in, so a pixel margin is the same size along it. */
|
||||
function getScaleTransform({
|
||||
distribution,
|
||||
asinhThreshold = 1,
|
||||
}: 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Half the span a single value spreads to, 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;
|
||||
}
|
||||
|
||||
export interface ScatterRangeOptions extends AxisDistribution {
|
||||
/** The axis this scale is laid out along. */
|
||||
dimension: 'x' | 'y';
|
||||
/** CSS pixels kept clear at each end so the largest dot draws whole. */
|
||||
marginPx: number;
|
||||
softMin?: number | null;
|
||||
softMax?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fits the scale to the data and then widens it by `marginPx` at each end.
|
||||
* uPlot clips series to the plot area, so a dot at the extreme value would
|
||||
* otherwise lose the half that hangs past the edge. Log scales are left
|
||||
* unsnapped: rounding out to the next power of ten after the margin can add a
|
||||
* whole 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)];
|
||||
};
|
||||
}
|
||||
328
frontend/src/lib/visualization/charts/Scatter/utils.ts
Normal file
328
frontend/src/lib/visualization/charts/Scatter/utils.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
type AxisProps,
|
||||
DistributionType,
|
||||
DrawStyle,
|
||||
SelectionPreferencesSource,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { Threshold } from 'lib/uPlotV2/hooks/types';
|
||||
import {
|
||||
applyScatterPlugin,
|
||||
createScatterPlugin,
|
||||
SCATTER_FACETS,
|
||||
} from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
|
||||
import {
|
||||
DEFAULT_SCATTER_POINT_SIZE,
|
||||
ScatterChartData,
|
||||
ScatterPointSize,
|
||||
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 { createScatterRange } from './scatterRange';
|
||||
|
||||
/** Circle outline; the fill carries the colour. */
|
||||
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;
|
||||
|
||||
export interface ScatterSeries {
|
||||
/** Group label, as the legend names it. */
|
||||
label: string;
|
||||
xs: number[];
|
||||
ys: number[];
|
||||
/** Optional third channel, in the caller's units. */
|
||||
sizes?: Array<number | null>;
|
||||
}
|
||||
|
||||
export enum ScatterAxisScale {
|
||||
/** Log when the values are positive and span several decades, else linear. */
|
||||
Auto = 'auto',
|
||||
Linear = 'linear',
|
||||
Log = 'log',
|
||||
/** Log-like, but places zero and negatives. */
|
||||
SymLog = 'symlog',
|
||||
}
|
||||
|
||||
/** Decades of positive values `auto` needs before it picks a log axis. */
|
||||
export const AUTO_LOG_MIN_DECADES = 3;
|
||||
|
||||
export interface ScatterAxisOptions {
|
||||
/** Axis title. */
|
||||
label?: string;
|
||||
unit?: string;
|
||||
softMin?: number | null;
|
||||
softMax?: number | null;
|
||||
scale?: ScatterAxisScale;
|
||||
}
|
||||
|
||||
export interface BuildScatterConfigArgs {
|
||||
id: string;
|
||||
series: ScatterSeries[];
|
||||
isDarkMode: boolean;
|
||||
x: ScatterAxisOptions;
|
||||
y: ScatterAxisOptions;
|
||||
pointSize?: ScatterPointSize;
|
||||
/** 0–1. */
|
||||
fillOpacity?: number;
|
||||
colorMapping?: Record<string, string>;
|
||||
/** Drawn on the y axis. */
|
||||
thresholds?: Threshold[];
|
||||
decimalPrecision?: PrecisionOption;
|
||||
selectionPreferencesSource?: SelectionPreferencesSource;
|
||||
shouldSaveSelectionPreference?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dot under the cursor, read off uPlot's cursor: the scatter plugin's
|
||||
* `dataIdx` answers only for the hit series, so its index is the one set.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
export interface AxisDistribution {
|
||||
distribution: DistributionType;
|
||||
asinhThreshold?: number;
|
||||
}
|
||||
|
||||
function getPositiveRange(values: number[]): {
|
||||
minPositive: number;
|
||||
maxPositive: number;
|
||||
hasNonPositive: boolean;
|
||||
} {
|
||||
let minPositive = Infinity;
|
||||
let maxPositive = -Infinity;
|
||||
let hasNonPositive = false;
|
||||
for (const value of values) {
|
||||
if (!Number.isFinite(value)) {
|
||||
continue;
|
||||
}
|
||||
if (value <= 0) {
|
||||
hasNonPositive = true;
|
||||
} else {
|
||||
minPositive = Math.min(minPositive, value);
|
||||
maxPositive = Math.max(maxPositive, value);
|
||||
}
|
||||
}
|
||||
return { minPositive, maxPositive, hasNonPositive };
|
||||
}
|
||||
|
||||
/**
|
||||
* The symmetric log's linear band ends at the smallest non-zero magnitude, so
|
||||
* the small values still spread out.
|
||||
*/
|
||||
function symmetricLogDistribution(minPositive: number): AxisDistribution {
|
||||
const asinhThreshold = Number.isFinite(minPositive)
|
||||
? 10 ** Math.floor(Math.log10(minPositive))
|
||||
: 1;
|
||||
return { distribution: DistributionType.SymmetricLog, asinhThreshold };
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain log axis cannot place zero or negatives, so `log` falls back to the
|
||||
* symmetric log rather than lose those points; callers that would rather drop
|
||||
* them filter first.
|
||||
*/
|
||||
export function resolveAxisDistribution(
|
||||
values: number[],
|
||||
scale: ScatterAxisScale = ScatterAxisScale.Auto,
|
||||
/**
|
||||
* Soft limits (and thresholds) the axis must reach. Only `auto` weighs them:
|
||||
* a soft min at or below zero rules out log. An explicit scale keeps its
|
||||
* choice, and a log axis drops a bound it cannot place.
|
||||
*/
|
||||
softLimits: Array<number | null | undefined> = [],
|
||||
): AxisDistribution {
|
||||
const { minPositive, hasNonPositive } = getPositiveRange(values);
|
||||
switch (scale) {
|
||||
case ScatterAxisScale.Linear:
|
||||
return { distribution: DistributionType.Linear };
|
||||
case ScatterAxisScale.SymLog:
|
||||
return symmetricLogDistribution(minPositive);
|
||||
case ScatterAxisScale.Log:
|
||||
return hasNonPositive
|
||||
? symmetricLogDistribution(minPositive)
|
||||
: { distribution: DistributionType.Logarithmic };
|
||||
case ScatterAxisScale.Auto:
|
||||
default: {
|
||||
const extent = getPositiveRange([
|
||||
...values,
|
||||
...softLimits.filter((limit): limit is number => limit != null),
|
||||
]);
|
||||
const spansDecades =
|
||||
Number.isFinite(extent.minPositive) &&
|
||||
Math.log10(extent.maxPositive / extent.minPositive) >= AUTO_LOG_MIN_DECADES;
|
||||
return !extent.hasNonPositive && spansDecades
|
||||
? { distribution: DistributionType.Logarithmic }
|
||||
: { distribution: DistributionType.Linear };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 }
|
||||
: {};
|
||||
}
|
||||
|
||||
export function buildScatterConfig({
|
||||
id,
|
||||
series,
|
||||
isDarkMode,
|
||||
x,
|
||||
y,
|
||||
pointSize = DEFAULT_SCATTER_POINT_SIZE,
|
||||
fillOpacity,
|
||||
colorMapping = {},
|
||||
thresholds,
|
||||
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 ySoftLimits = adjustSoftLimitsWithThresholds(
|
||||
y.softMin ?? null,
|
||||
y.softMax ?? null,
|
||||
thresholds,
|
||||
y.unit,
|
||||
);
|
||||
const xDistribution = resolveAxisDistribution(
|
||||
series.flatMap((entry) => entry.xs),
|
||||
x.scale,
|
||||
[x.softMin, x.softMax],
|
||||
);
|
||||
const yDistribution = resolveAxisDistribution(
|
||||
series.flatMap((entry) => entry.ys),
|
||||
y.scale,
|
||||
[ySoftLimits.softMin, ySoftLimits.softMax],
|
||||
);
|
||||
|
||||
const yThresholds =
|
||||
thresholds && thresholds.length > 0
|
||||
? { scaleKey: 'y', thresholds, yAxisUnit: y.unit }
|
||||
: undefined;
|
||||
|
||||
// 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,
|
||||
softMin: x.softMin ?? undefined,
|
||||
softMax: x.softMax ?? undefined,
|
||||
range: createScatterRange({
|
||||
dimension: 'x',
|
||||
marginPx,
|
||||
softMin: x.softMin,
|
||||
softMax: x.softMax,
|
||||
...xDistribution,
|
||||
}),
|
||||
...xDistribution,
|
||||
});
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
softMin: y.softMin ?? undefined,
|
||||
softMax: y.softMax ?? undefined,
|
||||
thresholds: yThresholds,
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
if (yThresholds) {
|
||||
builder.addThresholds(yThresholds);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -15,6 +15,10 @@ import type {
|
||||
HeatmapColorOptions,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import type {
|
||||
ScatterChannels,
|
||||
ScatterPointLabel,
|
||||
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
@@ -87,6 +91,27 @@ 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;
|
||||
/** The series' drawn colour. */
|
||||
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;
|
||||
resolvePointLabels?: (
|
||||
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
|
||||
|
||||
@@ -40,6 +40,8 @@ interface ConfigPaneProps {
|
||||
legendSeries: LegendSeries[];
|
||||
/** Table panel's resolved value columns, for the table-only editors. */
|
||||
tableColumns: TableColumnOption[];
|
||||
/** Group-by labels of a joined scalar result, for the scatter dimensions editor. */
|
||||
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,
|
||||
@@ -136,6 +139,7 @@ function ConfigPane({
|
||||
defaults={defaults}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
groupColumns={groupColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
@@ -163,6 +167,7 @@ function ConfigPane({
|
||||
defaults={defaults}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
groupColumns={groupColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { SectionEditorContext } from '../sectionContext';
|
||||
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
|
||||
import SettingsSection from '../SettingsSection/SettingsSection';
|
||||
import { isDifferent } from '../utils/changes';
|
||||
import { getScatterAxisColumnNames } from '../utils/scatterAxisColumns';
|
||||
import { getSizeColumnLabel } from '../utils/sizeColumnLabel';
|
||||
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
|
||||
|
||||
type SectionSlotProps = {
|
||||
@@ -63,6 +65,7 @@ function SectionSlot({
|
||||
bare,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
groupColumns,
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
@@ -74,7 +77,10 @@ function SectionSlot({
|
||||
const editor = resolveSectionEditor(config.kind);
|
||||
// Controlled so the header slot can expand on click; list sections open when populated.
|
||||
const [open, setOpen] = useState(() => {
|
||||
if (config.kind === SectionKind.Visualization) {
|
||||
if (
|
||||
config.kind === SectionKind.Visualization ||
|
||||
config.kind === SectionKind.Dimensions
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const value = editor?.get(spec);
|
||||
@@ -129,6 +135,9 @@ function SectionSlot({
|
||||
legendSeries,
|
||||
yAxisUnit,
|
||||
tableColumns,
|
||||
groupColumns,
|
||||
sizeColumnLabel: getSizeColumnLabel(spec, tableColumns),
|
||||
axisColumnNames: getScatterAxisColumnNames(spec, tableColumns),
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.select {
|
||||
width: 100%;
|
||||
|
||||
:global(.ant-select-selector) {
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Select } from 'antd';
|
||||
|
||||
import styles from './ConfigMultiSelect.module.scss';
|
||||
|
||||
export interface ConfigMultiSelectItem<T extends string = string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ConfigMultiSelectProps<T extends string = string> {
|
||||
testId: string;
|
||||
value: T[];
|
||||
/** Shown while nothing is selected, which is a valid choice. */
|
||||
placeholder?: string;
|
||||
items: ConfigMultiSelectItem<T>[];
|
||||
onChange: (value: T[]) => void;
|
||||
disabled?: boolean;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-select for the config sections; the picks show as tags. The order of
|
||||
* `items` is kept rather than the order they were picked in, so the same set
|
||||
* always reads (and is stored) the same way.
|
||||
*/
|
||||
function ConfigMultiSelect<T extends string = string>({
|
||||
testId,
|
||||
value,
|
||||
placeholder,
|
||||
items,
|
||||
onChange,
|
||||
disabled,
|
||||
'aria-label': ariaLabel,
|
||||
}: ConfigMultiSelectProps<T>): JSX.Element {
|
||||
return (
|
||||
<Select<T[]>
|
||||
mode="multiple"
|
||||
className={styles.select}
|
||||
data-testid={testId}
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
virtual={false}
|
||||
options={items}
|
||||
onChange={(next): void =>
|
||||
onChange(
|
||||
items
|
||||
.map((item) => item.value)
|
||||
.filter((candidate) => next.includes(candidate)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigMultiSelect;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import ConfigMultiSelect from '../ConfigMultiSelect';
|
||||
|
||||
const ITEMS = [
|
||||
{ value: 'a', label: 'alpha' },
|
||||
{ value: 'b', label: 'beta' },
|
||||
{ value: 'c', label: 'gamma' },
|
||||
];
|
||||
|
||||
async function open(): Promise<ReturnType<typeof userEvent.setup>> {
|
||||
const user = userEvent.setup();
|
||||
await user.click(
|
||||
screen
|
||||
.getByTestId('multi')
|
||||
.querySelector('.ant-select-selector') as HTMLElement,
|
||||
);
|
||||
return user;
|
||||
}
|
||||
|
||||
describe('ConfigMultiSelect', () => {
|
||||
it('shows the placeholder while nothing is picked', () => {
|
||||
render(
|
||||
<ConfigMultiSelect
|
||||
testId="multi"
|
||||
value={[]}
|
||||
placeholder="Every group key"
|
||||
items={ITEMS}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Every group key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps picks in item order, not the order they were picked', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ConfigMultiSelect
|
||||
testId="multi"
|
||||
value={['c']}
|
||||
items={ITEMS}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const user = await open();
|
||||
await user.click(await screen.findByRole('option', { name: 'alpha' }));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith(['a', 'c']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
// The design-system slider insets its track by half a thumb on each side so the
|
||||
// fill follows the thumb's centre. Pull that inset back off the row so the track
|
||||
// lines up with the other controls; the thumb never paints past the track edge.
|
||||
margin-inline: calc(var(--slider-thumb-width, 18px) / -2);
|
||||
}
|
||||
|
||||
.value {
|
||||
flex-shrink: 0;
|
||||
min-width: 56px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Slider } from '@signozhq/ui/slider';
|
||||
|
||||
import styles from './ConfigRangeSlider.module.scss';
|
||||
|
||||
interface ConfigRangeSliderProps {
|
||||
testId: string;
|
||||
value: [number, number];
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
/** Renders the current range beside the track (e.g. `4–24 px`). */
|
||||
formatValue?: (value: [number, number]) => string;
|
||||
onChange: (value: [number, number]) => void;
|
||||
}
|
||||
|
||||
/** Two-thumb slider for a min/max pair; the thumbs can meet but not cross. */
|
||||
function ConfigRangeSlider({
|
||||
testId,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
formatValue,
|
||||
onChange,
|
||||
}: ConfigRangeSliderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<Slider
|
||||
range
|
||||
testId={testId}
|
||||
className={styles.slider}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(next): void => {
|
||||
if (Array.isArray(next) && next.length === 2) {
|
||||
onChange([Math.min(next[0], next[1]), Math.max(next[0], next[1])]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className={styles.value}>
|
||||
{formatValue ? formatValue(value) : `${value[0]}–${value[1]}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigRangeSlider;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import ConfigRangeSlider from '../ConfigRangeSlider';
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('ConfigRangeSlider', () => {
|
||||
it('renders a thumb per end and the formatted range', () => {
|
||||
render(
|
||||
<ConfigRangeSlider
|
||||
testId="range"
|
||||
value={[4, 24]}
|
||||
min={2}
|
||||
max={40}
|
||||
step={1}
|
||||
formatValue={([min, max]): string => `${min}–${max} px`}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole('slider')).toHaveLength(2);
|
||||
expect(screen.getByText('4–24 px')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('moves one end and reports the pair low to high', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ConfigRangeSlider
|
||||
testId="range"
|
||||
value={[4, 24]}
|
||||
min={2}
|
||||
max={40}
|
||||
step={1}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const [, maxThumb] = screen.getAllByRole('slider');
|
||||
maxThumb.focus();
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith([4, 25]);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,12 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
export interface SectionEditorContext {
|
||||
legendSeries?: LegendSeries[];
|
||||
tableColumns?: TableColumnOption[];
|
||||
/** Group-by labels of the joined scalar result; the colour-by options. */
|
||||
groupColumns?: string[];
|
||||
/** Label of the value column a Scatter Plot sizes its dots by; unset when none is. */
|
||||
sizeColumnLabel?: string;
|
||||
/** Name of the column each Scatter Plot axis plots, for its label's placeholder. */
|
||||
axisColumnNames?: { x?: string; y?: string };
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
panelKind?: PanelKind;
|
||||
onChangePanelKind?: (kind: PanelKind) => void;
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
DashboardtypesHistogramBucketsDTO,
|
||||
DashboardtypesLegendDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesScatterPlotAxesDTO,
|
||||
DashboardtypesScatterPlotDimensionsDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
SectionKind,
|
||||
@@ -22,9 +24,11 @@ import AxesSection from './sections/AxesSection/AxesSection';
|
||||
import BucketsSection from './sections/BucketsSection/BucketsSection';
|
||||
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
|
||||
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
|
||||
import DimensionsSection from './sections/DimensionsSection/DimensionsSection';
|
||||
import FormattingSection from './sections/FormattingSection/FormattingSection';
|
||||
import LegendSection from './sections/LegendSection/LegendSection';
|
||||
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
|
||||
import ScatterAxesSection from './sections/ScatterAxesSection/ScatterAxesSection';
|
||||
import TextLayoutSection from './sections/TextLayoutSection/TextLayoutSection';
|
||||
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
|
||||
import VisualizationSection from './sections/VisualizationSection/VisualizationSection';
|
||||
@@ -86,6 +90,20 @@ export const SECTION_REGISTRY: {
|
||||
getPluginSlice<PanelAxesSlice>(spec, 'axes'),
|
||||
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
|
||||
},
|
||||
// Same `axes` key as above, in Scatter Plot's per-axis shape.
|
||||
[SectionKind.ScatterAxes]: {
|
||||
Component: ScatterAxesSection,
|
||||
get: (spec): DashboardtypesScatterPlotAxesDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesScatterPlotAxesDTO>(spec, 'axes'),
|
||||
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
|
||||
},
|
||||
[SectionKind.Dimensions]: {
|
||||
Component: DimensionsSection,
|
||||
get: (spec): DashboardtypesScatterPlotDimensionsDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesScatterPlotDimensionsDTO>(spec, 'dimensions'),
|
||||
update: (spec, dimensions): PanelSpec =>
|
||||
updatePluginSlice(spec, 'dimensions', dimensions),
|
||||
},
|
||||
[SectionKind.Legend]: {
|
||||
Component: LegendSection,
|
||||
get: (spec): DashboardtypesLegendDTO | undefined =>
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { SectionEditorContext } from '../../sectionContext';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
import DisconnectValuesField from './DisconnectValuesField';
|
||||
import HeatmapColorsField from './HeatmapColorsField';
|
||||
import PointsFields from './PointsFields';
|
||||
import {
|
||||
FILL_MODE_OPTIONS,
|
||||
FILLED_FILL_MODE_OPTIONS,
|
||||
@@ -39,8 +40,9 @@ function ChartAppearanceSection({
|
||||
controls,
|
||||
onChange,
|
||||
stepInterval,
|
||||
sizeColumnLabel,
|
||||
}: SectionEditorProps<SectionKind.ChartAppearance> &
|
||||
Pick<SectionEditorContext, 'stepInterval'>): JSX.Element {
|
||||
Pick<SectionEditorContext, 'stepInterval' | 'sizeColumnLabel'>): JSX.Element {
|
||||
const reset = createFieldResetter(value, savedValue, onChange);
|
||||
const interpolation = value?.lineInterpolation;
|
||||
|
||||
@@ -122,6 +124,15 @@ function ChartAppearanceSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.points && (
|
||||
<PointsFields
|
||||
value={value?.points}
|
||||
savedValue={savedValue?.points}
|
||||
sizeColumnLabel={sizeColumnLabel}
|
||||
onChange={(points): void => onChange({ ...value, points })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.spanGaps && (
|
||||
<DisconnectValuesField
|
||||
testId="panel-editor-v2-span-gaps"
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { DashboardtypesScatterPlotPointsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
POINT_OPACITY_BOUNDS,
|
||||
POINT_SIZE_BOUNDS,
|
||||
resolvePointOpacity,
|
||||
resolvePointSize,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/ScatterPlotPanel/utils/points';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigRangeSlider from '../../controls/ConfigRangeSlider/ConfigRangeSlider';
|
||||
import ConfigSlider from '../../controls/ConfigSlider/ConfigSlider';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
import { formatOpacity, formatPointSize, formatPointSizeRange } from './utils';
|
||||
|
||||
const OPACITY_STEP = 0.01;
|
||||
|
||||
interface PointsFieldsProps {
|
||||
value: DashboardtypesScatterPlotPointsDTO | undefined;
|
||||
savedValue: DashboardtypesScatterPlotPointsDTO | undefined;
|
||||
onChange: (next: DashboardtypesScatterPlotPointsDTO) => void;
|
||||
/** The column dots are sized by; unset draws every dot at one size. */
|
||||
sizeColumnLabel?: string;
|
||||
}
|
||||
|
||||
function PointsFields({
|
||||
value,
|
||||
savedValue,
|
||||
onChange,
|
||||
sizeColumnLabel,
|
||||
}: PointsFieldsProps): JSX.Element {
|
||||
const reset = createFieldResetter(value, savedValue, onChange);
|
||||
// The renderer's defaults, so a thumb starts where an unset field draws.
|
||||
const size = resolvePointSize(value);
|
||||
|
||||
return (
|
||||
<>
|
||||
{sizeColumnLabel ? (
|
||||
<ConfigField
|
||||
label="Point size"
|
||||
help={`Each dot's area scales with ${sizeColumnLabel} between min and max.`}
|
||||
{...reset('minSize', 'maxSize')}
|
||||
>
|
||||
<ConfigRangeSlider
|
||||
testId="panel-editor-v2-point-size-range"
|
||||
value={[size.min, size.max]}
|
||||
min={POINT_SIZE_BOUNDS.min}
|
||||
max={POINT_SIZE_BOUNDS.max}
|
||||
step={1}
|
||||
formatValue={formatPointSizeRange}
|
||||
onChange={([minSize, maxSize]): void =>
|
||||
onChange({ ...value, minSize, maxSize })
|
||||
}
|
||||
/>
|
||||
</ConfigField>
|
||||
) : (
|
||||
<ConfigField
|
||||
label="Point size"
|
||||
help="Every dot uses this size. Map a Size column to draw a bubble chart."
|
||||
{...reset('size')}
|
||||
>
|
||||
<ConfigSlider
|
||||
testId="panel-editor-v2-point-size"
|
||||
value={size.fixed}
|
||||
min={POINT_SIZE_BOUNDS.min}
|
||||
max={POINT_SIZE_BOUNDS.max}
|
||||
step={1}
|
||||
formatValue={formatPointSize}
|
||||
onChange={(next): void => onChange({ ...value, size: next })}
|
||||
/>
|
||||
</ConfigField>
|
||||
)}
|
||||
<ConfigField label="Fill opacity" {...reset('opacity')}>
|
||||
<ConfigSlider
|
||||
testId="panel-editor-v2-point-opacity"
|
||||
value={resolvePointOpacity(value)}
|
||||
min={POINT_OPACITY_BOUNDS.min}
|
||||
max={POINT_OPACITY_BOUNDS.max}
|
||||
step={OPACITY_STEP}
|
||||
formatValue={formatOpacity}
|
||||
onChange={(opacity): void => onChange({ ...value, opacity })}
|
||||
/>
|
||||
</ConfigField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PointsFields;
|
||||
@@ -0,0 +1,114 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import ChartAppearanceSection from '../ChartAppearanceSection';
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('ChartAppearanceSection points', () => {
|
||||
it('offers one point size when nothing sizes the dots', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ points: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-point-size')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-point-size-range'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('6 px')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Every dot uses this size. Map a Size column to draw a bubble chart.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('70%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers a min/max range named after the size column when one is bound', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ points: { minSize: 5 } }}
|
||||
controls={{ points: true }}
|
||||
sizeColumnLabel="B.count()"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-point-size-range'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-point-size'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('5–24 px')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Each dot's area scales with B.count() between min and max.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('steps the fixed size by a whole pixel, keeping the range', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ points: { size: 6, minSize: 4, maxSize: 30 } }}
|
||||
controls={{ points: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const [sizeThumb] = screen.getAllByRole('slider');
|
||||
sizeThumb.focus();
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
points: { size: 7, minSize: 4, maxSize: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it('stops the opacity at 10%', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ points: { opacity: 0.1 } }}
|
||||
controls={{ points: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const thumbs = screen.getAllByRole('slider');
|
||||
const opacityThumb = thumbs[thumbs.length - 1];
|
||||
expect(opacityThumb).toHaveAttribute('aria-valuemin', '0.1');
|
||||
opacityThumb.focus();
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the points out of a kind that does not declare them', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ lineStyle: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-point-size'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Fill opacity')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -12,3 +12,11 @@ export function defaultDisconnectDuration(stepInterval?: number): string {
|
||||
export function formatOpacity(opacity: number): string {
|
||||
return `${Math.round(opacity * 100)}%`;
|
||||
}
|
||||
|
||||
export function formatPointSize(size: number): string {
|
||||
return `${size} px`;
|
||||
}
|
||||
|
||||
export function formatPointSizeRange([min, max]: [number, number]): string {
|
||||
return `${min}–${max} px`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.labelWithInfo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: inline-flex;
|
||||
color: var(--l3-foreground);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.infoTooltip {
|
||||
max-width: 240px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useMemo } from 'react';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import ConfigMultiSelect from '../../controls/ConfigMultiSelect/ConfigMultiSelect';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
import { resolveAutoAxes } from '../../utils/scatterAxisColumns';
|
||||
|
||||
import styles from './DimensionsSection.module.scss';
|
||||
import LabelWithInfo from './LabelWithInfo';
|
||||
import {
|
||||
buildColorKeyItems,
|
||||
buildDimensionItems,
|
||||
formatAutoOption,
|
||||
formatColumnOption,
|
||||
UNSET_DIMENSION,
|
||||
} from './utils';
|
||||
|
||||
type DimensionsSectionProps = SectionEditorProps<SectionKind.Dimensions> &
|
||||
Pick<SectionEditorContext, 'tableColumns' | 'groupColumns'>;
|
||||
|
||||
const FIELD_INFO = {
|
||||
x: 'The value column that places each dot left to right. Auto takes the first value column.',
|
||||
y: "The value column that places each dot bottom to top. Auto takes the first value column X doesn't use.",
|
||||
sizeBy:
|
||||
"Optional. Scales each dot's area by this value column; a group without a value draws at the default size.",
|
||||
colorBy:
|
||||
'One color and legend entry per combination of the selected keys. Leave it empty to color by every group key.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Edits the `dimensions` slice of a Scatter Plot: which value column each axis
|
||||
* and the dot size read, and which group-by keys colour the dots. Options come
|
||||
* from the preview query's joined result.
|
||||
*/
|
||||
function DimensionsSection({
|
||||
value,
|
||||
onChange,
|
||||
tableColumns = [],
|
||||
groupColumns = [],
|
||||
}: DimensionsSectionProps): JSX.Element {
|
||||
const valueItems = useMemo(
|
||||
() =>
|
||||
tableColumns.map((column) => ({
|
||||
value: column.key,
|
||||
label: formatColumnOption(column),
|
||||
})),
|
||||
[tableColumns],
|
||||
);
|
||||
const autoAxes = resolveAutoAxes(tableColumns, {
|
||||
x: value?.x,
|
||||
y: value?.y,
|
||||
});
|
||||
const colorBy = value?.colorBy ?? [];
|
||||
const colorItems = buildColorKeyItems(groupColumns, colorBy);
|
||||
|
||||
const fields = [
|
||||
{ dimension: 'x', label: 'X axis', unsetLabel: formatAutoOption(autoAxes.x) },
|
||||
{ dimension: 'y', label: 'Y axis', unsetLabel: formatAutoOption(autoAxes.y) },
|
||||
{ dimension: 'sizeBy', label: 'Size by', unsetLabel: 'None' },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
{fields.map(({ dimension, label, unsetLabel }) => (
|
||||
<ConfigField
|
||||
key={dimension}
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label={label}
|
||||
info={FIELD_INFO[dimension]}
|
||||
testId={`panel-editor-v2-dimension-${dimension}-info`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ConfigSelect
|
||||
testId={`panel-editor-v2-dimension-${dimension}`}
|
||||
value={value?.[dimension] || UNSET_DIMENSION}
|
||||
items={buildDimensionItems(valueItems, value?.[dimension], unsetLabel)}
|
||||
onChange={(next): void => onChange({ ...value, [dimension]: next })}
|
||||
/>
|
||||
</ConfigField>
|
||||
))}
|
||||
<ConfigField
|
||||
label={
|
||||
<LabelWithInfo
|
||||
label="Color by"
|
||||
info={FIELD_INFO.colorBy}
|
||||
testId="panel-editor-v2-dimension-colorBy-info"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ConfigMultiSelect
|
||||
testId="panel-editor-v2-dimension-colorBy"
|
||||
aria-label="Color by"
|
||||
value={colorBy}
|
||||
items={colorItems}
|
||||
placeholder={
|
||||
colorItems.length > 0 ? 'Every group key' : 'No group-by labels'
|
||||
}
|
||||
disabled={colorItems.length === 0}
|
||||
onChange={(next): void => onChange({ ...value, colorBy: next })}
|
||||
/>
|
||||
</ConfigField>
|
||||
<p className={styles.description}>
|
||||
Each dot is one group from the query. X and Y place it, Size by scales it,
|
||||
and Color by groups it in the legend.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DimensionsSection;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './DimensionsSection.module.scss';
|
||||
|
||||
interface LabelWithInfoProps {
|
||||
label: string;
|
||||
info: string;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function LabelWithInfo({
|
||||
label,
|
||||
info,
|
||||
testId,
|
||||
}: LabelWithInfoProps): JSX.Element {
|
||||
return (
|
||||
<span className={styles.labelWithInfo}>
|
||||
{label}
|
||||
<TooltipSimple
|
||||
title={info}
|
||||
tooltipContentProps={{ className: styles.infoTooltip }}
|
||||
>
|
||||
<span className={styles.info} data-testid={testId}>
|
||||
<Info size={12} />
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default LabelWithInfo;
|
||||
@@ -0,0 +1,189 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import DimensionsSection from '../DimensionsSection';
|
||||
|
||||
const TABLE_COLUMNS = [
|
||||
{ key: 'A', label: 'A.Request rate', name: 'Request rate' },
|
||||
{ key: 'B.p99(duration_nano)', label: 'B.p99 latency', name: 'p99 latency' },
|
||||
{ key: 'C.count()', label: 'C.count()', name: 'count()' },
|
||||
];
|
||||
|
||||
async function pick(dimension: string, label: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const trigger = screen.getByTestId(`panel-editor-v2-dimension-${dimension}`);
|
||||
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
|
||||
await user.click(await screen.findByRole('option', { name: label }));
|
||||
}
|
||||
|
||||
describe('DimensionsSection', () => {
|
||||
it('names the column each unset axis falls back to', () => {
|
||||
render(
|
||||
<DimensionsSection
|
||||
value={undefined}
|
||||
onChange={jest.fn()}
|
||||
tableColumns={TABLE_COLUMNS}
|
||||
groupColumns={['service.name']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-dimension-x')).toHaveTextContent(
|
||||
'Auto (A · Request rate)',
|
||||
);
|
||||
expect(screen.getByTestId('panel-editor-v2-dimension-y')).toHaveTextContent(
|
||||
'Auto (B.p99(duration_nano) · p99 latency)',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-dimension-sizeBy'),
|
||||
).toHaveTextContent('None');
|
||||
});
|
||||
|
||||
it('falls Y back to a column other than the bound X', () => {
|
||||
render(
|
||||
<DimensionsSection
|
||||
value={{ x: 'B.p99(duration_nano)' }}
|
||||
onChange={jest.fn()}
|
||||
tableColumns={TABLE_COLUMNS}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-dimension-y')).toHaveTextContent(
|
||||
'Auto (A · Request rate)',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a column by its key alone when the name only repeats it', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<DimensionsSection
|
||||
value={undefined}
|
||||
onChange={onChange}
|
||||
tableColumns={TABLE_COLUMNS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await pick('sizeBy', 'C.count()');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ sizeBy: 'C.count()' });
|
||||
});
|
||||
|
||||
it('binds an axis to a value column by its key', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<DimensionsSection
|
||||
value={{ x: 'A' }}
|
||||
onChange={onChange}
|
||||
tableColumns={TABLE_COLUMNS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await pick('y', 'B.p99(duration_nano) · p99 latency');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
x: 'A',
|
||||
y: 'B.p99(duration_nano)',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the size back to none', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<DimensionsSection
|
||||
value={{ sizeBy: 'A' }}
|
||||
onChange={onChange}
|
||||
tableColumns={TABLE_COLUMNS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await pick('sizeBy', 'None');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ sizeBy: '' });
|
||||
});
|
||||
|
||||
it('keeps a binding the results no longer have visible', () => {
|
||||
render(
|
||||
<DimensionsSection
|
||||
value={{ x: 'D' }}
|
||||
onChange={jest.fn()}
|
||||
tableColumns={TABLE_COLUMNS}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-dimension-x')).toHaveTextContent(
|
||||
'D (not in results)',
|
||||
);
|
||||
});
|
||||
|
||||
it('colours by any combination of group-by keys, kept in result order', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<DimensionsSection
|
||||
value={{ colorBy: ['service.name'] }}
|
||||
onChange={onChange}
|
||||
groupColumns={['k8s.namespace.name', 'service.name']}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId('panel-editor-v2-dimension-colorBy');
|
||||
await user.click(
|
||||
trigger.querySelector('.ant-select-selector') as HTMLElement,
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole('option', { name: 'k8s.namespace.name' }),
|
||||
);
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
colorBy: ['k8s.namespace.name', 'service.name'],
|
||||
});
|
||||
});
|
||||
|
||||
it('explains every field from an info icon', () => {
|
||||
render(<DimensionsSection value={undefined} onChange={jest.fn()} />);
|
||||
|
||||
['x', 'y', 'sizeBy', 'colorBy'].forEach((dimension) => {
|
||||
expect(
|
||||
screen.getByTestId(`panel-editor-v2-dimension-${dimension}-info`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('says what an empty colour selection means', () => {
|
||||
render(
|
||||
<DimensionsSection
|
||||
value={undefined}
|
||||
onChange={jest.fn()}
|
||||
groupColumns={['service.name']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Every group key')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-dimension-colorBy-info'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a selected colour key the results no longer have', () => {
|
||||
render(
|
||||
<DimensionsSection
|
||||
value={{ colorBy: ['host.name'] }}
|
||||
onChange={jest.fn()}
|
||||
groupColumns={['service.name']}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-dimension-colorBy'),
|
||||
).toHaveTextContent('host.name (not in results)');
|
||||
});
|
||||
|
||||
it('disables colour when the query has no group by', () => {
|
||||
render(<DimensionsSection value={undefined} onChange={jest.fn()} />);
|
||||
|
||||
expect(screen.getByText('No group-by labels')).toBeInTheDocument();
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('panel-editor-v2-dimension-colorBy')
|
||||
.classList.contains('ant-select-disabled'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ConfigMultiSelectItem } from '../../controls/ConfigMultiSelect/ConfigMultiSelect';
|
||||
import type { ConfigSelectItem } from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import type { TableColumnOption } from '../../../hooks/useTableColumns';
|
||||
|
||||
/** The select value for an unset dimension; the spec stores it as `''`. */
|
||||
export const UNSET_DIMENSION = '';
|
||||
|
||||
/** `A · Request rate`, or the key alone when the name only repeats it (`A.count()`). */
|
||||
export function formatColumnOption({
|
||||
key,
|
||||
name,
|
||||
}: Pick<TableColumnOption, 'key' | 'name'>): string {
|
||||
return key === name || key.endsWith(`.${name}`) ? key : `${key} · ${name}`;
|
||||
}
|
||||
|
||||
export function formatAutoOption(
|
||||
column: TableColumnOption | undefined,
|
||||
): string {
|
||||
return column ? `Auto (${formatColumnOption(column)})` : 'Auto';
|
||||
}
|
||||
|
||||
/**
|
||||
* The unset option first, then the result's columns. A bound key the current
|
||||
* result no longer has stays listed, so the select never shows a bare value.
|
||||
*/
|
||||
export function buildDimensionItems(
|
||||
columns: ConfigSelectItem[],
|
||||
current: string | undefined,
|
||||
unsetLabel: string,
|
||||
): ConfigSelectItem[] {
|
||||
const items = [{ value: UNSET_DIMENSION, label: unsetLabel }, ...columns];
|
||||
if (current && !columns.some((column) => column.value === current)) {
|
||||
items.push({ value: current, label: `${current} (not in results)` });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** The result's group-by keys, then any selected key the result no longer has. */
|
||||
export function buildColorKeyItems(
|
||||
groupColumns: string[],
|
||||
selected: string[],
|
||||
): ConfigMultiSelectItem[] {
|
||||
return [
|
||||
...groupColumns.map((column) => ({ value: column, label: column })),
|
||||
...selected
|
||||
.filter((key) => !groupColumns.includes(key))
|
||||
.map((key) => ({ value: key, label: `${key} (not in results)` })),
|
||||
];
|
||||
}
|
||||
@@ -152,7 +152,7 @@ describe('FormattingSection', () => {
|
||||
<FormattingSection
|
||||
value={{ columnUnits: { A: 'By' } }}
|
||||
controls={{ columnUnits: true }}
|
||||
tableColumns={[{ key: 'A', label: 'A' }]}
|
||||
tableColumns={[{ key: 'A', label: 'A', name: 'A' }]}
|
||||
metricUnit="s"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
@@ -166,7 +166,7 @@ describe('FormattingSection', () => {
|
||||
<FormattingSection
|
||||
value={{ columnUnits: { A: 's' } }}
|
||||
controls={{ columnUnits: true }}
|
||||
tableColumns={[{ key: 'A', label: 'A' }]}
|
||||
tableColumns={[{ key: 'A', label: 'A', name: 'A' }]}
|
||||
metricUnit="s"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.axes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.axis + .axis {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigField from '../../controls/ConfigField/ConfigField';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
import { createFieldResetter } from '../../utils/changes';
|
||||
import ScatterAxisFields from './ScatterAxisFields';
|
||||
|
||||
import styles from './ScatterAxesSection.module.scss';
|
||||
|
||||
type ScatterAxesSectionProps = SectionEditorProps<SectionKind.ScatterAxes> &
|
||||
Pick<SectionEditorContext, 'axisColumnNames'>;
|
||||
|
||||
/** Edits a Scatter Plot's `axes` slice: label, soft bounds and scale, per axis. */
|
||||
function ScatterAxesSection({
|
||||
value,
|
||||
savedValue,
|
||||
onChange,
|
||||
axisColumnNames,
|
||||
}: ScatterAxesSectionProps): JSX.Element {
|
||||
const reset = createFieldResetter(value, savedValue, onChange);
|
||||
|
||||
return (
|
||||
<div className={styles.axes}>
|
||||
{(['x', 'y'] as const).map((axis) => (
|
||||
<div key={axis} className={styles.axis}>
|
||||
<ConfigField label={`${axis.toUpperCase()} axis`} {...reset(axis)}>
|
||||
<ScatterAxisFields
|
||||
axis={axis}
|
||||
value={value?.[axis]}
|
||||
columnName={axisColumnNames?.[axis]}
|
||||
onChange={(next): void => onChange({ ...value, [axis]: next })}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScatterAxesSection;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Input } from 'antd';
|
||||
import {
|
||||
DashboardtypesAxisScaleDTO,
|
||||
type DashboardtypesScatterPlotAxisDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfigInlineField from '../../controls/ConfigInlineField/ConfigInlineField';
|
||||
import AxisFields from '../AxesSection/AxisFields';
|
||||
import AxisRangeField from '../AxesSection/AxisRangeField';
|
||||
import AxisScaleField from '../AxesSection/AxisScaleField';
|
||||
import { LOG_RANGE_IGNORED_HELP, SCALE_HELP, SCALE_OPTIONS } from './options';
|
||||
import { hasBoundALogAxisDrops } from './utils';
|
||||
|
||||
interface ScatterAxisFieldsProps {
|
||||
axis: 'x' | 'y';
|
||||
value: DashboardtypesScatterPlotAxisDTO | undefined;
|
||||
onChange: (next: DashboardtypesScatterPlotAxisDTO) => void;
|
||||
/** The name of the column the axis plots, shown until a label is typed. */
|
||||
columnName?: string;
|
||||
}
|
||||
|
||||
function ScatterAxisFields({
|
||||
axis,
|
||||
value,
|
||||
onChange,
|
||||
columnName,
|
||||
}: ScatterAxisFieldsProps): JSX.Element {
|
||||
const scale = value?.scale ?? DashboardtypesAxisScaleDTO.auto;
|
||||
const name = axis.toUpperCase();
|
||||
|
||||
return (
|
||||
<AxisFields>
|
||||
<ConfigInlineField label="Label">
|
||||
<Input
|
||||
data-testid={`panel-editor-v2-${axis}-label`}
|
||||
aria-label={`${name}-axis label`}
|
||||
placeholder={columnName ?? 'None'}
|
||||
value={value?.label ?? ''}
|
||||
onChange={(event): void =>
|
||||
onChange({ ...value, label: event.target.value })
|
||||
}
|
||||
/>
|
||||
</ConfigInlineField>
|
||||
<AxisRangeField
|
||||
testIdPrefix={`panel-editor-v2-${axis}`}
|
||||
value={value}
|
||||
onChange={(bounds): void => onChange({ ...value, ...bounds })}
|
||||
help={
|
||||
hasBoundALogAxisDrops(scale, value) ? LOG_RANGE_IGNORED_HELP : undefined
|
||||
}
|
||||
helpTestId={`panel-editor-v2-${axis}-range-help`}
|
||||
/>
|
||||
<AxisScaleField
|
||||
testId={`panel-editor-v2-${axis}-scale`}
|
||||
aria-label={`${name}-axis scale`}
|
||||
value={scale}
|
||||
items={SCALE_OPTIONS}
|
||||
help={SCALE_HELP}
|
||||
onChange={(next): void => onChange({ ...value, scale: next })}
|
||||
/>
|
||||
</AxisFields>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScatterAxisFields;
|
||||
@@ -0,0 +1,171 @@
|
||||
import { DashboardtypesAxisScaleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import ScatterAxesSection from '../ScatterAxesSection';
|
||||
|
||||
describe('ScatterAxesSection', () => {
|
||||
it('renders bounds and scale for both axes', () => {
|
||||
render(<ScatterAxesSection value={undefined} onChange={jest.fn()} />);
|
||||
|
||||
['x', 'y'].forEach((axis) => {
|
||||
expect(
|
||||
screen.getByTestId(`panel-editor-v2-${axis}-soft-min`),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(`panel-editor-v2-${axis}-soft-max`),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(`panel-editor-v2-${axis}-scale`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('writes a soft bound to its own axis', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ScatterAxesSection
|
||||
value={{ x: { scale: DashboardtypesAxisScaleDTO.log } }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId('panel-editor-v2-y-soft-max'), '5');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
x: { scale: DashboardtypesAxisScaleDTO.log },
|
||||
y: { softMax: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the plotted column as each label placeholder', () => {
|
||||
render(
|
||||
<ScatterAxesSection
|
||||
value={undefined}
|
||||
onChange={jest.fn()}
|
||||
axisColumnNames={{ x: 'Request rate', y: 'p99 latency' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-x-label')).toHaveAttribute(
|
||||
'placeholder',
|
||||
'Request rate',
|
||||
);
|
||||
expect(screen.getByTestId('panel-editor-v2-y-label')).toHaveAttribute(
|
||||
'placeholder',
|
||||
'p99 latency',
|
||||
);
|
||||
});
|
||||
|
||||
it('resets one axis without touching the other', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ScatterAxesSection
|
||||
value={{
|
||||
x: { scale: DashboardtypesAxisScaleDTO.log, label: 'Throughput' },
|
||||
y: { scale: DashboardtypesAxisScaleDTO.symlog },
|
||||
}}
|
||||
savedValue={{}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const [resetX] = screen.getAllByTestId('config-field-reset');
|
||||
await user.click(resetX);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
x: undefined,
|
||||
y: { scale: DashboardtypesAxisScaleDTO.symlog },
|
||||
});
|
||||
});
|
||||
|
||||
it('says a log axis ignores a bound at or below zero', () => {
|
||||
render(
|
||||
<ScatterAxesSection
|
||||
value={{
|
||||
x: { scale: DashboardtypesAxisScaleDTO.log, softMin: -10 },
|
||||
y: { scale: DashboardtypesAxisScaleDTO.symlog, softMin: -10 },
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-x-range-help')).toHaveTextContent(
|
||||
"A log axis can't reach 0 or below",
|
||||
);
|
||||
expect(screen.getByTestId('panel-editor-v2-y-range-help')).toHaveTextContent(
|
||||
'The axis always shows at least this range.',
|
||||
);
|
||||
});
|
||||
|
||||
it('flags a soft min above the soft max on that axis only', () => {
|
||||
render(
|
||||
<ScatterAxesSection
|
||||
value={{ x: { softMin: 23, softMax: 12 }, y: { softMin: 1, softMax: 2 } }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-x-range-help')).toHaveTextContent(
|
||||
"Min can't be greater than Max.",
|
||||
);
|
||||
expect(screen.getByTestId('panel-editor-v2-y-range-help')).toHaveTextContent(
|
||||
'The axis always shows at least this range.',
|
||||
);
|
||||
});
|
||||
|
||||
it('writes an axis label', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<ScatterAxesSection value={undefined} onChange={onChange} />);
|
||||
|
||||
await user.type(screen.getByTestId('panel-editor-v2-y-label'), 'p');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ y: { label: 'p' } });
|
||||
});
|
||||
|
||||
it('clears a soft bound to null when the field is emptied', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ScatterAxesSection value={{ x: { softMin: 10 } }} onChange={onChange} />,
|
||||
);
|
||||
|
||||
await user.clear(screen.getByTestId('panel-editor-v2-x-soft-min'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ x: { softMin: null } });
|
||||
});
|
||||
|
||||
it.each([
|
||||
DashboardtypesAxisScaleDTO.linear,
|
||||
DashboardtypesAxisScaleDTO.log,
|
||||
DashboardtypesAxisScaleDTO.symlog,
|
||||
])('sets the x scale to %s', async (scale) => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<ScatterAxesSection value={undefined} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByTestId(`panel-editor-v2-x-scale-${scale}`));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ x: { scale } });
|
||||
});
|
||||
|
||||
it('marks a changed scale against the saved value and resets it', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ScatterAxesSection
|
||||
value={{ y: { scale: DashboardtypesAxisScaleDTO.log } }}
|
||||
savedValue={{ y: { scale: DashboardtypesAxisScaleDTO.auto } }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /reset/i }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
y: { scale: DashboardtypesAxisScaleDTO.auto },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { DashboardtypesAxisScaleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
pickScaleOptions,
|
||||
SCALE_HELP as AXIS_SCALE_HELP,
|
||||
} from '../AxesSection/options';
|
||||
|
||||
export const SCALE_OPTIONS = pickScaleOptions([
|
||||
DashboardtypesAxisScaleDTO.auto,
|
||||
DashboardtypesAxisScaleDTO.linear,
|
||||
DashboardtypesAxisScaleDTO.log,
|
||||
DashboardtypesAxisScaleDTO.symlog,
|
||||
]);
|
||||
|
||||
export const LOG_RANGE_IGNORED_HELP =
|
||||
"A log axis can't reach 0 or below, so that bound is ignored. Use Symlog to include it.";
|
||||
|
||||
/** A log axis leaves out groups it can't place, rather than drawing them elsewhere. */
|
||||
export const SCALE_HELP: Record<DashboardtypesAxisScaleDTO, string> = {
|
||||
...AXIS_SCALE_HELP,
|
||||
[DashboardtypesAxisScaleDTO.log]:
|
||||
"Spreads out values that span orders of magnitude. Groups with a value of 0 or less can't be placed and are left out.",
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
DashboardtypesAxisScaleDTO,
|
||||
type DashboardtypesScatterPlotAxisDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/** A log axis cannot place a soft bound at or below zero, so it drops it. */
|
||||
export function hasBoundALogAxisDrops(
|
||||
scale: DashboardtypesAxisScaleDTO,
|
||||
axis: DashboardtypesScatterPlotAxisDTO | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
scale === DashboardtypesAxisScaleDTO.log &&
|
||||
[axis?.softMin, axis?.softMax].some(
|
||||
(bound) => typeof bound === 'number' && bound <= 0,
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getScatterAxisColumnNames } from '../scatterAxisColumns';
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'A', label: 'A.Request rate', name: 'Request rate' },
|
||||
{ key: 'B', label: 'B.p99 latency', name: 'p99 latency' },
|
||||
{ key: 'C', label: 'C.Errors', name: 'Errors' },
|
||||
];
|
||||
|
||||
const scatterSpec = (dimensions: {
|
||||
x?: string;
|
||||
y?: string;
|
||||
}): DashboardtypesPanelSpecDTO =>
|
||||
({
|
||||
plugin: { kind: 'signoz/ScatterPlotPanel', spec: { dimensions } },
|
||||
queries: [],
|
||||
}) as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
describe('getScatterAxisColumnNames', () => {
|
||||
it('names the first two value columns when nothing is bound', () => {
|
||||
expect(getScatterAxisColumnNames(scatterSpec({}), COLUMNS)).toStrictEqual({
|
||||
x: 'Request rate',
|
||||
y: 'p99 latency',
|
||||
});
|
||||
});
|
||||
|
||||
it('follows the bound columns, with Y avoiding the column X took', () => {
|
||||
expect(
|
||||
getScatterAxisColumnNames(scatterSpec({ x: 'A', y: 'C' }), COLUMNS),
|
||||
).toStrictEqual({ x: 'Request rate', y: 'Errors' });
|
||||
expect(
|
||||
getScatterAxisColumnNames(scatterSpec({ x: 'B' }), COLUMNS),
|
||||
).toStrictEqual({ x: 'p99 latency', y: 'Request rate' });
|
||||
});
|
||||
|
||||
it('is empty before results load and for other kinds', () => {
|
||||
expect(getScatterAxisColumnNames(scatterSpec({}), [])).toStrictEqual({
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
});
|
||||
expect(
|
||||
getScatterAxisColumnNames(
|
||||
{
|
||||
plugin: { kind: 'signoz/TablePanel', spec: {} },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO,
|
||||
COLUMNS,
|
||||
),
|
||||
).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { getSizeColumnLabel } from '../sizeColumnLabel';
|
||||
|
||||
const scatterSpec = (size?: string): DashboardtypesPanelSpecDTO =>
|
||||
({
|
||||
plugin: {
|
||||
kind: 'signoz/ScatterPlotPanel',
|
||||
spec: { dimensions: { sizeBy: size } },
|
||||
},
|
||||
queries: [],
|
||||
}) as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
const COLUMNS = [{ key: 'B', label: 'B.count()', name: 'count()' }];
|
||||
|
||||
describe('getSizeColumnLabel', () => {
|
||||
it('names the bound column by its label', () => {
|
||||
expect(getSizeColumnLabel(scatterSpec('B'), COLUMNS)).toBe('B.count()');
|
||||
});
|
||||
|
||||
it('is unset when nothing is bound', () => {
|
||||
expect(getSizeColumnLabel(scatterSpec(''), COLUMNS)).toBeUndefined();
|
||||
expect(getSizeColumnLabel(scatterSpec(), COLUMNS)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is unset for a bound key the loaded result no longer has, as the chart draws it', () => {
|
||||
expect(getSizeColumnLabel(scatterSpec('C'), COLUMNS)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to the key before the result loads', () => {
|
||||
expect(getSizeColumnLabel(scatterSpec('B'), [])).toBe('B');
|
||||
});
|
||||
|
||||
it('is unset for other kinds', () => {
|
||||
const spec = {
|
||||
plugin: { kind: 'signoz/TablePanel', spec: {} },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
expect(getSizeColumnLabel(spec, COLUMNS)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { TableColumnOption } from '../../hooks/useTableColumns';
|
||||
|
||||
/**
|
||||
* The columns a Scatter Plot's axes plot, as the renderer picks them: a bound
|
||||
* key, else X takes the first value column and Y the first one X did not take.
|
||||
*/
|
||||
export function resolveAutoAxes(
|
||||
columns: TableColumnOption[],
|
||||
bound: { x?: string; y?: string },
|
||||
): { x?: TableColumnOption; y?: TableColumnOption } {
|
||||
const x = columns.find((column) => column.key === bound.x) ?? columns[0];
|
||||
const y =
|
||||
columns.find((column) => column.key === bound.y) ??
|
||||
columns.find((column) => column !== x);
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/** The name of the column each axis plots; unset for other kinds or before results load. */
|
||||
export function getScatterAxisColumnNames(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
tableColumns: TableColumnOption[] = [],
|
||||
): { x?: string; y?: string } {
|
||||
if (spec.plugin.kind !== 'signoz/ScatterPlotPanel') {
|
||||
return {};
|
||||
}
|
||||
const { x, y } = resolveAutoAxes(tableColumns, {
|
||||
x: spec.plugin.spec.dimensions?.x,
|
||||
y: spec.plugin.spec.dimensions?.y,
|
||||
});
|
||||
return { x: x?.name, y: y?.name };
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { TableColumnOption } from '../../hooks/useTableColumns';
|
||||
|
||||
/**
|
||||
* What the dots are sized by, mirroring the renderer: a bound key the loaded
|
||||
* result no longer has sizes nothing. Before the result loads the key stands in.
|
||||
*/
|
||||
export function getSizeColumnLabel(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
tableColumns: TableColumnOption[] = [],
|
||||
): string | undefined {
|
||||
if (spec.plugin.kind !== 'signoz/ScatterPlotPanel') {
|
||||
return undefined;
|
||||
}
|
||||
const key = spec.plugin.spec.dimensions?.sizeBy;
|
||||
if (!key) {
|
||||
return undefined;
|
||||
}
|
||||
if (tableColumns.length === 0) {
|
||||
return key;
|
||||
}
|
||||
return tableColumns.find((column) => column.key === key)?.label;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDef
|
||||
import { toPanelType } from '../../Panels/types/panelKind';
|
||||
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
import { useSyncQueryBuilderFields } from './useSyncQueryBuilderFields';
|
||||
|
||||
interface PanelEditorQueryBuilderProps {
|
||||
/** The edited kind's definition — drives supported query types, the signals the
|
||||
@@ -62,6 +63,7 @@ function PanelEditorQueryBuilder({
|
||||
const isRawQuery = isRawRequest(panelDefinition.queryCapabilities);
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
useSyncQueryBuilderFields(panelDefinition.syncedQueryBuilderFields);
|
||||
|
||||
const handleQueryCategoryChange = useCallback(
|
||||
(queryType: string): void => {
|
||||
|
||||
@@ -14,6 +14,9 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
|
||||
jest.mock('../useSyncQueryBuilderFields', () => ({
|
||||
useSyncQueryBuilderFields: jest.fn(),
|
||||
}));
|
||||
jest.mock('components/QueryBuilderV2/QueryBuilderV2', () => ({
|
||||
QueryBuilderV2: (props: unknown): null => {
|
||||
mockQueryBuilderV2(props);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
|
||||
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import type { SyncedQueryBuilderField } from '../../../Panels/types/panelCapabilities';
|
||||
import {
|
||||
snapshotQueries,
|
||||
syncQueryBuilderFields,
|
||||
} from '../syncQueryBuilderFields';
|
||||
|
||||
const key = (name: string): BaseAutocompleteData =>
|
||||
({ key: name }) as BaseAutocompleteData;
|
||||
|
||||
function query(
|
||||
queryName: string,
|
||||
{
|
||||
groupBy = [],
|
||||
limit = null,
|
||||
dataSource = 'traces',
|
||||
}: { groupBy?: string[]; limit?: number | null; dataSource?: string } = {},
|
||||
): IBuilderQuery {
|
||||
return {
|
||||
queryName,
|
||||
groupBy: groupBy.map(key),
|
||||
limit,
|
||||
dataSource,
|
||||
} as IBuilderQuery;
|
||||
}
|
||||
|
||||
const GROUP_BY: SyncedQueryBuilderField[] = [QueryBuilderField.GroupBy];
|
||||
|
||||
describe('syncQueryBuilderFields', () => {
|
||||
it('copies an edited field to the other queries', () => {
|
||||
const previous = snapshotQueries([query('A'), query('B')]);
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { groupBy: ['service.name'] }), query('B')],
|
||||
previous,
|
||||
GROUP_BY,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives a new query the existing values', () => {
|
||||
const previous = snapshotQueries([query('A', { groupBy: ['service.name'] })]);
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { groupBy: ['service.name'] }), query('B')],
|
||||
previous,
|
||||
GROUP_BY,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives a query switched to another signal the others' values, not its reset ones", () => {
|
||||
const previous = snapshotQueries([
|
||||
query('A', { groupBy: ['service.name'] }),
|
||||
query('B', { groupBy: ['service.name'], dataSource: 'metrics' }),
|
||||
]);
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { groupBy: ['service.name'] }), query('B')],
|
||||
previous,
|
||||
GROUP_BY,
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves values that already differ until one is edited', () => {
|
||||
const queries = [query('A', { groupBy: ['host.name'] }), query('B')];
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(queries, snapshotQueries(queries), GROUP_BY),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('syncs each field from its own source', () => {
|
||||
const previous = snapshotQueries([query('A'), query('B')]);
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { groupBy: ['service.name'] }), query('B', { limit: 50 })],
|
||||
previous,
|
||||
[QueryBuilderField.GroupBy, QueryBuilderField.Limit],
|
||||
),
|
||||
).toStrictEqual([
|
||||
{ index: 0, query: query('A', { groupBy: ['service.name'], limit: 50 }) },
|
||||
{ index: 1, query: query('B', { groupBy: ['service.name'], limit: 50 }) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves fields it is not asked to sync', () => {
|
||||
const previous = snapshotQueries([query('A'), query('B')]);
|
||||
|
||||
expect(
|
||||
syncQueryBuilderFields(
|
||||
[query('A', { limit: 50 }), query('B')],
|
||||
previous,
|
||||
GROUP_BY,
|
||||
),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
|
||||
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import type { SyncedQueryBuilderField } from '../../../Panels/types/panelCapabilities';
|
||||
import { useSyncQueryBuilderFields } from '../useSyncQueryBuilderFields';
|
||||
|
||||
const mockHandleSetQueryData = jest.fn();
|
||||
let mockQueries: IBuilderQuery[] = [];
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({
|
||||
currentQuery: { builder: { queryData: mockQueries } },
|
||||
handleSetQueryData: mockHandleSetQueryData,
|
||||
}),
|
||||
}));
|
||||
|
||||
const query = (queryName: string, groupBy: string[]): IBuilderQuery =>
|
||||
({
|
||||
queryName,
|
||||
groupBy: groupBy.map((name) => ({ key: name }) as BaseAutocompleteData),
|
||||
dataSource: 'traces',
|
||||
}) as IBuilderQuery;
|
||||
|
||||
const GROUP_BY: SyncedQueryBuilderField[] = [QueryBuilderField.GroupBy];
|
||||
|
||||
function setup(
|
||||
fields: SyncedQueryBuilderField[] | undefined,
|
||||
initial: IBuilderQuery[],
|
||||
): (next: IBuilderQuery[]) => void {
|
||||
mockQueries = initial;
|
||||
const { rerender } = renderHook(() => useSyncQueryBuilderFields(fields));
|
||||
return (next): void => {
|
||||
mockQueries = next;
|
||||
rerender();
|
||||
};
|
||||
}
|
||||
|
||||
describe('useSyncQueryBuilderFields', () => {
|
||||
beforeEach(() => mockHandleSetQueryData.mockClear());
|
||||
|
||||
it('writes the synced value into the other queries', () => {
|
||||
const update = setup(GROUP_BY, [query('A', []), query('B', [])]);
|
||||
|
||||
update([query('A', ['service.name']), query('B', [])]);
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
|
||||
1,
|
||||
query('B', ['service.name']),
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when the kind syncs no fields', () => {
|
||||
const update = setup(undefined, [query('A', []), query('B', [])]);
|
||||
|
||||
update([query('A', ['service.name']), query('B', [])]);
|
||||
|
||||
expect(mockHandleSetQueryData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import type { SyncedQueryBuilderField } from '../../Panels/types/panelCapabilities';
|
||||
|
||||
const QUERY_PROPERTY = {
|
||||
[QueryBuilderField.GroupBy]: 'groupBy',
|
||||
[QueryBuilderField.OrderBy]: 'orderBy',
|
||||
[QueryBuilderField.Having]: 'having',
|
||||
[QueryBuilderField.Limit]: 'limit',
|
||||
[QueryBuilderField.StepInterval]: 'stepInterval',
|
||||
[QueryBuilderField.ReduceTo]: 'reduceTo',
|
||||
} as const satisfies Record<SyncedQueryBuilderField, keyof IBuilderQuery>;
|
||||
|
||||
export function snapshotQueries(
|
||||
queries: IBuilderQuery[],
|
||||
): Map<string, IBuilderQuery> {
|
||||
return new Map(queries.map((query) => [query.queryName, query]));
|
||||
}
|
||||
|
||||
/**
|
||||
* The query each field should be copied from: the one whose value just
|
||||
* changed, or any settled query when one was added or switched signal. A
|
||||
* signal switch reloads that query's own state, so it is never the source.
|
||||
*/
|
||||
function findSource(
|
||||
property: keyof IBuilderQuery,
|
||||
settled: IBuilderQuery[],
|
||||
hasNewcomer: boolean,
|
||||
previous: Map<string, IBuilderQuery>,
|
||||
): IBuilderQuery | undefined {
|
||||
const changed = settled.find(
|
||||
(query) =>
|
||||
!isEqual(previous.get(query.queryName)?.[property], query[property]),
|
||||
);
|
||||
return changed ?? (hasNewcomer ? settled[0] : undefined);
|
||||
}
|
||||
|
||||
/** The queries that need rewriting so every synced field matches its source. */
|
||||
export function syncQueryBuilderFields(
|
||||
queries: IBuilderQuery[],
|
||||
previous: Map<string, IBuilderQuery>,
|
||||
fields: SyncedQueryBuilderField[],
|
||||
): { index: number; query: IBuilderQuery }[] {
|
||||
const settled = queries.filter((query) => {
|
||||
const before = previous.get(query.queryName);
|
||||
return (
|
||||
before?.dataSource === query.dataSource && before.source === query.source
|
||||
);
|
||||
});
|
||||
const hasNewcomer = settled.length < queries.length;
|
||||
const sources = fields
|
||||
.map((field) => QUERY_PROPERTY[field])
|
||||
.map((property) => ({
|
||||
property,
|
||||
source: findSource(property, settled, hasNewcomer, previous),
|
||||
}));
|
||||
|
||||
return queries.flatMap((query, index) => {
|
||||
const patch = sources.reduce<Partial<IBuilderQuery>>(
|
||||
(acc, { property, source }) =>
|
||||
source && !isEqual(query[property], source[property])
|
||||
? { ...acc, [property]: source[property] }
|
||||
: acc,
|
||||
{},
|
||||
);
|
||||
return Object.keys(patch).length > 0
|
||||
? [{ index, query: { ...query, ...patch } }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import type { SyncedQueryBuilderField } from '../../Panels/types/panelCapabilities';
|
||||
import {
|
||||
snapshotQueries,
|
||||
syncQueryBuilderFields,
|
||||
} from './syncQueryBuilderFields';
|
||||
|
||||
const NO_FIELDS: SyncedQueryBuilderField[] = [];
|
||||
|
||||
/**
|
||||
* Keeps `fields` equal across builder queries: an edit to one query copies to
|
||||
* the rest, and a query added or switched to another signal takes the others'
|
||||
* values. Values that already differ when this starts are left alone until one
|
||||
* of them is edited.
|
||||
*/
|
||||
export function useSyncQueryBuilderFields(
|
||||
fields: SyncedQueryBuilderField[] = NO_FIELDS,
|
||||
): void {
|
||||
const { currentQuery, handleSetQueryData } = useQueryBuilder();
|
||||
const queries = currentQuery.builder.queryData;
|
||||
const previousRef = useRef<Map<string, IBuilderQuery>>();
|
||||
const enabled = fields.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const previous = previousRef.current;
|
||||
previousRef.current = enabled ? snapshotQueries(queries) : undefined;
|
||||
if (!enabled || !previous || queries.length < 2) {
|
||||
return;
|
||||
}
|
||||
syncQueryBuilderFields(queries, previous, fields).forEach(
|
||||
({ index, query }) => handleSetQueryData(index, query),
|
||||
);
|
||||
}, [enabled, fields, queries, handleSetQueryData]);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
|
||||
import { useGroupColumns } from './hooks/useGroupColumns';
|
||||
import { useTableColumns } from './hooks/useTableColumns';
|
||||
|
||||
import logEvent from '@/api/common/logEvent';
|
||||
@@ -196,6 +197,7 @@ function QueryEditorBody({
|
||||
const { onDragSelect } = usePanelInteractions();
|
||||
const legendSeries = useLegendSeries(draft, data);
|
||||
const tableColumns = useTableColumns(draft, data);
|
||||
const groupColumns = useGroupColumns(draft, data);
|
||||
|
||||
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
|
||||
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
|
||||
@@ -326,6 +328,7 @@ function QueryEditorBody({
|
||||
queryType={currentQuery.queryType}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
groupColumns={groupColumns}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import { useGroupColumns } from '../useGroupColumns';
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables',
|
||||
() => ({ prepareScalarTables: jest.fn() }),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData',
|
||||
() => ({ getScalarResults: jest.fn(() => []) }),
|
||||
);
|
||||
|
||||
const mockPrepareScalarTables = prepareScalarTables as unknown as jest.Mock;
|
||||
|
||||
const DATA = {
|
||||
response: undefined,
|
||||
legendMap: {},
|
||||
requestPayload: undefined,
|
||||
} as unknown as PanelQueryData;
|
||||
|
||||
function panelOfKind(kind: string): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind, spec: {} }, queries: [] },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('useGroupColumns', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns [] for a kind that does not join its scalar rows', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useGroupColumns(panelOfKind('signoz/PieChartPanel'), DATA),
|
||||
);
|
||||
|
||||
expect(result.current).toStrictEqual([]);
|
||||
expect(mockPrepareScalarTables).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps only the group-by columns, by key', () => {
|
||||
mockPrepareScalarTables.mockReturnValue([
|
||||
{ columns: [], rows: [] },
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
id: 'k8s.namespace.name',
|
||||
name: 'k8s.namespace.name',
|
||||
isValueColumn: false,
|
||||
},
|
||||
{ id: 'A', name: 'cpu', isValueColumn: true },
|
||||
{ id: 'k8s.pod.name', name: 'k8s.pod.name', isValueColumn: false },
|
||||
],
|
||||
rows: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGroupColumns(panelOfKind('signoz/ScatterPlotPanel'), DATA),
|
||||
);
|
||||
|
||||
expect(result.current).toStrictEqual(['k8s.namespace.name', 'k8s.pod.name']);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { preparePieData } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
|
||||
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { flattenTimeSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
|
||||
@@ -47,6 +48,7 @@ const mockFlatten = flattenTimeSeries as unknown as jest.Mock;
|
||||
const mockResolveLabel = resolveSeriesLabelV5 as unknown as jest.Mock;
|
||||
const mockGenerateColor = generateColor as unknown as jest.Mock;
|
||||
const mockPreparePie = preparePieData as unknown as jest.Mock;
|
||||
const mockPrepareScalarTables = prepareScalarTables as unknown as jest.Mock;
|
||||
|
||||
const PANEL = {
|
||||
kind: 'Panel',
|
||||
@@ -60,6 +62,17 @@ const HISTOGRAM_PANEL = {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/HistogramPanel', spec: {} }, queries: [] },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
const scatterPanel = (colorBy?: string[]): DashboardtypesPanelDTO =>
|
||||
({
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/ScatterPlotPanel',
|
||||
spec: { dimensions: { colorBy } },
|
||||
},
|
||||
queries: [],
|
||||
},
|
||||
}) as unknown as DashboardtypesPanelDTO;
|
||||
const DATA = { response: {}, legendMap: {} } as unknown as PanelQueryData;
|
||||
|
||||
// Each flattened series carries the label resolveSeriesLabelV5 should report.
|
||||
@@ -133,6 +146,36 @@ describe('useLegendSeries', () => {
|
||||
expect(mockFlatten).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves scatter plot panels by the colour-by label', () => {
|
||||
mockPrepareScalarTables.mockReturnValue([
|
||||
{
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [
|
||||
{ id: 'ns', name: 'ns', queryName: '', isValueColumn: false },
|
||||
{ id: 'pod', name: 'pod', queryName: '', isValueColumn: false },
|
||||
{ id: 'A', name: 'A', queryName: 'A', isValueColumn: true },
|
||||
{ id: 'B', name: 'B', queryName: 'B', isValueColumn: true },
|
||||
],
|
||||
rows: [
|
||||
{ data: { ns: 'prod', pod: 'p1', A: 1, B: 2 } },
|
||||
{ data: { ns: 'dev', pod: 'p2', A: 3, B: 4 } },
|
||||
{ data: { ns: 'prod', pod: 'p3', A: 5, B: 6 } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useLegendSeries(scatterPanel(['ns']), DATA),
|
||||
);
|
||||
|
||||
expect(result.current).toStrictEqual([
|
||||
{ label: 'prod', defaultColor: 'color:prod' },
|
||||
{ label: 'dev', defaultColor: 'color:dev' },
|
||||
]);
|
||||
expect(mockFlatten).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the dark palette in dark mode and the light palette otherwise', () => {
|
||||
mockFlatten.mockReturnValue(seriesWithLabels(['a']));
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@ function columnUnits(spec: DashboardtypesPanelSpecDTO): unknown {
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumnOption[] = [
|
||||
{ key: 'A', label: 'A' },
|
||||
{ key: 'B', label: 'B' },
|
||||
{ key: 'A', label: 'A', name: 'A' },
|
||||
{ key: 'B', label: 'B', name: 'B' },
|
||||
];
|
||||
|
||||
const NO_COLUMNS: TableColumnOption[] = [];
|
||||
|
||||
@@ -56,6 +56,23 @@ describe('useTableColumns', () => {
|
||||
expect(mockPrepareScalarTables).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves columns for a scatter plot, which joins its rows like a table', () => {
|
||||
tableWith([{ id: 'A', name: 'p99', isValueColumn: true }]);
|
||||
const panel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
plugin: { kind: 'signoz/ScatterPlotPanel', spec: {} },
|
||||
queries: [],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
const { result } = renderHook(() => useTableColumns(panel, DATA));
|
||||
|
||||
expect(result.current).toStrictEqual([
|
||||
{ key: 'A', label: 'p99', name: 'p99', unit: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns [] when there is no scalar table with columns', () => {
|
||||
mockPrepareScalarTables.mockReturnValue([{ columns: [], rows: [] }]);
|
||||
|
||||
@@ -73,7 +90,7 @@ describe('useTableColumns', () => {
|
||||
const { result } = renderHook(() => useTableColumns(tablePanel(), DATA));
|
||||
|
||||
expect(result.current).toStrictEqual([
|
||||
{ key: 'A', label: 'p99', unit: undefined },
|
||||
{ key: 'A', label: 'p99', name: 'p99', unit: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { getScalarResults } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
/**
|
||||
* Group-by labels of a joined scalar result, the keys a group column is stored
|
||||
* under. Empty for kinds that don't join their scalar rows or before data arrives.
|
||||
*/
|
||||
export function useGroupColumns(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
data: PanelQueryData,
|
||||
): string[] {
|
||||
return useMemo(() => {
|
||||
if (
|
||||
!getQueryPanelDefinition(panel.spec.plugin.kind)?.queryCapabilities
|
||||
.formatTableResultForUI
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const table = prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap,
|
||||
requestPayload: data.requestPayload,
|
||||
}).find((candidate) => candidate.columns.length > 0);
|
||||
return (table?.columns ?? [])
|
||||
.filter((column) => !column.isValueColumn)
|
||||
.map((column) => column.id);
|
||||
}, [
|
||||
panel.spec.plugin.kind,
|
||||
data.response,
|
||||
data.legendMap,
|
||||
data.requestPayload,
|
||||
]);
|
||||
}
|
||||
@@ -21,7 +21,12 @@ export function useLegendSeries(
|
||||
return useMemo(() => {
|
||||
const resolve = getSectionControls(kind, SectionKind.Legend)?.colors;
|
||||
return resolve
|
||||
? resolve({ queries: panel.spec.queries, data, isDarkMode })
|
||||
? resolve({
|
||||
spec: panel.spec,
|
||||
queries: panel.spec.queries,
|
||||
data,
|
||||
isDarkMode,
|
||||
})
|
||||
: [];
|
||||
}, [kind, panel.spec.queries, data, isDarkMode]);
|
||||
}, [kind, panel.spec, data, isDarkMode]);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user