mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-11 22:10:42 +01:00
Compare commits
12 Commits
refactor/v
...
feat/heatm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7b68ee687 | ||
|
|
232122ecda | ||
|
|
42fdcdefef | ||
|
|
30ad2a303d | ||
|
|
d39938d462 | ||
|
|
e81e9a2fd3 | ||
|
|
00718c0662 | ||
|
|
55a0c8dd35 | ||
|
|
4175f84815 | ||
|
|
fd032291f9 | ||
|
|
851abd2c93 | ||
|
|
c41899f2eb |
@@ -296,6 +296,17 @@ components:
|
||||
- jsmops
|
||||
- incidentio
|
||||
type: string
|
||||
AlertmanagertypesChannelListOrder:
|
||||
enum:
|
||||
- asc
|
||||
- desc
|
||||
type: string
|
||||
AlertmanagertypesChannelListSort:
|
||||
enum:
|
||||
- updated_at
|
||||
- created_at
|
||||
- name
|
||||
type: string
|
||||
AlertmanagertypesChannelMSTeamsConfig:
|
||||
properties:
|
||||
sendResolved:
|
||||
@@ -576,6 +587,43 @@ components:
|
||||
wont_fix_resolution:
|
||||
type: string
|
||||
type: object
|
||||
AlertmanagertypesListableNotificationChannel:
|
||||
properties:
|
||||
channels:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesListedNotificationChannel'
|
||||
type: array
|
||||
total:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- channels
|
||||
- total
|
||||
type: object
|
||||
AlertmanagertypesListedNotificationChannel:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
kind:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelKind'
|
||||
name:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- displayName
|
||||
- kind
|
||||
- createdAt
|
||||
- updatedAt
|
||||
type: object
|
||||
AlertmanagertypesMaintenanceKind:
|
||||
enum:
|
||||
- fixed
|
||||
@@ -942,6 +990,20 @@ components:
|
||||
- timezone
|
||||
- startTime
|
||||
type: object
|
||||
AlertmanagertypesTestableNotificationChannel:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
|
||||
required:
|
||||
- config
|
||||
type: object
|
||||
AlertmanagertypesUpdatableNotificationChannel:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
|
||||
required:
|
||||
- config
|
||||
type: object
|
||||
AuthtypesAttributeMapping:
|
||||
properties:
|
||||
email:
|
||||
@@ -7395,10 +7457,7 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
type: array
|
||||
meta:
|
||||
properties:
|
||||
unit:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
|
||||
predictedSeries:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
@@ -7413,12 +7472,51 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5Bucket:
|
||||
Querybuildertypesv5AggregationMeta:
|
||||
properties:
|
||||
step:
|
||||
format: double
|
||||
type: number
|
||||
buckets:
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
type: array
|
||||
unit:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5BucketOptions:
|
||||
discriminator:
|
||||
mapping:
|
||||
linear: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
|
||||
log: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
|
||||
type: object
|
||||
Querybuildertypesv5BucketOptionsLinear:
|
||||
properties:
|
||||
kind:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
|
||||
spec:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LinearBucketsSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
Querybuildertypesv5BucketOptionsLog:
|
||||
properties:
|
||||
kind:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
|
||||
spec:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LogBucketsSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
Querybuildertypesv5BucketsKind:
|
||||
enum:
|
||||
- linear
|
||||
- log
|
||||
type: string
|
||||
Querybuildertypesv5BuilderQuerySpec:
|
||||
discriminator:
|
||||
mapping:
|
||||
@@ -7599,6 +7697,16 @@ components:
|
||||
value:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5LinearBucketsSpec:
|
||||
properties:
|
||||
maxValue:
|
||||
format: double
|
||||
type: number
|
||||
numBuckets:
|
||||
type: integer
|
||||
required:
|
||||
- maxValue
|
||||
type: object
|
||||
Querybuildertypesv5LogAggregation:
|
||||
properties:
|
||||
alias:
|
||||
@@ -7606,6 +7714,12 @@ components:
|
||||
expression:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5LogBucketsSpec:
|
||||
properties:
|
||||
scale:
|
||||
nullable: true
|
||||
type: integer
|
||||
type: object
|
||||
Querybuildertypesv5MetricAggregation:
|
||||
properties:
|
||||
comparisonSpaceAggregationParam:
|
||||
@@ -7686,6 +7800,8 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5QueryBuilderFormula:
|
||||
properties:
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
disabled:
|
||||
type: boolean
|
||||
expression:
|
||||
@@ -7716,6 +7832,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LogAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -7777,6 +7895,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5MetricAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -7838,6 +7958,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -8163,6 +8285,7 @@ components:
|
||||
- raw
|
||||
- raw_stream
|
||||
- trace
|
||||
- heatmap
|
||||
type: string
|
||||
Querybuildertypesv5ScalarData:
|
||||
properties:
|
||||
@@ -8237,8 +8360,6 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5TimeSeriesValue:
|
||||
properties:
|
||||
bucket:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Bucket'
|
||||
partial:
|
||||
type: boolean
|
||||
timestamp:
|
||||
@@ -8349,6 +8470,8 @@ components:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
overallStateChanged:
|
||||
type: boolean
|
||||
relatedAITracesLink:
|
||||
type: string
|
||||
relatedLogsLink:
|
||||
type: string
|
||||
relatedTracesLink:
|
||||
@@ -8392,6 +8515,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Label'
|
||||
nullable: true
|
||||
type: array
|
||||
relatedAITracesLink:
|
||||
type: string
|
||||
relatedLogsLink:
|
||||
type: string
|
||||
relatedTracesLink:
|
||||
@@ -8497,6 +8622,7 @@ components:
|
||||
- TRACES_BASED_ALERT
|
||||
- LOGS_BASED_ALERT
|
||||
- EXCEPTIONS_BASED_ALERT
|
||||
- AI_TRACES_BASED_ALERT
|
||||
type: string
|
||||
RuletypesBasicRuleThreshold:
|
||||
properties:
|
||||
@@ -19720,6 +19846,86 @@ paths:
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/notification_channels:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns a page of notification channels for the org. Each entry
|
||||
carries the channel's identity and kind but not its configuration; fetch a
|
||||
channel by ID for that. Supports a case-insensitive display name search (`query`),
|
||||
a kind filter (`kind`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`),
|
||||
and offset-based pagination (`limit`/`offset`).
|
||||
operationId: ListNotificationChannels
|
||||
parameters:
|
||||
- in: query
|
||||
name: query
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: kind
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelKind'
|
||||
- in: query
|
||||
name: sort
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelListSort'
|
||||
- in: query
|
||||
name: order
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelListOrder'
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: offset
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AlertmanagertypesListableNotificationChannel'
|
||||
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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- notification-channel:list
|
||||
- tokenizer:
|
||||
- notification-channel:list
|
||||
summary: List notification channels
|
||||
tags:
|
||||
- channels
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a notification channel
|
||||
@@ -19782,6 +19988,239 @@ paths:
|
||||
summary: Create notification channel
|
||||
tags:
|
||||
- channels
|
||||
/api/v2/notification_channels/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint deletes a notification channel by ID
|
||||
operationId: DeleteNotificationChannel
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"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:
|
||||
- notification-channel:delete
|
||||
- tokenizer:
|
||||
- notification-channel:delete
|
||||
summary: Delete notification channel
|
||||
tags:
|
||||
- channels
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns a notification channel by ID. A channel written
|
||||
by the v1 API can carry a configuration this API does not model.
|
||||
operationId: GetNotificationChannel
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
|
||||
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:
|
||||
- notification-channel:read
|
||||
- tokenizer:
|
||||
- notification-channel:read
|
||||
summary: Get notification channel by ID
|
||||
tags:
|
||||
- channels
|
||||
put:
|
||||
deprecated: false
|
||||
description: 'This endpoint replaces a notification channel''s configuration
|
||||
in full. Neither name is part of the request body: both are immutable. The
|
||||
kind may change, which replaces the channel''s notifier configuration.'
|
||||
operationId: UpdateNotificationChannel
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesUpdatableNotificationChannel'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
|
||||
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:
|
||||
- notification-channel:update
|
||||
- tokenizer:
|
||||
- notification-channel:update
|
||||
summary: Update notification channel
|
||||
tags:
|
||||
- channels
|
||||
/api/v2/notification_channels/test:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint sends a test notification for the configuration in
|
||||
the request body. The channel need not exist and nothing is persisted, so
|
||||
the body carries a configuration only.
|
||||
operationId: TestNotificationChannel
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesTestableNotificationChannel'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- notification-channel:create
|
||||
- tokenizer:
|
||||
- notification-channel:create
|
||||
summary: Test notification channel
|
||||
tags:
|
||||
- channels
|
||||
/api/v2/orgs/me:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
// Mock for uplot library used in tests
|
||||
export interface MockUPlotInstance {
|
||||
/** Consumers read `root.parentElement` to detect a re-mounted container. */
|
||||
root: HTMLDivElement;
|
||||
setData: jest.Mock;
|
||||
setSize: jest.Mock;
|
||||
destroy: jest.Mock;
|
||||
@@ -17,13 +19,20 @@ export interface MockUPlotPaths {
|
||||
}
|
||||
|
||||
// Create mock instance methods
|
||||
const createMockUPlotInstance = (): MockUPlotInstance => ({
|
||||
setData: jest.fn(),
|
||||
setSize: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
setSeries: jest.fn(),
|
||||
});
|
||||
const createMockUPlotInstance = (target?: HTMLElement): MockUPlotInstance => {
|
||||
const root = document.createElement('div');
|
||||
// Real uPlot mounts its root inside the target; without it a re-render reads
|
||||
// `root.parentElement` off undefined and throws.
|
||||
target?.appendChild(root);
|
||||
return {
|
||||
root,
|
||||
setData: jest.fn(),
|
||||
setSize: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
setSeries: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
|
||||
const createMockPathBuilder = (name: string): jest.Mock =>
|
||||
@@ -53,14 +62,16 @@ const mockTzDate = jest.fn(
|
||||
function MockUPlot(
|
||||
_options: unknown,
|
||||
_data: unknown,
|
||||
_target: HTMLElement,
|
||||
target: HTMLElement,
|
||||
): MockUPlotInstance {
|
||||
return createMockUPlotInstance();
|
||||
return createMockUPlotInstance(target);
|
||||
}
|
||||
|
||||
// Add static methods to the constructor
|
||||
MockUPlot.tzDate = mockTzDate;
|
||||
MockUPlot.paths = mockPaths;
|
||||
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
|
||||
MockUPlot.pxRatio = 1;
|
||||
|
||||
// Export the constructor as default
|
||||
export default MockUPlot;
|
||||
|
||||
@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
|
||||
transformIgnorePatterns: [
|
||||
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
|
||||
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
|
||||
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/public/'],
|
||||
|
||||
@@ -21,14 +21,23 @@ import type {
|
||||
AlertmanagertypesPostableChannelDTO,
|
||||
AlertmanagertypesPostableNotificationChannelDTO,
|
||||
AlertmanagertypesReceiverDTO,
|
||||
AlertmanagertypesTestableNotificationChannelDTO,
|
||||
AlertmanagertypesUpdatableNotificationChannelDTO,
|
||||
CreateChannel201,
|
||||
CreateNotificationChannel201,
|
||||
DeleteChannelByIDPathParameters,
|
||||
DeleteNotificationChannelPathParameters,
|
||||
GetChannelByID200,
|
||||
GetChannelByIDPathParameters,
|
||||
GetNotificationChannel200,
|
||||
GetNotificationChannelPathParameters,
|
||||
ListChannels200,
|
||||
ListNotificationChannels200,
|
||||
ListNotificationChannelsParams,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateChannelByIDPathParameters,
|
||||
UpdateNotificationChannel200,
|
||||
UpdateNotificationChannelPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
@@ -649,6 +658,105 @@ export const useTestChannelDeprecated = <
|
||||
> => {
|
||||
return useMutation(getTestChannelDeprecatedMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a page of notification channels for the org. Each entry carries the channel's identity and kind but not its configuration; fetch a channel by ID for that. Supports a case-insensitive display name search (`query`), a kind filter (`kind`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).
|
||||
* @summary List notification channels
|
||||
*/
|
||||
export const listNotificationChannels = (
|
||||
params?: ListNotificationChannelsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListNotificationChannels200>({
|
||||
url: `/api/v2/notification_channels`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListNotificationChannelsQueryKey = (
|
||||
params?: ListNotificationChannelsParams,
|
||||
) => {
|
||||
return [`/api/v2/notification_channels`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListNotificationChannelsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listNotificationChannels>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListNotificationChannelsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listNotificationChannels>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListNotificationChannelsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listNotificationChannels>>
|
||||
> = ({ signal }) => listNotificationChannels(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listNotificationChannels>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListNotificationChannelsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listNotificationChannels>>
|
||||
>;
|
||||
export type ListNotificationChannelsQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List notification channels
|
||||
*/
|
||||
|
||||
export function useListNotificationChannels<
|
||||
TData = Awaited<ReturnType<typeof listNotificationChannels>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListNotificationChannelsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listNotificationChannels>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListNotificationChannelsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List notification channels
|
||||
*/
|
||||
export const invalidateListNotificationChannels = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListNotificationChannelsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListNotificationChannelsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a notification channel
|
||||
* @summary Create notification channel
|
||||
@@ -733,3 +841,370 @@ export const useCreateNotificationChannel = <
|
||||
> => {
|
||||
return useMutation(getCreateNotificationChannelMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint deletes a notification channel by ID
|
||||
* @summary Delete notification channel
|
||||
*/
|
||||
export const deleteNotificationChannel = (
|
||||
{ id }: DeleteNotificationChannelPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/notification_channels/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteNotificationChannelMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>,
|
||||
TError,
|
||||
{ pathParams: DeleteNotificationChannelPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>,
|
||||
TError,
|
||||
{ pathParams: DeleteNotificationChannelPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteNotificationChannel'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>,
|
||||
{ pathParams: DeleteNotificationChannelPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteNotificationChannel(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteNotificationChannelMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>
|
||||
>;
|
||||
|
||||
export type DeleteNotificationChannelMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete notification channel
|
||||
*/
|
||||
export const useDeleteNotificationChannel = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>,
|
||||
TError,
|
||||
{ pathParams: DeleteNotificationChannelPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>,
|
||||
TError,
|
||||
{ pathParams: DeleteNotificationChannelPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteNotificationChannelMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns a notification channel by ID. A channel written by the v1 API can carry a configuration this API does not model.
|
||||
* @summary Get notification channel by ID
|
||||
*/
|
||||
export const getNotificationChannel = (
|
||||
{ id }: GetNotificationChannelPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetNotificationChannel200>({
|
||||
url: `/api/v2/notification_channels/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetNotificationChannelQueryKey = ({
|
||||
id,
|
||||
}: GetNotificationChannelPathParameters) => {
|
||||
return [`/api/v2/notification_channels/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetNotificationChannelQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getNotificationChannel>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetNotificationChannelPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getNotificationChannel>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetNotificationChannelQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getNotificationChannel>>
|
||||
> = ({ signal }) => getNotificationChannel({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getNotificationChannel>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetNotificationChannelQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getNotificationChannel>>
|
||||
>;
|
||||
export type GetNotificationChannelQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get notification channel by ID
|
||||
*/
|
||||
|
||||
export function useGetNotificationChannel<
|
||||
TData = Awaited<ReturnType<typeof getNotificationChannel>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetNotificationChannelPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getNotificationChannel>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetNotificationChannelQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get notification channel by ID
|
||||
*/
|
||||
export const invalidateGetNotificationChannel = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetNotificationChannelPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetNotificationChannelQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint replaces a notification channel's configuration in full. Neither name is part of the request body: both are immutable. The kind may change, which replaces the channel's notifier configuration.
|
||||
* @summary Update notification channel
|
||||
*/
|
||||
export const updateNotificationChannel = (
|
||||
{ id }: UpdateNotificationChannelPathParameters,
|
||||
alertmanagertypesUpdatableNotificationChannelDTO?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateNotificationChannel200>({
|
||||
url: `/api/v2/notification_channels/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: alertmanagertypesUpdatableNotificationChannelDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateNotificationChannelMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateNotificationChannel'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>,
|
||||
{
|
||||
pathParams: UpdateNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateNotificationChannel(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateNotificationChannelMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>
|
||||
>;
|
||||
export type UpdateNotificationChannelMutationBody =
|
||||
| BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>
|
||||
| undefined;
|
||||
export type UpdateNotificationChannelMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update notification channel
|
||||
*/
|
||||
export const useUpdateNotificationChannel = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateNotificationChannelMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint sends a test notification for the configuration in the request body. The channel need not exist and nothing is persisted, so the body carries a configuration only.
|
||||
* @summary Test notification channel
|
||||
*/
|
||||
export const testNotificationChannel = (
|
||||
alertmanagertypesTestableNotificationChannelDTO?: BodyType<AlertmanagertypesTestableNotificationChannelDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/notification_channels/test`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: alertmanagertypesTestableNotificationChannelDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTestNotificationChannelMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['testNotificationChannel'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>,
|
||||
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return testNotificationChannel(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type TestNotificationChannelMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>
|
||||
>;
|
||||
export type TestNotificationChannelMutationBody =
|
||||
| BodyType<AlertmanagertypesTestableNotificationChannelDTO>
|
||||
| undefined;
|
||||
export type TestNotificationChannelMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Test notification channel
|
||||
*/
|
||||
export const useTestNotificationChannel = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getTestNotificationChannelMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -507,6 +507,15 @@ export enum AlertmanagertypesChannelKindDTO {
|
||||
jsmops = 'jsmops',
|
||||
incidentio = 'incidentio',
|
||||
}
|
||||
export enum AlertmanagertypesChannelListOrderDTO {
|
||||
asc = 'asc',
|
||||
desc = 'desc',
|
||||
}
|
||||
export enum AlertmanagertypesChannelListSortDTO {
|
||||
updated_at = 'updated_at',
|
||||
created_at = 'created_at',
|
||||
name = 'name',
|
||||
}
|
||||
export interface ModelLabelSetDTO {
|
||||
[key: string]: string;
|
||||
}
|
||||
@@ -1000,6 +1009,44 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
|
||||
wont_fix_resolution?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesListedNotificationChannelDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
kind: AlertmanagertypesChannelKindDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesListableNotificationChannelDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
channels: AlertmanagertypesListedNotificationChannelDTO[];
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
total: number;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesMaintenanceKindDTO {
|
||||
fixed = 'fixed',
|
||||
recurring = 'recurring',
|
||||
@@ -2391,6 +2438,14 @@ export interface AlertmanagertypesReceiverDTO {
|
||||
wechat_configs?: ConfigWechatConfigDTO[];
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesTestableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesUpdatableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesAttributeMappingDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -4079,6 +4134,53 @@ export interface Querybuildertypesv5LogAggregationDTO {
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5BucketOptionsLinearDTOKind {
|
||||
linear = 'linear',
|
||||
}
|
||||
export interface Querybuildertypesv5LinearBucketsSpecDTO {
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
maxValue: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
numBuckets?: number;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5BucketOptionsLinearDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @enum linear
|
||||
*/
|
||||
kind: Querybuildertypesv5BucketOptionsLinearDTOKind;
|
||||
spec: Querybuildertypesv5LinearBucketsSpecDTO;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5BucketOptionsLogDTOKind {
|
||||
log = 'log',
|
||||
}
|
||||
export interface Querybuildertypesv5LogBucketsSpecDTO {
|
||||
/**
|
||||
* @type integer,null
|
||||
*/
|
||||
scale?: number | null;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5BucketOptionsLogDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @enum log
|
||||
*/
|
||||
kind: Querybuildertypesv5BucketOptionsLogDTOKind;
|
||||
spec: Querybuildertypesv5LogBucketsSpecDTO;
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5BucketOptionsDTO =
|
||||
| Querybuildertypesv5BucketOptionsLinearDTO
|
||||
| Querybuildertypesv5BucketOptionsLogDTO;
|
||||
|
||||
export interface Querybuildertypesv5FilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -4272,6 +4374,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5LogAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4399,6 +4502,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5MetricAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4474,6 +4578,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4929,6 +5034,7 @@ export enum Querybuildertypesv5RequestTypeDTO {
|
||||
raw = 'raw',
|
||||
raw_stream = 'raw_stream',
|
||||
trace = 'trace',
|
||||
heatmap = 'heatmap',
|
||||
}
|
||||
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind {
|
||||
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
|
||||
@@ -4975,6 +5081,7 @@ export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -8542,16 +8649,7 @@ export interface Querybuildertypesv5LabelDTO {
|
||||
value?: Querybuildertypesv5LabelDTOValue;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5BucketDTO {
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5TimeSeriesValueDTO {
|
||||
bucket?: Querybuildertypesv5BucketDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -9102,12 +9200,16 @@ export interface PromotetypesPromotePathDTO {
|
||||
promote?: boolean;
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5AggregationBucketDTOMeta = {
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
buckets?: number[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
/**
|
||||
@@ -9126,10 +9228,7 @@ export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
* @type array
|
||||
*/
|
||||
lowerBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
meta?: Querybuildertypesv5AggregationBucketDTOMeta;
|
||||
meta?: Querybuildertypesv5AggregationMetaDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -9144,6 +9243,10 @@ export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
upperBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5BucketsKindDTO {
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
}
|
||||
export type Querybuildertypesv5ColumnDescriptorDTOMeta = {
|
||||
/**
|
||||
* @type string
|
||||
@@ -9610,6 +9713,10 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
|
||||
* @type boolean
|
||||
*/
|
||||
overallStateChanged: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
relatedAITracesLink?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9658,6 +9765,10 @@ export interface RulestatehistorytypesGettableRuleStateHistoryContributorDTO {
|
||||
* @type array,null
|
||||
*/
|
||||
labels: Querybuildertypesv5LabelDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
relatedAITracesLink?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9753,6 +9864,7 @@ export enum RuletypesAlertTypeDTO {
|
||||
TRACES_BASED_ALERT = 'TRACES_BASED_ALERT',
|
||||
LOGS_BASED_ALERT = 'LOGS_BASED_ALERT',
|
||||
EXCEPTIONS_BASED_ALERT = 'EXCEPTIONS_BASED_ALERT',
|
||||
AI_TRACES_BASED_ALERT = 'AI_TRACES_BASED_ALERT',
|
||||
}
|
||||
export enum RuletypesMatchTypeDTO {
|
||||
at_least_once = 'at_least_once',
|
||||
@@ -13162,6 +13274,44 @@ export type GetMetricsTreemap200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListNotificationChannelsParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
query?: string;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
kind?: AlertmanagertypesChannelKindDTO;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
sort?: AlertmanagertypesChannelListSortDTO;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
order?: AlertmanagertypesChannelListOrderDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ListNotificationChannels200 = {
|
||||
data: AlertmanagertypesListableNotificationChannelDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateNotificationChannel201 = {
|
||||
data: AlertmanagertypesGettableNotificationChannelDTO;
|
||||
/**
|
||||
@@ -13170,6 +13320,31 @@ export type CreateNotificationChannel201 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteNotificationChannelPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetNotificationChannelPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetNotificationChannel200 = {
|
||||
data: AlertmanagertypesGettableNotificationChannelDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateNotificationChannelPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateNotificationChannel200 = {
|
||||
data: AlertmanagertypesGettableNotificationChannelDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMyOrganization200 = {
|
||||
data: TypesOrganizationDTO;
|
||||
/**
|
||||
|
||||
45
frontend/src/components/MarkdownEditor/EditorStatusBar.tsx
Normal file
45
frontend/src/components/MarkdownEditor/EditorStatusBar.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { CursorPosition } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
interface EditorStatusBarProps {
|
||||
cursor: CursorPosition;
|
||||
length: number;
|
||||
maxLength: number;
|
||||
hint?: ReactNode;
|
||||
}
|
||||
|
||||
function EditorStatusBar({
|
||||
cursor,
|
||||
length,
|
||||
maxLength,
|
||||
hint,
|
||||
}: EditorStatusBarProps): JSX.Element {
|
||||
const isOverLimit = length > maxLength;
|
||||
|
||||
return (
|
||||
<div className={styles.statusBar} data-testid="markdown-editor-status">
|
||||
<Typography.Text className={styles.statusPosition}>
|
||||
{`Ln ${cursor.line}, Col ${cursor.column}`}
|
||||
<span className={styles.statusSeparator}>·</span>
|
||||
<span
|
||||
className={cx(styles.statusCount, {
|
||||
[styles.statusCountOverLimit]: isOverLimit,
|
||||
})}
|
||||
data-testid="markdown-editor-char-count"
|
||||
>
|
||||
{isOverLimit ? `${length} / ${maxLength} chars` : `${length} chars`}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
{hint && (
|
||||
<Typography.Text className={styles.statusHint}>{hint}</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditorStatusBar;
|
||||
93
frontend/src/components/MarkdownEditor/EditorToolbar.tsx
Normal file
93
frontend/src/components/MarkdownEditor/EditorToolbar.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Bold,
|
||||
CodeXml,
|
||||
Heading,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
ListOrdered,
|
||||
Table,
|
||||
Type,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import InsertVariableMenu from './InsertVariableMenu';
|
||||
import MarkdownHelp from './MarkdownHelp';
|
||||
import type { EditorCommand, EditorVariable } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
const COMMAND_ICONS: Record<string, ReactNode> = {
|
||||
heading: <Heading size={14} />,
|
||||
bold: <Bold size={14} />,
|
||||
italic: <Italic size={14} />,
|
||||
'bulleted-list': <List size={14} />,
|
||||
'numbered-list': <ListOrdered size={14} />,
|
||||
link: <Link size={14} />,
|
||||
code: <CodeXml size={14} />,
|
||||
table: <Table size={14} />,
|
||||
};
|
||||
|
||||
interface EditorToolbarProps {
|
||||
formatLabel: string;
|
||||
commands: EditorCommand[];
|
||||
onRunCommand: (command: EditorCommand) => void;
|
||||
variables: EditorVariable[];
|
||||
onInsertVariable: (name: string) => void;
|
||||
disabled: boolean;
|
||||
extra?: ReactNode;
|
||||
}
|
||||
|
||||
function EditorToolbar({
|
||||
formatLabel,
|
||||
commands,
|
||||
onRunCommand,
|
||||
variables,
|
||||
onInsertVariable,
|
||||
disabled,
|
||||
extra,
|
||||
}: EditorToolbarProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.toolbar} data-testid="markdown-editor-toolbar">
|
||||
<span className={styles.formatChip}>
|
||||
<Type size={14} />
|
||||
<Typography.Text className={styles.formatLabel}>
|
||||
{formatLabel}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<span className={styles.toolbarDivider} />
|
||||
<div className={styles.commands}>
|
||||
{commands.map((command) => (
|
||||
<TooltipSimple key={command.id} title={command.label}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={command.label}
|
||||
data-testid={`markdown-command-${command.id}`}
|
||||
onClick={(): void => onRunCommand(command)}
|
||||
>
|
||||
{COMMAND_ICONS[command.id]}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.toolbarEnd}>
|
||||
{extra}
|
||||
<InsertVariableMenu
|
||||
variables={variables}
|
||||
onSelect={onInsertVariable}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<MarkdownHelp />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditorToolbar;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronDown, DollarSign, Search } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { EditorVariable } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
interface InsertVariableMenuProps {
|
||||
variables: EditorVariable[];
|
||||
/** Receives the variable name; the caller decides the token syntax. */
|
||||
onSelect: (name: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
function toMenuItems(
|
||||
variables: EditorVariable[],
|
||||
onSelect: (name: string) => void,
|
||||
): MenuItem[] {
|
||||
return variables.map((variable) => ({
|
||||
key: variable.name,
|
||||
label: (
|
||||
<span
|
||||
className={styles.variableRow}
|
||||
data-testid={`markdown-variable-${variable.name}`}
|
||||
>
|
||||
<span className={styles.variableName}>{`$${variable.name}`}</span>
|
||||
{variable.badge && (
|
||||
<span className={styles.variableBadge}>{variable.badge}</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
onClick: (): void => onSelect(variable.name),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Searchable variable picker; hidden entirely when there is nothing to insert. */
|
||||
function InsertVariableMenu({
|
||||
variables,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: InsertVariableMenuProps): JSX.Element | null {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return query
|
||||
? variables.filter((variable) => variable.name.toLowerCase().includes(query))
|
||||
: variables;
|
||||
}, [variables, search]);
|
||||
|
||||
const items = useMemo(
|
||||
() => toMenuItems(matches, onSelect),
|
||||
[matches, onSelect],
|
||||
);
|
||||
|
||||
if (variables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple
|
||||
className={styles.variableMenu}
|
||||
menu={{
|
||||
items,
|
||||
search: {
|
||||
placeholder: 'Search variables',
|
||||
searchIcon: <Search size={14} />,
|
||||
onSearchChange: setSearch,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
prefix={<DollarSign size={14} className={styles.insertVariableIcon} />}
|
||||
suffix={<ChevronDown size={14} />}
|
||||
className={styles.insertVariable}
|
||||
data-testid="markdown-insert-variable"
|
||||
>
|
||||
Insert variable
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default InsertVariableMenu;
|
||||
@@ -0,0 +1,253 @@
|
||||
@use '../../styles/scrollbar' as *;
|
||||
|
||||
.container {
|
||||
// Read by the decoration theme in `markdownHighlight`, which can't see SCSS.
|
||||
--md-syntax-heading: var(--text-vanilla-100);
|
||||
--md-syntax-strong: var(--text-vanilla-100);
|
||||
--md-syntax-emphasis: var(--text-vanilla-300);
|
||||
--md-syntax-quote: var(--text-vanilla-400);
|
||||
--md-syntax-marker: var(--text-robin-300);
|
||||
--md-syntax-code: var(--text-forest-400);
|
||||
--md-syntax-link: var(--text-robin-400);
|
||||
--md-syntax-variable: var(--text-amber-400);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
:global(body.lightMode) .container {
|
||||
--md-syntax-heading: var(--text-ink-400);
|
||||
--md-syntax-strong: var(--text-ink-400);
|
||||
--md-syntax-emphasis: var(--text-ink-200);
|
||||
--md-syntax-quote: var(--text-neutral-light-100);
|
||||
--md-syntax-marker: var(--text-robin-500);
|
||||
--md-syntax-code: var(--text-forest-700);
|
||||
--md-syntax-link: var(--text-robin-500);
|
||||
--md-syntax-variable: var(--text-sienna-500);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.formatChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
color: var(--text-sienna-400);
|
||||
}
|
||||
|
||||
.formatLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.toolbarDivider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.commands {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbarEnd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.insertVariable {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.insertVariableIcon {
|
||||
color: var(--text-amber-400);
|
||||
}
|
||||
|
||||
// The ui library's dropdown assumes a global border-box reset this app doesn't
|
||||
// have (`box-sizing` is set on `body` only and doesn't inherit): its items are
|
||||
// `width: 100%` + padding, so in the portal they lay out content-box and
|
||||
// overflow the popup by the padding — clipping the flush-right badge.
|
||||
.variableMenu,
|
||||
.variableMenu * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.variableMenu {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
// Shrinkable, so a clamped popup truncates the name instead of clipping the
|
||||
// badge at the content's `overflow: hidden` edge.
|
||||
.variableRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.variableName {
|
||||
font-family: var(--font-family-sf-mono);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.variableBadge {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-amber-400) 40%, transparent);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-amber-400);
|
||||
}
|
||||
|
||||
.editorArea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codeMirror {
|
||||
height: 100%;
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 13px;
|
||||
|
||||
:global(.cm-editor) {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.cm-editor.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:global(.cm-gutters) {
|
||||
background: transparent;
|
||||
border-right: none;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
:global(.cm-scroller) {
|
||||
line-height: 20px;
|
||||
padding: 0 12px;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
:global(.cm-content) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.statusPosition {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
.statusSeparator {
|
||||
color: var(--l1-border);
|
||||
}
|
||||
|
||||
.statusCount {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.statusCountOverLimit {
|
||||
color: var(--text-cherry-400);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.statusHint {
|
||||
font-size: 11px;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
.helpContent {
|
||||
width: 280px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.helpTitle {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
.helpList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.helpRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
dt {
|
||||
margin: 0;
|
||||
|
||||
code {
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-forest-400);
|
||||
}
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
}
|
||||
|
||||
// The help popover portals out of `.container`, so it can't inherit its tokens.
|
||||
:global(body.lightMode) .helpRow dt code {
|
||||
color: var(--text-forest-700);
|
||||
}
|
||||
245
frontend/src/components/MarkdownEditor/MarkdownEditor.tsx
Normal file
245
frontend/src/components/MarkdownEditor/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { copilot } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror, {
|
||||
type BasicSetupOptions,
|
||||
EditorView,
|
||||
type ViewUpdate,
|
||||
} from '@uiw/react-codemirror';
|
||||
import cx from 'classnames';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { formatVariableToken, MARKDOWN_MAX_LENGTH } from './constants';
|
||||
import EditorStatusBar from './EditorStatusBar';
|
||||
import EditorToolbar from './EditorToolbar';
|
||||
import { applyTransform, replaceDocument } from './editorDocument';
|
||||
import { insertText, MARKDOWN_COMMANDS } from './markdownCommands';
|
||||
import { markdownHighlight } from './markdownHighlight';
|
||||
import type {
|
||||
CursorPosition,
|
||||
EditorCommand,
|
||||
EditorTransform,
|
||||
EditorVariable,
|
||||
} from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
/** What the status bar reports. */
|
||||
type DocumentStatus = CursorPosition & { length: number };
|
||||
|
||||
// No language grammar is loaded, so bracket/indent/completion behaviour would only
|
||||
// get in the way of prose. `indentWithTab` stays off so Tab keeps moving focus.
|
||||
const BASIC_SETUP: BasicSetupOptions = {
|
||||
lineNumbers: true,
|
||||
highlightActiveLine: true,
|
||||
highlightActiveLineGutter: true,
|
||||
foldGutter: false,
|
||||
autocompletion: false,
|
||||
bracketMatching: false,
|
||||
closeBrackets: false,
|
||||
indentOnInput: false,
|
||||
syntaxHighlighting: false,
|
||||
highlightSelectionMatches: false,
|
||||
rectangularSelection: false,
|
||||
crosshairCursor: false,
|
||||
searchKeymap: false,
|
||||
foldKeymap: false,
|
||||
lintKeymap: false,
|
||||
completionKeymap: false,
|
||||
closeBracketsKeymap: false,
|
||||
};
|
||||
|
||||
const EMPTY_VARIABLES: EditorVariable[] = [];
|
||||
|
||||
export interface MarkdownEditorProps {
|
||||
/** Seeds the document; replaced only from outside. See the sync effect. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Offered by the "Insert variable" menu; the button disables when empty. */
|
||||
variables?: EditorVariable[];
|
||||
/** What the character counter reports against. */
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
/** Shown on the toolbar chip. */
|
||||
formatLabel?: string;
|
||||
/** Rendered before the "Insert variable" menu. */
|
||||
toolbarExtra?: ReactNode;
|
||||
/** Right-hand status-bar note, e.g. "Preview updates as you type". */
|
||||
statusHint?: ReactNode;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source editor for Markdown bodies. Source-only: it neither parses nor renders
|
||||
* the body, so the preview surface and its sanitisation stay the caller's concern.
|
||||
*/
|
||||
function MarkdownEditor({
|
||||
value,
|
||||
onChange,
|
||||
variables = EMPTY_VARIABLES,
|
||||
maxLength = MARKDOWN_MAX_LENGTH,
|
||||
placeholder = 'Write Markdown…',
|
||||
readOnly = false,
|
||||
formatLabel = 'Markdown',
|
||||
toolbarExtra,
|
||||
statusHint,
|
||||
autoFocus = false,
|
||||
className,
|
||||
testId = 'markdown-editor',
|
||||
}: MarkdownEditorProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
// Set while a programmatic replacement is in flight, so the caller isn't told
|
||||
// about a change it asked for. `dispatch` runs listeners synchronously, so the
|
||||
// window is exactly one call.
|
||||
const isSyncingRef = useRef(false);
|
||||
const previousValueRef = useRef(value);
|
||||
const hasSeededRef = useRef(false);
|
||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
||||
const [status, setStatus] = useState<DocumentStatus>(() => ({
|
||||
line: 1,
|
||||
column: 1,
|
||||
length: value.length,
|
||||
}));
|
||||
|
||||
const syncDocument = useCallback((view: EditorView, next: string): void => {
|
||||
isSyncingRef.current = true;
|
||||
replaceDocument(view, next);
|
||||
isSyncingRef.current = false;
|
||||
}, []);
|
||||
|
||||
const onCreateEditor = useCallback((view: EditorView): void => {
|
||||
viewRef.current = view;
|
||||
setIsEditorReady(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Seeds the document, then applies external replacements — nothing else. Keeping
|
||||
* keystrokes out of this round-trip is what stops a stale `value` from replacing
|
||||
* the document and resetting the caret when typing outpaces React.
|
||||
*
|
||||
* The seed can't go in `onCreateEditor`: the wrapper defaults its own `value` to
|
||||
* `''` and reconciles against it once the view exists, wiping anything written
|
||||
* before that. `isEditorReady` puts this effect after that pass, since a parent's
|
||||
* effects flush after its children's.
|
||||
*
|
||||
* Focus marks ownership: a replacement arriving mid-typing is dropped rather than
|
||||
* applied over the author.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = previousValueRef.current;
|
||||
previousValueRef.current = value;
|
||||
const isSeeding = !hasSeededRef.current;
|
||||
hasSeededRef.current = true;
|
||||
|
||||
if (!isSeeding && (value === previous || view.hasFocus)) {
|
||||
return;
|
||||
}
|
||||
if (view.state.doc.toString() !== value) {
|
||||
syncDocument(view, value);
|
||||
}
|
||||
}, [value, isEditorReady, syncDocument]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(next: string): void => {
|
||||
if (!isSyncingRef.current) {
|
||||
onChange(next);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const runTransform = useCallback((transform: EditorTransform): void => {
|
||||
const view = viewRef.current;
|
||||
if (view) {
|
||||
applyTransform(view, transform);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onRunCommand = useCallback(
|
||||
(command: EditorCommand): void => runTransform(command.run),
|
||||
[runTransform],
|
||||
);
|
||||
|
||||
const onInsertVariable = useCallback(
|
||||
(name: string): void =>
|
||||
runTransform((snapshot) => insertText(snapshot, formatVariableToken(name))),
|
||||
[runTransform],
|
||||
);
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [markdownHighlight(), EditorView.lineWrapping],
|
||||
[],
|
||||
);
|
||||
|
||||
// From the document, not `value`: the caller may debounce or drop a change, and
|
||||
// the counter has to match what the author sees.
|
||||
const onUpdate = useCallback((update: ViewUpdate): void => {
|
||||
if (!update.selectionSet && !update.docChanged) {
|
||||
return;
|
||||
}
|
||||
const { head } = update.state.selection.main;
|
||||
const line = update.state.doc.lineAt(head);
|
||||
setStatus({
|
||||
line: line.number,
|
||||
column: head - line.from + 1,
|
||||
length: update.state.doc.length,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.container, className)} data-testid={testId}>
|
||||
<EditorToolbar
|
||||
formatLabel={formatLabel}
|
||||
commands={MARKDOWN_COMMANDS}
|
||||
onRunCommand={onRunCommand}
|
||||
variables={variables}
|
||||
onInsertVariable={onInsertVariable}
|
||||
disabled={readOnly}
|
||||
extra={toolbarExtra}
|
||||
/>
|
||||
<div className={styles.editorArea}>
|
||||
<CodeMirror
|
||||
className={styles.codeMirror}
|
||||
// No `value`: passing it re-enables the wrapper's own reconciliation,
|
||||
// and with it the caret reset.
|
||||
onCreateEditor={onCreateEditor}
|
||||
onChange={handleChange}
|
||||
onUpdate={onUpdate}
|
||||
theme={isDarkMode ? copilot : githubLight}
|
||||
basicSetup={BASIC_SETUP}
|
||||
placeholder={placeholder}
|
||||
editable={!readOnly}
|
||||
readOnly={readOnly}
|
||||
indentWithTab={false}
|
||||
autoFocus={autoFocus}
|
||||
extensions={extensions}
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
<EditorStatusBar
|
||||
cursor={status}
|
||||
length={status.length}
|
||||
maxLength={maxLength}
|
||||
hint={statusHint}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownEditor;
|
||||
44
frontend/src/components/MarkdownEditor/MarkdownHelp.tsx
Normal file
44
frontend/src/components/MarkdownEditor/MarkdownHelp.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { CircleHelp } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { MARKDOWN_HELP_ITEMS } from './constants';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
function MarkdownHelp(): JSX.Element {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Markdown syntax help"
|
||||
data-testid="markdown-help-trigger"
|
||||
>
|
||||
<CircleHelp size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className={styles.helpContent}>
|
||||
<Typography.Text className={styles.helpTitle}>
|
||||
Markdown syntax
|
||||
</Typography.Text>
|
||||
<dl className={styles.helpList}>
|
||||
{MARKDOWN_HELP_ITEMS.map((item) => (
|
||||
<div key={item.syntax} className={styles.helpRow}>
|
||||
<dt>
|
||||
<code>{item.syntax}</code>
|
||||
</dt>
|
||||
<dd>{item.label}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownHelp;
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
import { mockCodeMirrorDomApis } from 'components/QueryBuilderV2/QueryV2/__tests__/codemirrorDomMocks';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import MarkdownEditor from '../MarkdownEditor';
|
||||
import type { EditorVariable } from '../types';
|
||||
|
||||
beforeAll(() => {
|
||||
mockCodeMirrorDomApis();
|
||||
});
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => true,
|
||||
}));
|
||||
|
||||
const VARIABLES: EditorVariable[] = [
|
||||
{ name: 'environment', badge: 'QUERY' },
|
||||
{ name: 'service', badge: 'CUSTOM' },
|
||||
];
|
||||
|
||||
/** A caller whose state trails the editor by one keystroke. */
|
||||
function LaggingHarness(): JSX.Element {
|
||||
const [value, setValue] = useState('');
|
||||
const previousRef = useRef('');
|
||||
const onChange = useCallback((next: string): void => {
|
||||
setValue(previousRef.current);
|
||||
previousRef.current = next;
|
||||
}, []);
|
||||
|
||||
return <MarkdownEditor value={value} onChange={onChange} />;
|
||||
}
|
||||
|
||||
/** Pushes a replacement in from outside the editor. */
|
||||
function ExternalHarness(): JSX.Element {
|
||||
const [value, setValue] = useState('before');
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={(): void => setValue('after')}>
|
||||
push
|
||||
</button>
|
||||
<MarkdownEditor value={value} onChange={setValue} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Harness({
|
||||
initialValue = '',
|
||||
maxLength,
|
||||
variables = VARIABLES,
|
||||
}: {
|
||||
initialValue?: string;
|
||||
maxLength?: number;
|
||||
variables?: EditorVariable[];
|
||||
}): JSX.Element {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
return (
|
||||
<MarkdownEditor
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
variables={variables}
|
||||
maxLength={maxLength}
|
||||
statusHint="Preview updates as you type"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getView = (): EditorView => {
|
||||
const dom = document.querySelector('.cm-editor');
|
||||
const view = dom ? EditorView.findFromDOM(dom as HTMLElement) : null;
|
||||
if (!view) {
|
||||
throw new Error('editor view not mounted');
|
||||
}
|
||||
return view;
|
||||
};
|
||||
|
||||
const select = (from: number, to: number): void => {
|
||||
act(() => {
|
||||
getView().dispatch({ selection: { anchor: from, head: to } });
|
||||
});
|
||||
};
|
||||
|
||||
const documentText = (): string => getView().state.doc.toString();
|
||||
|
||||
describe('MarkdownEditor', () => {
|
||||
it('reports the caret position and character count', async () => {
|
||||
render(<Harness initialValue={'one\ntwo'} />);
|
||||
|
||||
select(5, 5);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('markdown-editor-status')).toHaveTextContent(
|
||||
'Ln 2, Col 2',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
|
||||
'7 chars',
|
||||
);
|
||||
});
|
||||
|
||||
it('flags a body over the character cap', async () => {
|
||||
render(<Harness initialValue="123456" maxLength={5} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
|
||||
'6 / 5 chars',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('applies a toolbar command to the selection', async () => {
|
||||
render(<Harness initialValue="a word b" />);
|
||||
|
||||
select(2, 6);
|
||||
await userEvent.click(screen.getByTestId('markdown-command-bold'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(documentText()).toBe('a **word** b');
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts a variable token at the caret', async () => {
|
||||
render(<Harness initialValue="env: " />);
|
||||
|
||||
select(5, 5);
|
||||
await userEvent.click(screen.getByTestId('markdown-insert-variable'));
|
||||
|
||||
// The row shows the name and kind badge.
|
||||
const row = await screen.findByTestId('markdown-variable-environment');
|
||||
expect(row).toHaveTextContent('$environment');
|
||||
expect(row).toHaveTextContent('QUERY');
|
||||
|
||||
// fireEvent: userEvent's pointer-down path walks DOM selection APIs the
|
||||
// CodeMirror mocks stub out.
|
||||
fireEvent.click(row);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(documentText()).toBe('env: $environment');
|
||||
});
|
||||
});
|
||||
|
||||
it('colours Markdown syntax and variable tokens in the source', async () => {
|
||||
render(<Harness initialValue={'## Runbook\nowner {{team}}'} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.cm-md-heading')).toBeInTheDocument();
|
||||
});
|
||||
expect(document.querySelector('.cm-md-variable')).toHaveTextContent(
|
||||
'{{team}}',
|
||||
);
|
||||
});
|
||||
|
||||
describe('uncontrolled document', () => {
|
||||
const type = (at: number, text: string): void => {
|
||||
act(() => {
|
||||
getView().dispatch({
|
||||
changes: { from: at, insert: text },
|
||||
selection: { anchor: at + text.length },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const focusEditor = (): void => {
|
||||
act(() => {
|
||||
getView().focus();
|
||||
});
|
||||
};
|
||||
|
||||
it('keeps the document and caret while the caller lags behind the typing', () => {
|
||||
render(<LaggingHarness />);
|
||||
focusEditor();
|
||||
|
||||
type(0, 'a');
|
||||
type(1, 'b');
|
||||
type(2, 'c');
|
||||
|
||||
expect(documentText()).toBe('abc');
|
||||
expect(getView().state.selection.main.head).toBe(3);
|
||||
});
|
||||
|
||||
it('reports every keystroke to the caller', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<MarkdownEditor value="ab" onChange={onChange} />);
|
||||
|
||||
type(2, 'c');
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith('abc');
|
||||
});
|
||||
|
||||
it('does not report the seed back as a change', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<MarkdownEditor value="seeded" onChange={onChange} />);
|
||||
|
||||
expect(documentText()).toBe('seeded');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies an external replacement while the editor is unfocused', async () => {
|
||||
render(<ExternalHarness />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'push' }));
|
||||
|
||||
expect(documentText()).toBe('after');
|
||||
});
|
||||
|
||||
it('ignores a replacement that arrives while the author is still typing', () => {
|
||||
render(<ExternalHarness />);
|
||||
focusEditor();
|
||||
|
||||
// fireEvent: a real click would blur the editor first. This covers an update
|
||||
// arriving on its own, while the author is still in the document.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'push' }));
|
||||
|
||||
expect(documentText()).toBe('before');
|
||||
});
|
||||
|
||||
it('counts characters from the document, not from the lagging value', async () => {
|
||||
render(<MarkdownEditor value="ab" onChange={jest.fn()} />);
|
||||
|
||||
type(2, 'cde');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
|
||||
'5 chars',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('offers both list kinds in the toolbar', () => {
|
||||
render(<Harness />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('markdown-command-bulleted-list'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('markdown-command-numbered-list'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables authoring affordances when read-only', () => {
|
||||
render(
|
||||
<MarkdownEditor
|
||||
value="body"
|
||||
onChange={jest.fn()}
|
||||
variables={VARIABLES}
|
||||
readOnly
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('markdown-command-bold')).toBeDisabled();
|
||||
expect(screen.getByTestId('markdown-insert-variable')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('hides the insert-variable control when none are available', () => {
|
||||
render(<Harness variables={[]} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('markdown-insert-variable'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { insertText, MARKDOWN_COMMANDS } from '../markdownCommands';
|
||||
import type { EditorSnapshot, EditorTransform } from '../types';
|
||||
|
||||
const commandById = (id: string): EditorTransform => {
|
||||
const command = MARKDOWN_COMMANDS.find((entry) => entry.id === id);
|
||||
if (!command) {
|
||||
throw new Error(`unknown command: ${id}`);
|
||||
}
|
||||
return command.run;
|
||||
};
|
||||
|
||||
const heading = commandById('heading');
|
||||
const bold = commandById('bold');
|
||||
const italic = commandById('italic');
|
||||
const bulletedList = commandById('bulleted-list');
|
||||
const numberedList = commandById('numbered-list');
|
||||
const link = commandById('link');
|
||||
const code = commandById('code');
|
||||
const table = commandById('table');
|
||||
|
||||
/** `|` marks a caret, `[...]` a range, so expectations read like the editor looks. */
|
||||
const snapshot = (marked: string): EditorSnapshot => {
|
||||
if (marked.includes('|')) {
|
||||
const caret = marked.indexOf('|');
|
||||
return {
|
||||
text: marked.replace('|', ''),
|
||||
selectionStart: caret,
|
||||
selectionEnd: caret,
|
||||
};
|
||||
}
|
||||
const start = marked.indexOf('[');
|
||||
const end = marked.indexOf(']') - 1;
|
||||
return {
|
||||
text: marked.replace('[', '').replace(']', ''),
|
||||
selectionStart: start,
|
||||
selectionEnd: end,
|
||||
};
|
||||
};
|
||||
|
||||
const selectionOf = (result: EditorSnapshot): string =>
|
||||
result.text.slice(result.selectionStart, result.selectionEnd);
|
||||
|
||||
describe('heading', () => {
|
||||
it('prefixes the caret line and keeps the caret on the same character', () => {
|
||||
const result = heading(snapshot('Chec|kout'));
|
||||
|
||||
expect(result.text).toBe('## Checkout');
|
||||
expect(result.selectionStart).toBe(7);
|
||||
});
|
||||
|
||||
it('strips the prefix when every selected line already has one', () => {
|
||||
const result = heading({
|
||||
text: '## one\n### two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
|
||||
it('adds the prefix when only some selected lines have one', () => {
|
||||
const result = heading({
|
||||
text: '## one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 10,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('## ## one\n## two');
|
||||
});
|
||||
|
||||
it('does not pull in the line after a selection ending on a line break', () => {
|
||||
const result = heading({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 4,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('## one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulleted list', () => {
|
||||
it('bullets every line of a multi-line selection', () => {
|
||||
const result = bulletedList({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 7,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('- one\n- two');
|
||||
expect(selectionOf(result)).toBe('- one\n- two');
|
||||
});
|
||||
|
||||
it('unbullets a list written with a different marker', () => {
|
||||
const result = bulletedList({
|
||||
text: '* one\n+ two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 11,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('numbered list', () => {
|
||||
it('numbers each line of the selection in order', () => {
|
||||
const result = numberedList({
|
||||
text: 'one\ntwo\nthree',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n2. two\n3. three');
|
||||
});
|
||||
|
||||
it('unnumbers a list whose numbering is not sequential', () => {
|
||||
const result = numberedList({
|
||||
text: '1. one\n5. two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('switching between list kinds', () => {
|
||||
it('converts bullets to numbers rather than marking them twice', () => {
|
||||
const result = numberedList({
|
||||
text: '- one\n- two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 11,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n2. two');
|
||||
});
|
||||
|
||||
it('converts numbers to bullets', () => {
|
||||
const result = bulletedList({
|
||||
text: '1. one\n2. two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('- one\n- two');
|
||||
});
|
||||
|
||||
it('keeps indentation so nested items stay nested', () => {
|
||||
const result = numberedList({
|
||||
text: 'one\n - nested',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 16,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n 2. nested');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bold and italic', () => {
|
||||
it('wraps the selection and keeps the original text selected', () => {
|
||||
const result = bold(snapshot('a [word] b'));
|
||||
|
||||
expect(result.text).toBe('a **word** b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('unwraps when the markers sit inside the selection', () => {
|
||||
const result = bold({
|
||||
text: 'a **word** b',
|
||||
selectionStart: 2,
|
||||
selectionEnd: 10,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('a word b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('unwraps when the markers sit just outside the selection', () => {
|
||||
const result = bold({
|
||||
text: 'a **word** b',
|
||||
selectionStart: 4,
|
||||
selectionEnd: 8,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('a word b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('leaves the caret between the markers when nothing is selected', () => {
|
||||
const result = italic(snapshot('a |b'));
|
||||
|
||||
expect(result.text).toBe('a __b');
|
||||
expect(result.selectionStart).toBe(3);
|
||||
expect(result.selectionEnd).toBe(3);
|
||||
});
|
||||
|
||||
it('does not mistake a leading document boundary for a marker', () => {
|
||||
const result = bold(snapshot('[word] tail'));
|
||||
|
||||
expect(result.text).toBe('**word** tail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('link', () => {
|
||||
it('selects the url when the label came from the selection', () => {
|
||||
const result = link(snapshot('see [docs] now'));
|
||||
|
||||
expect(result.text).toBe('see [docs](https://) now');
|
||||
expect(selectionOf(result)).toBe('https://');
|
||||
});
|
||||
|
||||
it('selects the label placeholder when nothing was selected', () => {
|
||||
const result = link(snapshot('see |'));
|
||||
|
||||
expect(result.text).toBe('see [text](https://)');
|
||||
expect(selectionOf(result)).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('code', () => {
|
||||
it('uses backticks for a single-line selection', () => {
|
||||
const result = code(snapshot('run [npm] here'));
|
||||
|
||||
expect(result.text).toBe('run `npm` here');
|
||||
});
|
||||
|
||||
it('fences a multi-line selection and selects its content', () => {
|
||||
const result = code({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 7,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('```\none\ntwo\n```');
|
||||
expect(selectionOf(result)).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('table', () => {
|
||||
it('starts the skeleton on its own line and selects the first header cell', () => {
|
||||
const result = table(snapshot('intro|'));
|
||||
|
||||
expect(result.text).toBe(
|
||||
'intro\n| Column | Column |\n| --- | --- |\n| | |',
|
||||
);
|
||||
expect(selectionOf(result)).toBe('Column');
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertText', () => {
|
||||
it('replaces the selection and leaves the caret after the insertion', () => {
|
||||
const result = insertText(snapshot('env is [old]'), '{{env}}');
|
||||
|
||||
expect(result.text).toBe('env is {{env}}');
|
||||
expect(result.selectionStart).toBe(14);
|
||||
expect(result.selectionEnd).toBe(14);
|
||||
});
|
||||
});
|
||||
24
frontend/src/components/MarkdownEditor/constants.ts
Normal file
24
frontend/src/components/MarkdownEditor/constants.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// The body is persisted inline in the dashboard JSON, so its length is capped.
|
||||
export const MARKDOWN_MAX_LENGTH = 16000;
|
||||
|
||||
/** The canonical syntax; the renderer resolves the other three too. */
|
||||
export const formatVariableToken = (name: string): string => `$${name}`;
|
||||
|
||||
export const MARKDOWN_HELP_ITEMS: { syntax: string; label: string }[] = [
|
||||
// First: consecutive lines joining into one paragraph is the CommonMark rule
|
||||
// authors trip over before any of the formatting syntax.
|
||||
{ syntax: 'blank line', label: 'New paragraph' },
|
||||
{ syntax: '2 spaces + ⏎', label: 'Line break' },
|
||||
{ syntax: '# Heading', label: 'Heading (1–6 #)' },
|
||||
{ syntax: '**bold**', label: 'Bold' },
|
||||
{ syntax: '_italic_', label: 'Italic' },
|
||||
{ syntax: '- item', label: 'Bulleted list' },
|
||||
{ syntax: '1. item', label: 'Numbered list' },
|
||||
{ syntax: '- [ ] task', label: 'Task list' },
|
||||
{ syntax: '[label](url)', label: 'Link' },
|
||||
{ syntax: '', label: 'Image' },
|
||||
{ syntax: '`code`', label: 'Inline code' },
|
||||
{ syntax: '```lang', label: 'Code block' },
|
||||
{ syntax: '> quote', label: 'Blockquote' },
|
||||
{ syntax: '| a | b |', label: 'Table' },
|
||||
];
|
||||
69
frontend/src/components/MarkdownEditor/editorDocument.ts
Normal file
69
frontend/src/components/MarkdownEditor/editorDocument.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
|
||||
import type { EditorSnapshot, EditorTransform } from './types';
|
||||
|
||||
// Narrows a whole-document replacement to the range that changed, so a toolbar
|
||||
// action doesn't invalidate the document's decorations or scroll position.
|
||||
function toChangeSpec(
|
||||
previous: string,
|
||||
next: string,
|
||||
): { from: number; to: number; insert: string } | null {
|
||||
if (previous === next) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shorter = Math.min(previous.length, next.length);
|
||||
let start = 0;
|
||||
while (start < shorter && previous[start] === next[start]) {
|
||||
start += 1;
|
||||
}
|
||||
|
||||
let previousEnd = previous.length;
|
||||
let nextEnd = next.length;
|
||||
while (
|
||||
previousEnd > start &&
|
||||
nextEnd > start &&
|
||||
previous[previousEnd - 1] === next[nextEnd - 1]
|
||||
) {
|
||||
previousEnd -= 1;
|
||||
nextEnd -= 1;
|
||||
}
|
||||
|
||||
return { from: start, to: previousEnd, insert: next.slice(start, nextEnd) };
|
||||
}
|
||||
|
||||
export function readSnapshot(view: EditorView): EditorSnapshot {
|
||||
const range = view.state.selection.main;
|
||||
return {
|
||||
text: view.state.doc.toString(),
|
||||
selectionStart: range.from,
|
||||
selectionEnd: range.to,
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns whether the transform ran, as CodeMirror's keymap contract expects. */
|
||||
export function applyTransform(
|
||||
view: EditorView,
|
||||
transform: EditorTransform,
|
||||
): boolean {
|
||||
if (view.state.readOnly) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const next = transform(readSnapshot(view));
|
||||
const changes = toChangeSpec(view.state.doc.toString(), next.text);
|
||||
view.dispatch({
|
||||
...(changes ? { changes } : {}),
|
||||
selection: { anchor: next.selectionStart, head: next.selectionEnd },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Replaces the whole document, for seeding and external replacements. */
|
||||
export function replaceDocument(view: EditorView, next: string): void {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: next },
|
||||
});
|
||||
}
|
||||
269
frontend/src/components/MarkdownEditor/markdownCommands.ts
Normal file
269
frontend/src/components/MarkdownEditor/markdownCommands.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import type { EditorCommand, EditorSnapshot, EditorTransform } from './types';
|
||||
|
||||
const BOLD_MARKER = '**';
|
||||
const ITALIC_MARKER = '_';
|
||||
const INLINE_CODE_MARKER = '`';
|
||||
const CODE_FENCE = '```';
|
||||
const HEADING_PREFIX = '## ';
|
||||
const BULLET_MARKER = '- ';
|
||||
|
||||
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
|
||||
const BULLET_LIST_PATTERN = /^[ \t]*[-*+] /;
|
||||
const ORDERED_LIST_PATTERN = /^[ \t]*\d+\. /;
|
||||
// Either kind of marker, matched after the indent has been split off.
|
||||
const LIST_MARKER_PATTERN = /^(?:[-*+]|\d+\.) /;
|
||||
const INDENT_PATTERN = /^[ \t]*/;
|
||||
|
||||
const LINK_LABEL_PLACEHOLDER = 'text';
|
||||
const LINK_URL_PLACEHOLDER = 'https://';
|
||||
const TABLE_CELL_PLACEHOLDER = 'Column';
|
||||
const TABLE_SNIPPET = [
|
||||
`| ${TABLE_CELL_PLACEHOLDER} | ${TABLE_CELL_PLACEHOLDER} |`,
|
||||
'| --- | --- |',
|
||||
'| | |',
|
||||
].join('\n');
|
||||
|
||||
interface LineRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
// A selection ending exactly on a line break stops there rather than pulling in
|
||||
// the next line, so "select the line, hit list" doesn't bullet the line below too.
|
||||
function expandToLines(text: string, from: number, to: number): LineRange {
|
||||
const end = to > from && text[to - 1] === '\n' ? to - 1 : to;
|
||||
const breakBefore = from === 0 ? -1 : text.lastIndexOf('\n', from - 1);
|
||||
const breakAfter = text.indexOf('\n', end);
|
||||
return {
|
||||
start: breakBefore + 1,
|
||||
end: breakAfter === -1 ? text.length : breakAfter,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pads `block` so it starts and ends on its own line. */
|
||||
function replaceWithBlock(
|
||||
snapshot: EditorSnapshot,
|
||||
block: string,
|
||||
): { text: string; blockStart: number } {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const before = text.slice(0, selectionStart);
|
||||
const after = text.slice(selectionEnd);
|
||||
const lead = before === '' || before.endsWith('\n') ? '' : '\n';
|
||||
const trail = after === '' || after.startsWith('\n') ? '' : '\n';
|
||||
return {
|
||||
text: before + lead + block + trail + after,
|
||||
blockStart: before.length + lead.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Rewrites every line the selection touches. */
|
||||
function replaceLines(
|
||||
snapshot: EditorSnapshot,
|
||||
mapLines: (lines: string[]) => string[],
|
||||
): EditorSnapshot {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const { start, end } = expandToLines(text, selectionStart, selectionEnd);
|
||||
const lines = text.slice(start, end).split('\n');
|
||||
const nextLines = mapLines(lines);
|
||||
const block = nextLines.join('\n');
|
||||
const nextText = text.slice(0, start) + block + text.slice(end);
|
||||
|
||||
if (selectionStart !== selectionEnd) {
|
||||
return {
|
||||
text: nextText,
|
||||
selectionStart: start,
|
||||
selectionEnd: start + block.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Caret-only: the range covers one line, so shift by that line's delta.
|
||||
const shifted = selectionStart + nextLines[0].length - lines[0].length;
|
||||
const caret = Math.min(Math.max(shifted, start), start + nextLines[0].length);
|
||||
return { text: nextText, selectionStart: caret, selectionEnd: caret };
|
||||
}
|
||||
|
||||
/** Strips `prefix` when every selected line already matches `pattern`, else adds it. */
|
||||
function toggleLinePrefix(prefix: string, pattern: RegExp): EditorTransform {
|
||||
return (snapshot): EditorSnapshot =>
|
||||
replaceLines(snapshot, (lines) => {
|
||||
const isApplied = lines.every((line) => pattern.test(line));
|
||||
return lines.map((line) =>
|
||||
isApplied ? line.replace(pattern, '') : `${prefix}${line}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles this kind of list marker. A line carrying the *other* kind is converted
|
||||
* rather than marked twice, and indentation is preserved so nesting survives.
|
||||
* `markerAt` takes the line's position, which is what lets an ordered list number.
|
||||
*/
|
||||
function toggleList(
|
||||
pattern: RegExp,
|
||||
markerAt: (index: number) => string,
|
||||
): EditorTransform {
|
||||
return (snapshot): EditorSnapshot =>
|
||||
replaceLines(snapshot, (lines) => {
|
||||
const isApplied = lines.every((line) => pattern.test(line));
|
||||
return lines.map((line, index) => {
|
||||
const indent = INDENT_PATTERN.exec(line)?.[0] ?? '';
|
||||
const body = line.slice(indent.length).replace(LIST_MARKER_PATTERN, '');
|
||||
return isApplied
|
||||
? `${indent}${body}`
|
||||
: `${indent}${markerAt(index)}${body}`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps when the markers are already there, whether they sit inside the selection
|
||||
* (`**bold**` selected whole) or just outside it (only `bold` selected).
|
||||
*/
|
||||
function toggleWrap(marker: string): EditorTransform {
|
||||
return ({ text, selectionStart, selectionEnd }): EditorSnapshot => {
|
||||
const selected = text.slice(selectionStart, selectionEnd);
|
||||
const width = marker.length;
|
||||
|
||||
if (
|
||||
selected.length >= width * 2 &&
|
||||
selected.startsWith(marker) &&
|
||||
selected.endsWith(marker)
|
||||
) {
|
||||
const inner = selected.slice(width, -width);
|
||||
return {
|
||||
text: text.slice(0, selectionStart) + inner + text.slice(selectionEnd),
|
||||
selectionStart,
|
||||
selectionEnd: selectionStart + inner.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
selectionStart >= width &&
|
||||
text.slice(selectionStart - width, selectionStart) === marker &&
|
||||
text.slice(selectionEnd, selectionEnd + width) === marker
|
||||
) {
|
||||
return {
|
||||
text:
|
||||
text.slice(0, selectionStart - width) +
|
||||
selected +
|
||||
text.slice(selectionEnd + width),
|
||||
selectionStart: selectionStart - width,
|
||||
selectionEnd: selectionStart - width + selected.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text:
|
||||
text.slice(0, selectionStart) +
|
||||
marker +
|
||||
selected +
|
||||
marker +
|
||||
text.slice(selectionEnd),
|
||||
selectionStart: selectionStart + width,
|
||||
selectionEnd: selectionStart + width + selected.length,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Lands the selection on whichever half is still a placeholder. */
|
||||
const insertLink: EditorTransform = ({
|
||||
text,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
}): EditorSnapshot => {
|
||||
const selected = text.slice(selectionStart, selectionEnd);
|
||||
const label = selected || LINK_LABEL_PLACEHOLDER;
|
||||
const snippet = `[${label}](${LINK_URL_PLACEHOLDER})`;
|
||||
const nextText =
|
||||
text.slice(0, selectionStart) + snippet + text.slice(selectionEnd);
|
||||
// `[` + label + `](` is label.length + 3 characters.
|
||||
const target = selected
|
||||
? {
|
||||
from: selectionStart + label.length + 3,
|
||||
length: LINK_URL_PLACEHOLDER.length,
|
||||
}
|
||||
: { from: selectionStart + 1, length: label.length };
|
||||
|
||||
return {
|
||||
text: nextText,
|
||||
selectionStart: target.from,
|
||||
selectionEnd: target.from + target.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Backticks for a single-line selection, a fence for a multi-line one. */
|
||||
const insertCode: EditorTransform = (snapshot): EditorSnapshot => {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const selected = text.slice(selectionStart, selectionEnd);
|
||||
if (!selected.includes('\n')) {
|
||||
return toggleWrap(INLINE_CODE_MARKER)(snapshot);
|
||||
}
|
||||
|
||||
const { text: nextText, blockStart } = replaceWithBlock(
|
||||
snapshot,
|
||||
`${CODE_FENCE}\n${selected}\n${CODE_FENCE}`,
|
||||
);
|
||||
const contentStart = blockStart + CODE_FENCE.length + 1;
|
||||
return {
|
||||
text: nextText,
|
||||
selectionStart: contentStart,
|
||||
selectionEnd: contentStart + selected.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Selects the first header cell, for immediate typing. */
|
||||
const insertTable: EditorTransform = (snapshot): EditorSnapshot => {
|
||||
const { text, blockStart } = replaceWithBlock(snapshot, TABLE_SNIPPET);
|
||||
const firstCell = blockStart + TABLE_SNIPPET.indexOf(TABLE_CELL_PLACEHOLDER);
|
||||
return {
|
||||
text,
|
||||
selectionStart: firstCell,
|
||||
selectionEnd: firstCell + TABLE_CELL_PLACEHOLDER.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Replaces the selection and leaves the caret after the insertion. */
|
||||
export function insertText(
|
||||
snapshot: EditorSnapshot,
|
||||
value: string,
|
||||
): EditorSnapshot {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const caret = selectionStart + value.length;
|
||||
return {
|
||||
text: text.slice(0, selectionStart) + value + text.slice(selectionEnd),
|
||||
selectionStart: caret,
|
||||
selectionEnd: caret,
|
||||
};
|
||||
}
|
||||
|
||||
/** Display order. A new action is an entry here plus an icon in `EditorToolbar`. */
|
||||
export const MARKDOWN_COMMANDS: EditorCommand[] = [
|
||||
{
|
||||
id: 'heading',
|
||||
label: 'Heading',
|
||||
run: toggleLinePrefix(HEADING_PREFIX, HEADING_PATTERN),
|
||||
},
|
||||
{
|
||||
id: 'bold',
|
||||
label: 'Bold',
|
||||
run: toggleWrap(BOLD_MARKER),
|
||||
},
|
||||
{
|
||||
id: 'italic',
|
||||
label: 'Italic',
|
||||
run: toggleWrap(ITALIC_MARKER),
|
||||
},
|
||||
{
|
||||
id: 'bulleted-list',
|
||||
label: 'Bulleted list',
|
||||
run: toggleList(BULLET_LIST_PATTERN, () => BULLET_MARKER),
|
||||
},
|
||||
{
|
||||
id: 'numbered-list',
|
||||
label: 'Numbered list',
|
||||
run: toggleList(ORDERED_LIST_PATTERN, (index) => `${index + 1}. `),
|
||||
},
|
||||
{ id: 'link', label: 'Link', run: insertLink },
|
||||
{ id: 'code', label: 'Code', run: insertCode },
|
||||
{ id: 'table', label: 'Table', run: insertTable },
|
||||
];
|
||||
149
frontend/src/components/MarkdownEditor/markdownHighlight.ts
Normal file
149
frontend/src/components/MarkdownEditor/markdownHighlight.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { Extension, Line, Range } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
type ViewUpdate,
|
||||
} from '@codemirror/view';
|
||||
|
||||
const FENCE_PATTERN = /^ {0,3}(```|~~~)/;
|
||||
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
|
||||
const QUOTE_PATTERN = /^ {0,3}> ?/;
|
||||
const LIST_MARKER_PATTERN = /^ {0,3}([-*+]|\d+\.) /;
|
||||
|
||||
/**
|
||||
* Convention: capture group 1, when present, is a left guard the token excludes —
|
||||
* the token runs from the end of that group to the end of the match. Lookbehind is
|
||||
* avoided for Safari compatibility, so guards are captured rather than asserted.
|
||||
*/
|
||||
const INLINE_PATTERNS: { pattern: RegExp; className: string }[] = [
|
||||
{ pattern: /`[^`\n]+`/g, className: 'cm-md-code' },
|
||||
{ pattern: /\*\*[^*\n]+\*\*/g, className: 'cm-md-strong' },
|
||||
{ pattern: /(^|[^\w*_`])_[^_\n]+_(?![\w_])/g, className: 'cm-md-emphasis' },
|
||||
{ pattern: /!?\[[^\]\n]*\]\([^)\n]*\)/g, className: 'cm-md-link' },
|
||||
{
|
||||
// The four variable syntaxes a dashboard body may carry.
|
||||
pattern:
|
||||
/\{\{\s*\.?[\w.-]+\s*\}\}|\[\[\s*[\w.-]+\s*\]\]|\$(?!__)[A-Za-z_]\w*(?:\.\w+)*/g,
|
||||
className: 'cm-md-variable',
|
||||
},
|
||||
];
|
||||
|
||||
const MARKS = {
|
||||
heading: Decoration.mark({ class: 'cm-md-heading' }),
|
||||
quote: Decoration.mark({ class: 'cm-md-quote' }),
|
||||
listMarker: Decoration.mark({ class: 'cm-md-list-marker' }),
|
||||
code: Decoration.mark({ class: 'cm-md-code' }),
|
||||
} as const;
|
||||
|
||||
const INLINE_MARKS = INLINE_PATTERNS.map(({ pattern, className }) => ({
|
||||
pattern,
|
||||
mark: Decoration.mark({ class: className }),
|
||||
}));
|
||||
|
||||
function pushInlineMarks(
|
||||
lineText: string,
|
||||
lineFrom: number,
|
||||
ranges: Range<Decoration>[],
|
||||
): void {
|
||||
INLINE_MARKS.forEach(({ pattern, mark }) => {
|
||||
pattern.lastIndex = 0;
|
||||
let match = pattern.exec(lineText);
|
||||
while (match !== null) {
|
||||
const guardLength = match[1]?.length ?? 0;
|
||||
const from = lineFrom + match.index + guardLength;
|
||||
const to = lineFrom + match.index + match[0].length;
|
||||
if (to > from) {
|
||||
ranges.push(mark.range(from, to));
|
||||
}
|
||||
match = pattern.exec(lineText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function pushBlockMark(line: Line, ranges: Range<Decoration>[]): void {
|
||||
if (HEADING_PATTERN.test(line.text)) {
|
||||
ranges.push(MARKS.heading.range(line.from, line.to));
|
||||
return;
|
||||
}
|
||||
if (QUOTE_PATTERN.test(line.text)) {
|
||||
ranges.push(MARKS.quote.range(line.from, line.to));
|
||||
return;
|
||||
}
|
||||
const listMarker = LIST_MARKER_PATTERN.exec(line.text);
|
||||
if (listMarker) {
|
||||
ranges.push(
|
||||
MARKS.listMarker.range(line.from, line.from + listMarker[0].length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scans the whole document rather than the viewport: fenced blocks opening above
|
||||
// the visible range would otherwise be mis-detected. Bounded by the length cap.
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const { doc } = view.state;
|
||||
const ranges: Range<Decoration>[] = [];
|
||||
let isInsideFence = false;
|
||||
|
||||
for (let lineNumber = 1; lineNumber <= doc.lines; lineNumber += 1) {
|
||||
const line = doc.line(lineNumber);
|
||||
const isFenceDelimiter = FENCE_PATTERN.test(line.text);
|
||||
|
||||
if (isFenceDelimiter || isInsideFence) {
|
||||
if (line.to > line.from) {
|
||||
ranges.push(MARKS.code.range(line.from, line.to));
|
||||
}
|
||||
isInsideFence = isFenceDelimiter ? !isInsideFence : isInsideFence;
|
||||
} else {
|
||||
pushBlockMark(line, ranges);
|
||||
pushInlineMarks(line.text, line.from, ranges);
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(ranges, true);
|
||||
}
|
||||
|
||||
// Colours come from custom properties so the SCSS module owns light/dark.
|
||||
const syntaxTheme = EditorView.theme({
|
||||
'.cm-md-heading': {
|
||||
color: 'var(--md-syntax-heading)',
|
||||
fontWeight: '600',
|
||||
},
|
||||
'.cm-md-quote': { color: 'var(--md-syntax-quote)', fontStyle: 'italic' },
|
||||
'.cm-md-list-marker': { color: 'var(--md-syntax-marker)' },
|
||||
'.cm-md-code': { color: 'var(--md-syntax-code)' },
|
||||
'.cm-md-strong': { color: 'var(--md-syntax-strong)', fontWeight: '600' },
|
||||
'.cm-md-emphasis': {
|
||||
color: 'var(--md-syntax-emphasis)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'.cm-md-link': { color: 'var(--md-syntax-link)' },
|
||||
'.cm-md-variable': { color: 'var(--md-syntax-variable)' },
|
||||
});
|
||||
|
||||
const highlightPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ decorations: (plugin): DecorationSet => plugin.decorations },
|
||||
);
|
||||
|
||||
/**
|
||||
* Decorations rather than a grammar, so the editor stays on the CodeMirror packages
|
||||
* already bundled — no `@codemirror/lang-markdown` / `@lezer` for what is only a
|
||||
* colouring pass over a body the renderer parses for real.
|
||||
*/
|
||||
export function markdownHighlight(): Extension {
|
||||
return [highlightPlugin, syntaxTheme];
|
||||
}
|
||||
27
frontend/src/components/MarkdownEditor/types.ts
Normal file
27
frontend/src/components/MarkdownEditor/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** The value every editor command reads and returns. */
|
||||
export interface EditorSnapshot {
|
||||
text: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
}
|
||||
|
||||
export type EditorTransform = (snapshot: EditorSnapshot) => EditorSnapshot;
|
||||
|
||||
export interface EditorVariable {
|
||||
name: string;
|
||||
/** Short tag for the variable's kind, e.g. "QUERY". */
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
export interface EditorCommand {
|
||||
id: string;
|
||||
/** Accessible name and tooltip for the toolbar button. */
|
||||
label: string;
|
||||
run: EditorTransform;
|
||||
}
|
||||
|
||||
/** 1-based, as the status bar reports it. */
|
||||
export interface CursorPosition {
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 4px 12px 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--text-vanilla-400);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 2px;
|
||||
transform: translateX(-1px);
|
||||
background: var(--text-vanilla-100);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.caption {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.keys {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.swatch,
|
||||
.hatchSwatch {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// Approximates the canvas hatch painted over null cells.
|
||||
.hatchSwatch {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent 0 2px,
|
||||
var(--text-vanilla-400) 2px 3px
|
||||
);
|
||||
}
|
||||
81
frontend/src/lib/uPlotV2/components/ColorBar/ColorBar.tsx
Normal file
81
frontend/src/lib/uPlotV2/components/ColorBar/ColorBar.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Styles from './ColorBar.module.scss';
|
||||
|
||||
export interface ColorBarProps {
|
||||
/** Low to high, drawn as hard-edged segments so the bar shows the same set of
|
||||
* colours as the cells. */
|
||||
ramp: string[];
|
||||
minLabel: string;
|
||||
maxLabel: string;
|
||||
/** 0..1. `null` hides the marker. */
|
||||
markerPosition?: number | null;
|
||||
/** What the colour encodes, e.g. "count". */
|
||||
label?: string;
|
||||
/** Keys for the two states a ramp cannot express: a hatched data gap, and a
|
||||
* genuine zero at the bottom. Without them the difference is guesswork. */
|
||||
showStateKeys?: boolean;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/** What a colour means, plus a marker for the value under the cursor. */
|
||||
export default function ColorBar({
|
||||
ramp,
|
||||
minLabel,
|
||||
maxLabel,
|
||||
markerPosition = null,
|
||||
label,
|
||||
showStateKeys = true,
|
||||
'data-testid': testId = 'color-bar',
|
||||
}: ColorBarProps): JSX.Element | null {
|
||||
const gradient = useMemo(() => {
|
||||
if (ramp.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (ramp.length === 1) {
|
||||
return ramp[0];
|
||||
}
|
||||
const stops = ramp.flatMap((color, index) => {
|
||||
const from = (index / ramp.length) * 100;
|
||||
const to = ((index + 1) / ramp.length) * 100;
|
||||
return [`${color} ${from}%`, `${color} ${to}%`];
|
||||
});
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||
}, [ramp]);
|
||||
|
||||
if (gradient === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clampedMarker =
|
||||
markerPosition === null ? null : Math.min(Math.max(markerPosition, 0), 1);
|
||||
|
||||
return (
|
||||
<div className={Styles.container} data-testid={testId}>
|
||||
{label && <span className={Styles.caption}>{label}</span>}
|
||||
<span className={Styles.label}>{minLabel}</span>
|
||||
<div className={Styles.track} style={{ background: gradient }}>
|
||||
{clampedMarker !== null && (
|
||||
<span
|
||||
className={Styles.marker}
|
||||
style={{ left: `${clampedMarker * 100}%` }}
|
||||
data-testid={`${testId}-marker`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className={Styles.label}>{maxLabel}</span>
|
||||
{showStateKeys && (
|
||||
<div className={Styles.keys} data-testid={`${testId}-state-keys`}>
|
||||
<span className={Styles.key}>
|
||||
<span className={Styles.hatchSwatch} />
|
||||
no data
|
||||
</span>
|
||||
<span className={Styles.key}>
|
||||
<span className={Styles.swatch} style={{ background: ramp[0] }} />
|
||||
count 0
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import ColorBar from '../ColorBar';
|
||||
|
||||
const RAMP = ['#111111', '#555555', '#999999', '#dddddd'];
|
||||
|
||||
describe('ColorBar', () => {
|
||||
it('renders the domain labels', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="1,204" />);
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,204')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing without a ramp', () => {
|
||||
const { container } = render(
|
||||
<ColorBar ramp={[]} minLabel="0" maxLabel="0" />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('hides the marker when nothing is hovered', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
|
||||
|
||||
expect(screen.queryByTestId('color-bar-marker')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('positions the marker at the hovered value', () => {
|
||||
render(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={0.25} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '25%' });
|
||||
});
|
||||
|
||||
it('clamps a marker outside the ramp to its ends', () => {
|
||||
const { rerender } = render(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={-2} />,
|
||||
);
|
||||
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '0%' });
|
||||
|
||||
rerender(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={4} />,
|
||||
);
|
||||
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '100%' });
|
||||
});
|
||||
|
||||
it('keys the two states a colour ramp cannot express', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
|
||||
|
||||
expect(screen.getByText('no data')).toBeInTheDocument();
|
||||
expect(screen.getByText('count 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('draws the count-0 key with the bottom of the ramp', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
|
||||
|
||||
expect(screen.getByText('count 0').firstChild).toHaveStyle({
|
||||
background: RAMP[0],
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the state keys when asked', () => {
|
||||
render(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" showStateKeys={false} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('no data')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('captions what the colour encodes', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" label="count" />);
|
||||
|
||||
expect(screen.getByText('count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders hard-edged segments so the bar matches the drawn cells', () => {
|
||||
render(
|
||||
<ColorBar
|
||||
ramp={['#111111', '#dddddd']}
|
||||
minLabel="0"
|
||||
maxLabel="10"
|
||||
data-testid="scale"
|
||||
/>,
|
||||
);
|
||||
|
||||
const track = screen.getByTestId('scale').querySelector('div');
|
||||
expect(track).toHaveStyle({
|
||||
background:
|
||||
'linear-gradient(to right, #111111 0%, #111111 50%, #dddddd 50%, #dddddd 100%)',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import cx from 'classnames';
|
||||
|
||||
import { formatCount, HeatmapBucketRow } from './heatmapTooltipContent';
|
||||
|
||||
import Styles from './HeatmapTooltip.module.scss';
|
||||
|
||||
/** The buckets either side of the hovered one, so a mode reads as a shape rather
|
||||
* than a single number. */
|
||||
export default function HeatmapBucketList({
|
||||
rows,
|
||||
}: {
|
||||
rows: HeatmapBucketRow[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={Styles.rows} data-testid="heatmap-tooltip-buckets">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={cx(Styles.row, { [Styles.rowHovered]: row.isHovered })}
|
||||
data-hovered={row.isHovered}
|
||||
data-testid="heatmap-tooltip-bucket-row"
|
||||
>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowValue}>{formatCount(row.count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
formatCount,
|
||||
formatPercent,
|
||||
HeatmapContributionRow,
|
||||
} from './heatmapTooltipContent';
|
||||
|
||||
import Styles from './HeatmapTooltip.module.scss';
|
||||
|
||||
/** Only shown when the cell sums more than one group. */
|
||||
export default function HeatmapContributionList({
|
||||
rows,
|
||||
groupByLabel,
|
||||
}: {
|
||||
rows: HeatmapContributionRow[];
|
||||
/** The `groupBy` keys these rows are by. */
|
||||
groupByLabel: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={Styles.rows} data-testid="heatmap-tooltip-contribution">
|
||||
{groupByLabel && <span className={Styles.section}>{groupByLabel}</span>}
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={Styles.row}
|
||||
data-testid="heatmap-tooltip-contribution-row"
|
||||
>
|
||||
<span
|
||||
className={Styles.marker}
|
||||
style={{ background: row.color }}
|
||||
data-is-legend-marker={true}
|
||||
/>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowValue}>{formatCount(row.count)}</span>
|
||||
<span className={Styles.rowPercent}>{formatPercent(row.percent)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Surface matches the shared Tooltip exactly — same tokens, same radius, no
|
||||
// shadow (the plugin's portal wrapper is transparent and paints nothing).
|
||||
//
|
||||
// Padding lives on the sections rather than here, also matching the shared
|
||||
// tooltip: TooltipFooter draws its own dashed top border, background and bottom
|
||||
// corner radius, so it has to reach the container edges.
|
||||
.container {
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--l2-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 220px;
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
}
|
||||
|
||||
// Separates the cell identity from whichever question the second block answers.
|
||||
.divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-4) var(--spacing-4) var(--spacing-3);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Hollow ring, matching the legend's unselected marker — this names the filter the
|
||||
// grid is under, it is not a colour key.
|
||||
.filterMarker {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.titleBucket {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.titleCount {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-vanilla-100);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
}
|
||||
|
||||
.section {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-vanilla-400);
|
||||
padding: 0 var(--spacing-2) var(--spacing-1);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--text-vanilla-400);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// The hovered bucket is the one the cursor is on; lift it out of the neighbours.
|
||||
.rowHovered {
|
||||
background: var(--l3-background);
|
||||
color: var(--text-vanilla-100);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowValue {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rowPercent {
|
||||
flex: 0 0 auto;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
color: var(--text-vanilla-400);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.marker {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
resolveColumnIndex,
|
||||
resolveRowIndex,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { HeatmapTooltipProps } from '../../../types';
|
||||
import HeatmapBucketList from './HeatmapBucketList';
|
||||
import HeatmapContributionList from './HeatmapContributionList';
|
||||
import {
|
||||
buildBucketRows,
|
||||
buildContributionRows,
|
||||
formatBucketLabel,
|
||||
formatColumnRange,
|
||||
formatCount,
|
||||
formatGroupFilter,
|
||||
HeatmapTooltipBody,
|
||||
resolveGroupByLabel,
|
||||
resolveTooltipBody,
|
||||
} from './heatmapTooltipContent';
|
||||
|
||||
import Styles from './HeatmapTooltip.module.scss';
|
||||
|
||||
/**
|
||||
* The cell identity is the same in every state; the second block answers whichever
|
||||
* question the panel state leaves open (see `resolveTooltipBody`). Purpose-built
|
||||
* rather than composed from the shared `Tooltip`, which renders a flat list of
|
||||
* series values — none of these states is that shape.
|
||||
*
|
||||
* The cell comes from the live cursor, not a prop: uPlot's `cursor.idx` snaps to
|
||||
* the nearest timestamp, so half of every column would report its neighbour.
|
||||
*/
|
||||
export default function HeatmapTooltip({
|
||||
uPlotInstance,
|
||||
yAxis,
|
||||
step,
|
||||
series,
|
||||
visibleGroups,
|
||||
groupColor,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
isPinned,
|
||||
dismiss,
|
||||
renderTooltipFooter,
|
||||
}: HeatmapTooltipProps): JSX.Element | null {
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
|
||||
// Read outside the memo: uPlot mutates the same instance on every move, so
|
||||
// keying off the instance alone would freeze the cell.
|
||||
const { left = -10, top = -10 } = uPlotInstance.cursor;
|
||||
|
||||
const cell = useMemo(() => {
|
||||
if (left < 0 || top < 0) {
|
||||
return null;
|
||||
}
|
||||
const timestamps = uPlotInstance.data[0] as ArrayLike<number>;
|
||||
const column = resolveColumnIndex(
|
||||
timestamps,
|
||||
uPlotInstance.posToVal(left, 'x'),
|
||||
step,
|
||||
);
|
||||
const row = resolveRowIndex(yAxis.edges, uPlotInstance.posToVal(top, 'y'));
|
||||
if (column === null || row === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
row,
|
||||
column,
|
||||
timestamp: timestamps[column],
|
||||
count:
|
||||
(uPlotInstance.data[row + 1] as Array<number | null> | undefined)?.[
|
||||
column
|
||||
] ?? null,
|
||||
};
|
||||
}, [left, top, uPlotInstance, yAxis, step]);
|
||||
|
||||
// The cell sums the enabled groups, so those are what a breakdown must cover.
|
||||
const visible = useMemo(
|
||||
() => series.filter((entry) => visibleGroups.includes(entry.label)),
|
||||
[series, visibleGroups],
|
||||
);
|
||||
const body = resolveTooltipBody(visible.length);
|
||||
|
||||
const bucketRows = useMemo(() => {
|
||||
if (!cell || body !== HeatmapTooltipBody.Buckets) {
|
||||
return [];
|
||||
}
|
||||
return buildBucketRows({
|
||||
counts: uPlotInstance.data.slice(1) as Array<
|
||||
ArrayLike<number | null> | undefined
|
||||
>,
|
||||
yAxis,
|
||||
row: cell.row,
|
||||
column: cell.column,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
});
|
||||
}, [cell, body, uPlotInstance, yAxis, yAxisUnit, decimalPrecision]);
|
||||
|
||||
const contributionRows = useMemo(() => {
|
||||
if (!cell || body !== HeatmapTooltipBody.Contribution) {
|
||||
return [];
|
||||
}
|
||||
return buildContributionRows({
|
||||
series: visible,
|
||||
timestamp: cell.timestamp,
|
||||
row: cell.row,
|
||||
color: groupColor,
|
||||
});
|
||||
}, [cell, body, visible, groupColor]);
|
||||
|
||||
if (!cell) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A single enabled group out of several means the legend has isolated it.
|
||||
const isolated =
|
||||
series.length > 1 && visible.length === 1 ? visible[0] : undefined;
|
||||
const filterLabel = formatGroupFilter(isolated);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
|
||||
data-pinned={isPinned}
|
||||
data-testid="heatmap-tooltip"
|
||||
>
|
||||
<div className={Styles.identity}>
|
||||
<div className={Styles.header}>
|
||||
<span data-testid="heatmap-tooltip-range">
|
||||
{formatColumnRange({
|
||||
start: cell.timestamp,
|
||||
step,
|
||||
timezone: resolvedTimezone,
|
||||
})}
|
||||
</span>
|
||||
{filterLabel && (
|
||||
<span
|
||||
className={Styles.filter}
|
||||
style={{ color: groupColor }}
|
||||
data-testid="heatmap-tooltip-filter"
|
||||
>
|
||||
<span className={Styles.filterMarker} />
|
||||
<span className={Styles.filterLabel}>{filterLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={Styles.title}>
|
||||
<span className={Styles.titleBucket} data-testid="heatmap-tooltip-bucket">
|
||||
{formatBucketLabel({
|
||||
yAxis,
|
||||
row: cell.row,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
})}
|
||||
</span>
|
||||
<span className={Styles.titleCount} data-testid="heatmap-tooltip-count">
|
||||
{formatCount(cell.count)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} data-testid="heatmap-tooltip-divider" />
|
||||
|
||||
{body === HeatmapTooltipBody.Contribution ? (
|
||||
<HeatmapContributionList
|
||||
rows={contributionRows}
|
||||
groupByLabel={resolveGroupByLabel(series)}
|
||||
/>
|
||||
) : (
|
||||
<HeatmapBucketList rows={bucketRows} />
|
||||
)}
|
||||
|
||||
{renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import { render, RenderResult, screen } from 'tests/test-utils';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import HeatmapTooltip from '../HeatmapTooltip';
|
||||
|
||||
const BOUNDS = [100, 500, 1000, 2500];
|
||||
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
const TIMESTAMPS = [1_700_000_000, 1_700_000_300];
|
||||
const STEP = 300;
|
||||
const PLOT_SIZE = 500;
|
||||
const ROW_COUNT = BOUNDS.length + 1;
|
||||
|
||||
/** Row 2 is the 500ms–1s bucket the design mock hovers. */
|
||||
const HOVERED_ROW = 2;
|
||||
|
||||
function seriesFor(
|
||||
group: string,
|
||||
countsAtHoveredRow: [number, number],
|
||||
): HeatmapSeries {
|
||||
return {
|
||||
label: `service.name=${group}`,
|
||||
labels: [{ key: 'service.name', value: group }],
|
||||
points: TIMESTAMPS.map((timestamp, column) => ({
|
||||
timestamp,
|
||||
counts: Array.from({ length: ROW_COUNT }, (_, row) =>
|
||||
row === HOVERED_ROW ? countsAtHoveredRow[column] : row * 10,
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const GROUPED: HeatmapSeries[] = [
|
||||
seriesFor('checkout', [355, 300]),
|
||||
seriesFor('frontend', [86, 80]),
|
||||
seriesFor('cart', [14, 10]),
|
||||
seriesFor('payments', [0, 0]),
|
||||
];
|
||||
|
||||
/** Grid counts, matching what the renderer would have been handed. */
|
||||
function gridData(rowTotals: number[]): uPlot.AlignedData {
|
||||
return [
|
||||
TIMESTAMPS,
|
||||
...Array.from({ length: ROW_COUNT }, (_, row) => [
|
||||
rowTotals[row] ?? row * 40,
|
||||
rowTotals[row] ?? row * 40,
|
||||
]),
|
||||
] as unknown as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
// Totals chosen to match the mock: 2 / 92 / 455 / 269 / 10 bottom-up.
|
||||
const ROW_TOTALS = [10, 269, 455, 92, 2];
|
||||
|
||||
function createFakePlot(): uPlot {
|
||||
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
|
||||
const ySpan = Y_AXIS.max - Y_AXIS.min;
|
||||
// Aim the cursor at the middle of the hovered row, first column.
|
||||
const rowMid = (Y_AXIS.edges[HOVERED_ROW] + Y_AXIS.edges[HOVERED_ROW + 1]) / 2;
|
||||
const top = PLOT_SIZE * (1 - (rowMid - Y_AXIS.min) / ySpan);
|
||||
|
||||
return {
|
||||
data: gridData(ROW_TOTALS),
|
||||
cursor: { left: PLOT_SIZE * 0.25, top },
|
||||
posToVal: (pos: number, scaleKey: string): number =>
|
||||
scaleKey === 'x'
|
||||
? TIMESTAMPS[0] + (pos / PLOT_SIZE) * xSpan
|
||||
: Y_AXIS.min + ((PLOT_SIZE - pos) / PLOT_SIZE) * ySpan,
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
function renderTooltip(
|
||||
overrides: Partial<React.ComponentProps<typeof HeatmapTooltip>> = {},
|
||||
): RenderResult {
|
||||
return render(
|
||||
<HeatmapTooltip
|
||||
id="panel-1"
|
||||
uPlotInstance={createFakePlot()}
|
||||
dataIndexes={[]}
|
||||
seriesIndex={null}
|
||||
isPinned={false}
|
||||
dismiss={jest.fn()}
|
||||
viaSync={false}
|
||||
yAxis={Y_AXIS}
|
||||
step={STEP}
|
||||
series={GROUPED}
|
||||
visibleGroups={GROUPED.map((entry) => entry.label)}
|
||||
groupColor="#fcfdbf"
|
||||
yAxisUnit="ms"
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('HeatmapTooltip — cell identity', () => {
|
||||
it('heads with the time span the column covers, not a single instant', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-range').textContent).toMatch(
|
||||
/^\d{2}:\d{2} → \d{2}:\d{2}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('names the hovered bucket and its count', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-bucket')).toHaveTextContent(
|
||||
'500 ms – 1 s',
|
||||
);
|
||||
expect(screen.getByTestId('heatmap-tooltip-count')).toHaveTextContent('455');
|
||||
});
|
||||
|
||||
it('marks the surface as pinned so the border picks up the ring', () => {
|
||||
renderTooltip({ isPinned: true });
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
|
||||
'data-pinned',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('is unpinned by default', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
|
||||
'data-pinned',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('separates the cell identity from the block below it', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-divider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a footer when the panel supplies one', () => {
|
||||
renderTooltip({
|
||||
renderTooltipFooter: ({ isPinned }): JSX.Element => (
|
||||
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('footer')).toHaveTextContent('press P');
|
||||
});
|
||||
|
||||
it('tells the footer when the tooltip is pinned', () => {
|
||||
renderTooltip({
|
||||
isPinned: true,
|
||||
renderTooltipFooter: ({ isPinned }): JSX.Element => (
|
||||
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('footer')).toHaveTextContent('pinned');
|
||||
});
|
||||
|
||||
it('renders nothing when the cursor is off the plot', () => {
|
||||
const plot = createFakePlot();
|
||||
(plot as { cursor: unknown }).cursor = { left: -10, top: -10 };
|
||||
|
||||
const { container } = renderTooltip({ uPlotInstance: plot });
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — grouped, nothing selected', () => {
|
||||
it('breaks the cell down by group instead of showing neighbours', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('heatmap-tooltip-contribution'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-buckets'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('heads the breakdown with the groupBy key', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByText('service.name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names each row by value alone and orders by contribution', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen
|
||||
.getAllByTestId('heatmap-tooltip-contribution-row')
|
||||
.map((row) => row.textContent);
|
||||
|
||||
expect(rows[0]).toContain('checkout');
|
||||
expect(rows[0]).toContain('355');
|
||||
expect(rows[1]).toContain('frontend');
|
||||
expect(rows[2]).toContain('cart');
|
||||
});
|
||||
|
||||
it('shows each group"s share of the cell', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen.getAllByTestId('heatmap-tooltip-contribution-row');
|
||||
// 355 / 455 = 78%, 86 / 455 = 19%, 14 / 455 = 3.1%
|
||||
expect(rows[0]).toHaveTextContent('78%');
|
||||
expect(rows[1]).toHaveTextContent('19%');
|
||||
expect(rows[2]).toHaveTextContent('3.1%');
|
||||
});
|
||||
|
||||
it('still lists a group that contributed nothing', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen.getAllByTestId('heatmap-tooltip-contribution-row');
|
||||
expect(rows).toHaveLength(GROUPED.length);
|
||||
expect(rows[3]).toHaveTextContent('payments');
|
||||
expect(rows[3]).toHaveTextContent('0.0%');
|
||||
});
|
||||
|
||||
it('does not name a filter when every group is enabled', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-filter'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — grouped, one enabled', () => {
|
||||
const selected = { visibleGroups: ['service.name=checkout'] };
|
||||
|
||||
it('returns to neighbouring buckets, since contribution is already answered', () => {
|
||||
renderTooltip(selected);
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-buckets')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-contribution'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the active filter', () => {
|
||||
renderTooltip(selected);
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-filter')).toHaveTextContent(
|
||||
'service.name = checkout',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — no grouping', () => {
|
||||
const ungrouped = {
|
||||
series: [{ label: '', points: GROUPED[0].points }],
|
||||
visibleGroups: [''],
|
||||
};
|
||||
|
||||
it('shows neighbouring buckets, highest first', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
const rows = screen
|
||||
.getAllByTestId('heatmap-tooltip-bucket-row')
|
||||
.map((row) => row.textContent);
|
||||
|
||||
// Two buckets either side of 500ms – 1s, reading down the y axis.
|
||||
expect(rows).toHaveLength(5);
|
||||
expect(rows[0]).toContain('> 2.5 s');
|
||||
expect(rows[2]).toContain('500 ms – 1 s');
|
||||
expect(rows[4]).toContain('≤ 100 ms');
|
||||
});
|
||||
|
||||
it('marks the hovered bucket among its neighbours', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
const hovered = screen
|
||||
.getAllByTestId('heatmap-tooltip-bucket-row')
|
||||
.filter((row) => row.dataset.hovered === 'true');
|
||||
|
||||
expect(hovered).toHaveLength(1);
|
||||
expect(hovered[0]).toHaveTextContent('500 ms – 1 s');
|
||||
});
|
||||
|
||||
it('never breaks down a single series', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-contribution'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatRowLabel } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
HeatmapSeries,
|
||||
HeatmapYAxis,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
/** Rows shown either side of the hovered one. */
|
||||
const NEIGHBOUR_SPAN = 2;
|
||||
/** Below this share a percentage needs a decimal to stay informative. */
|
||||
const PERCENT_DECIMAL_THRESHOLD = 10;
|
||||
/** Below this, the header needs seconds to distinguish columns. */
|
||||
const SUB_MINUTE_STEP = 60;
|
||||
|
||||
export const NO_DATA_LABEL = 'no data';
|
||||
|
||||
/**
|
||||
* Which question the second block answers. A cell summed across several groups begs
|
||||
* "which group?"; a cell that is already one series begs "how does this bucket
|
||||
* compare with its neighbours?".
|
||||
*/
|
||||
export enum HeatmapTooltipBody {
|
||||
Buckets = 'buckets',
|
||||
Contribution = 'contribution',
|
||||
}
|
||||
|
||||
export interface HeatmapBucketRow {
|
||||
label: string;
|
||||
count: number | null;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
export interface HeatmapContributionRow {
|
||||
label: string;
|
||||
color: string;
|
||||
count: number;
|
||||
/** Share of the cell's total, 0..100. */
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
|
||||
// One enabled group contributes the whole cell, so there is nothing to break
|
||||
// down — whether the query is ungrouped or the legend has isolated a group.
|
||||
return visibleCount > 1
|
||||
? HeatmapTooltipBody.Contribution
|
||||
: HeatmapTooltipBody.Buckets;
|
||||
}
|
||||
|
||||
/** A cell is an interval, so a single instant would misreport which observations
|
||||
* it contains. The date is left to the x axis directly below. */
|
||||
export function formatColumnRange({
|
||||
start,
|
||||
step,
|
||||
timezone,
|
||||
}: {
|
||||
/** Column start, in seconds. */
|
||||
start: number;
|
||||
/** Column width, in seconds. */
|
||||
step: number;
|
||||
timezone: string;
|
||||
}): string {
|
||||
const format =
|
||||
step < SUB_MINUTE_STEP
|
||||
? DATE_TIME_FORMATS.TIME_SECONDS
|
||||
: DATE_TIME_FORMATS.TIME;
|
||||
const from = dayjs(start * 1000).tz(timezone);
|
||||
const to = dayjs((start + step) * 1000).tz(timezone);
|
||||
return `${from.format(format)} → ${to.format(format)}`;
|
||||
}
|
||||
|
||||
/** Formatted with the panel's unit. */
|
||||
export function formatBucketLabel({
|
||||
yAxis,
|
||||
row,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
}: {
|
||||
yAxis: HeatmapYAxis;
|
||||
row: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}): string {
|
||||
const bucket = yAxis.rows[row];
|
||||
if (!bucket) {
|
||||
return '';
|
||||
}
|
||||
return formatRowLabel(bucket, (value) =>
|
||||
getToolTipValue(String(value), yAxisUnit, decimalPrecision),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCount(count: number | null): string {
|
||||
return count === null ? NO_DATA_LABEL : count.toLocaleString();
|
||||
}
|
||||
|
||||
export function formatPercent(percent: number): string {
|
||||
return percent >= PERCENT_DECIMAL_THRESHOLD
|
||||
? `${Math.round(percent)}%`
|
||||
: `${percent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** Names the group the grid is currently isolated to. */
|
||||
export function formatGroupFilter(series: HeatmapSeries | undefined): string {
|
||||
if (!series) {
|
||||
return '';
|
||||
}
|
||||
if (!series.labels?.length) {
|
||||
return series.label;
|
||||
}
|
||||
return series.labels
|
||||
.map((label) => `${label.key} = ${label.value}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/** The `groupBy` keys the breakdown is by. */
|
||||
export function resolveGroupByLabel(series: HeatmapSeries[]): string {
|
||||
const keys = series[0]?.labels?.map((label) => label.key) ?? [];
|
||||
return keys.join(', ');
|
||||
}
|
||||
|
||||
function formatSeriesValue(series: HeatmapSeries): string {
|
||||
if (!series.labels?.length) {
|
||||
return series.label;
|
||||
}
|
||||
return series.labels.map((label) => label.value).join(', ');
|
||||
}
|
||||
|
||||
/** Highest first, so the list reads in the same direction as the y axis. */
|
||||
export function buildBucketRows({
|
||||
counts,
|
||||
yAxis,
|
||||
row,
|
||||
column,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
}: {
|
||||
/** Row-major, as the renderer draws them. */
|
||||
counts: Array<ArrayLike<number | null> | undefined>;
|
||||
yAxis: HeatmapYAxis;
|
||||
row: number;
|
||||
column: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}): HeatmapBucketRow[] {
|
||||
const formatBucketValue = (value: number): string =>
|
||||
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
|
||||
|
||||
const rows: HeatmapBucketRow[] = [];
|
||||
for (let offset = NEIGHBOUR_SPAN; offset >= -NEIGHBOUR_SPAN; offset -= 1) {
|
||||
const index = row + offset;
|
||||
const bucket = yAxis.rows[index];
|
||||
if (!bucket) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
label: formatRowLabel(bucket, formatBucketValue),
|
||||
count: counts[index]?.[column] ?? null,
|
||||
isHovered: offset === 0,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest first. Groups that contributed nothing are still listed — that is an
|
||||
* answer, and dropping the row makes the list look truncated.
|
||||
*/
|
||||
export function buildContributionRows({
|
||||
series,
|
||||
timestamp,
|
||||
row,
|
||||
color,
|
||||
}: {
|
||||
/** Only the groups the legend has enabled — they are what the cell sums. */
|
||||
series: HeatmapSeries[];
|
||||
/** Column start, in seconds. */
|
||||
timestamp: number;
|
||||
row: number;
|
||||
color: string;
|
||||
}): HeatmapContributionRow[] {
|
||||
const counts = series.map((entry) => {
|
||||
const point = entry.points.find((item) => item.timestamp === timestamp);
|
||||
// Absent or null contributed nothing to the sum, which is what this breaks down.
|
||||
return point?.counts[row] ?? 0;
|
||||
});
|
||||
const total = counts.reduce((sum, count) => sum + count, 0);
|
||||
|
||||
return series
|
||||
.map((entry, index) => ({
|
||||
label: formatSeriesValue(entry),
|
||||
color,
|
||||
count: counts[index],
|
||||
percent: total > 0 ? (counts[index] / total) * 100 : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import uPlot from 'uplot';
|
||||
|
||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
import { LegendItem } from '../config/types';
|
||||
import { HeatmapSeries, HeatmapYAxis } from '../plugins/HeatmapPlugin/types';
|
||||
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
|
||||
|
||||
/**
|
||||
@@ -103,6 +104,21 @@ export interface BarTooltipProps extends BaseTooltipProps, TooltipRenderArgs {
|
||||
export interface HistogramTooltipProps
|
||||
extends BaseTooltipProps, TooltipRenderArgs {}
|
||||
|
||||
/** Not part of `TooltipProps`: it renders its own container, since none of its
|
||||
* states is the flat series list the shared `Tooltip` draws. */
|
||||
export interface HeatmapTooltipProps
|
||||
extends BaseTooltipProps, TooltipRenderArgs {
|
||||
yAxis: HeatmapYAxis;
|
||||
/** Column width in seconds. */
|
||||
step: number;
|
||||
/** Needed to break a summed cell down by contribution. */
|
||||
series: HeatmapSeries[];
|
||||
/** Groups the legend has enabled; the cell sums exactly these. */
|
||||
visibleGroups: string[];
|
||||
/** Same colour the legend and the densest cells use. */
|
||||
groupColor: string;
|
||||
}
|
||||
|
||||
export type TooltipProps =
|
||||
| TimeSeriesTooltipProps
|
||||
| BarTooltipProps
|
||||
|
||||
@@ -148,6 +148,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
show = true,
|
||||
side = 2, // bottom by default
|
||||
space,
|
||||
splits,
|
||||
gap = 5, // default gap is 5
|
||||
} = this.props;
|
||||
|
||||
@@ -179,6 +180,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
if (values) {
|
||||
axisConfig.values = values;
|
||||
}
|
||||
if (splits) {
|
||||
axisConfig.splits = splits;
|
||||
}
|
||||
if (gap !== undefined) {
|
||||
axisConfig.gap = gap;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,13 @@ export class UPlotScaleBuilder extends ConfigBuilder<
|
||||
|
||||
// Special handling for time scales (X axis)
|
||||
if (time) {
|
||||
// An explicit range wins: the alignment below trims the tail of the window
|
||||
// to whole minutes, which is right for point-based series but drops the
|
||||
// final column of any chart whose marks span an interval.
|
||||
if (range) {
|
||||
return { [scaleKey]: { time: true, auto: false, range } };
|
||||
}
|
||||
|
||||
let minTime = this.min ?? 0;
|
||||
let maxTime = this.max ?? 0;
|
||||
|
||||
|
||||
@@ -78,6 +78,8 @@ export interface AxisProps {
|
||||
};
|
||||
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
|
||||
values?: uPlot.Axis.Values;
|
||||
/** Explicit axis splits, overriding the default tick calculation. */
|
||||
splits?: uPlot.Axis.Splits;
|
||||
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
|
||||
gap?: number;
|
||||
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
clampColorSteps,
|
||||
createHeatmapColorResolver,
|
||||
DEFAULT_COLOR_STEPS,
|
||||
DEFAULT_HEATMAP_COLORS,
|
||||
getMaxCount,
|
||||
MAX_COLOR_STEPS,
|
||||
MIN_OPACITY_ALPHA,
|
||||
normalizeCount,
|
||||
resolveCountDomain,
|
||||
} from '../colorScale';
|
||||
import { HeatmapColorMode, HeatmapColorScale } from '../types';
|
||||
|
||||
const SERIES_COLOR = '#4e74f8';
|
||||
|
||||
describe('getMaxCount', () => {
|
||||
it('ignores null cells', () => {
|
||||
expect(
|
||||
getMaxCount([
|
||||
[1, null, 9],
|
||||
[null, 4],
|
||||
]),
|
||||
).toBe(9);
|
||||
});
|
||||
|
||||
it('returns 0 for an empty or all-null grid', () => {
|
||||
expect(getMaxCount([])).toBe(0);
|
||||
expect(getMaxCount([[null, null]])).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores non-finite counts', () => {
|
||||
expect(getMaxCount([[3, Number.POSITIVE_INFINITY, Number.NaN]])).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCountDomain', () => {
|
||||
it('floors at 0 on auto so a zero count sits at the bottom of the scale', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: null, maxCount: null }, [[5, 20]]),
|
||||
).toStrictEqual({
|
||||
min: 0,
|
||||
max: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('honours explicit clamps', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: 10, maxCount: 100 }, [[5, 20]]),
|
||||
).toStrictEqual({
|
||||
min: 10,
|
||||
max: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses a max at or below min', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: 50, maxCount: 10 }, [[5]]),
|
||||
).toStrictEqual({
|
||||
min: 50,
|
||||
max: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCount', () => {
|
||||
const domain = { min: 0, max: 1000 };
|
||||
|
||||
it('spreads low counts on a log scale where a linear one washes them out', () => {
|
||||
const log = (count: number): number =>
|
||||
normalizeCount({ count, domain, scale: HeatmapColorScale.Log });
|
||||
|
||||
expect(log(10)).toBeCloseTo(1 / 3, 5);
|
||||
expect(log(20)).toBeCloseTo(Math.log10(20) / 3, 5);
|
||||
expect(
|
||||
normalizeCount({ count: 10, domain, scale: HeatmapColorScale.Linear }),
|
||||
).toBeCloseTo(0.01, 5);
|
||||
});
|
||||
|
||||
it('puts 0 and 1 at the bottom of a log scale', () => {
|
||||
expect(
|
||||
normalizeCount({ count: 0, domain, scale: HeatmapColorScale.Log }),
|
||||
).toBe(0);
|
||||
expect(
|
||||
normalizeCount({ count: 1, domain, scale: HeatmapColorScale.Log }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('reaches the top of the scale at max on every scale', () => {
|
||||
[
|
||||
HeatmapColorScale.Log,
|
||||
HeatmapColorScale.Sqrt,
|
||||
HeatmapColorScale.Linear,
|
||||
].forEach((scale) => {
|
||||
expect(normalizeCount({ count: 1000, domain, scale })).toBeCloseTo(1, 6);
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the square root of the linear position on a sqrt scale', () => {
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 250,
|
||||
domain: { min: 0, max: 1000 },
|
||||
scale: HeatmapColorScale.Sqrt,
|
||||
}),
|
||||
).toBeCloseTo(0.5, 6);
|
||||
});
|
||||
|
||||
it('clamps counts outside the domain', () => {
|
||||
const scale = HeatmapColorScale.Linear;
|
||||
expect(normalizeCount({ count: -5, domain, scale })).toBe(0);
|
||||
expect(normalizeCount({ count: 5000, domain, scale })).toBe(1);
|
||||
});
|
||||
|
||||
it('returns the bottom of the scale when min equals max', () => {
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 7,
|
||||
domain: { min: 7, max: 7 },
|
||||
scale: HeatmapColorScale.Log,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('handles a log domain whose min and max share a decade floor', () => {
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 1,
|
||||
domain: { min: 0, max: 1 },
|
||||
scale: HeatmapColorScale.Log,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampColorSteps', () => {
|
||||
it('clamps to the supported range', () => {
|
||||
expect(clampColorSteps(1)).toBe(2);
|
||||
expect(clampColorSteps(500)).toBe(MAX_COLOR_STEPS);
|
||||
expect(clampColorSteps(32)).toBe(32);
|
||||
});
|
||||
|
||||
it('falls back to the default for a non-finite value', () => {
|
||||
expect(clampColorSteps(Number.NaN)).toBe(DEFAULT_COLOR_STEPS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createHeatmapColorResolver', () => {
|
||||
const build = (
|
||||
overrides: Partial<typeof DEFAULT_HEATMAP_COLORS> = {},
|
||||
isDarkMode = true,
|
||||
): ReturnType<typeof createHeatmapColorResolver> =>
|
||||
createHeatmapColorResolver({
|
||||
options: { ...DEFAULT_HEATMAP_COLORS, ...overrides },
|
||||
domain: { min: 0, max: 1000 },
|
||||
isDarkMode,
|
||||
seriesColor: SERIES_COLOR,
|
||||
});
|
||||
|
||||
it('leaves null cells uncoloured so they can be hatched', () => {
|
||||
const resolver = build();
|
||||
|
||||
expect(resolver.colorFor(null)).toBeNull();
|
||||
expect(resolver.positionOf(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('gives a zero count the bottom colour, not the null treatment', () => {
|
||||
const resolver = build();
|
||||
|
||||
expect(resolver.colorFor(0)).toBe(resolver.ramp[0]);
|
||||
});
|
||||
|
||||
it('emits one ramp entry per step', () => {
|
||||
expect(build({ steps: 8 }).ramp).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('maps the max count to the top of the ramp', () => {
|
||||
const resolver = build({ steps: 8 });
|
||||
|
||||
expect(resolver.colorFor(1000)).toBe(resolver.ramp[7]);
|
||||
});
|
||||
|
||||
it('picks different stops per theme so low counts stay near the surface', () => {
|
||||
expect(build({}, true).ramp[0]).not.toBe(build({}, false).ramp[0]);
|
||||
});
|
||||
|
||||
it('varies alpha in opacity mode, never below the visibility floor', () => {
|
||||
const resolver = build({ mode: HeatmapColorMode.Opacity, steps: 4 });
|
||||
|
||||
expect(resolver.ramp[0]).toBe(`rgba(78, 116, 248, ${MIN_OPACITY_ALPHA})`);
|
||||
// `color` drops the alpha channel from the string once it reaches 1.
|
||||
expect(resolver.ramp[3]).toBe('rgb(78, 116, 248)');
|
||||
});
|
||||
|
||||
it('prefers an explicit opacity fill over the series colour', () => {
|
||||
const resolver = build({
|
||||
mode: HeatmapColorMode.Opacity,
|
||||
fill: '#e5484d',
|
||||
steps: 2,
|
||||
});
|
||||
|
||||
expect(resolver.ramp[1]).toBe('rgb(229, 72, 77)');
|
||||
});
|
||||
|
||||
it('reports the domain it applied', () => {
|
||||
expect(build().domain).toStrictEqual({ min: 0, max: 1000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
import {
|
||||
canUseLogAxis,
|
||||
decimateAxisSplits,
|
||||
formatRowLabel,
|
||||
resolveColumnIndex,
|
||||
resolveHeatmapYAxis,
|
||||
resolveRowIndex,
|
||||
} from '../geometry';
|
||||
import { HeatmapAxisScale } from '../types';
|
||||
|
||||
const BOUNDS = [128, 256, 1024, 4096];
|
||||
|
||||
describe('canUseLogAxis', () => {
|
||||
it('accepts strictly positive bounds', () => {
|
||||
expect(canUseLogAxis(BOUNDS)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a zero or negative bound', () => {
|
||||
expect(canUseLogAxis([0, 128])).toBe(false);
|
||||
expect(canUseLogAxis([-1, 128])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty bounds', () => {
|
||||
expect(canUseLogAxis([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeatmapYAxis', () => {
|
||||
it('turns N bounds into N+1 rows with underflow and overflow at the ends', () => {
|
||||
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
expect(rows).toHaveLength(BOUNDS.length + 1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
upper: 128,
|
||||
isUnderflow: true,
|
||||
isOverflow: false,
|
||||
});
|
||||
expect(rows[1]).toMatchObject({ lower: 128, upper: 256 });
|
||||
expect(rows[4]).toMatchObject({
|
||||
lower: 4096,
|
||||
isOverflow: true,
|
||||
isUnderflow: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes one edge per row boundary, ascending', () => {
|
||||
const { rows, edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
expect(edges).toHaveLength(rows.length + 1);
|
||||
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
|
||||
});
|
||||
|
||||
it('places bounds in log space so row heights are log-proportional', () => {
|
||||
const { splits, min, max } = resolveHeatmapYAxis(
|
||||
BOUNDS,
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
|
||||
// Outer edges extend by the geometric mean ratio, (4096/128)^(1/3) = 3.174…
|
||||
expect(10 ** min).toBeCloseTo(128 / (4096 / 128) ** (1 / 3), 6);
|
||||
expect(10 ** max).toBeCloseTo(4096 * (4096 / 128) ** (1 / 3), 6);
|
||||
});
|
||||
|
||||
it('keeps bounds in value space on a linear axis', () => {
|
||||
const { splits, min } = resolveHeatmapYAxis(
|
||||
[10, 20, 30],
|
||||
HeatmapAxisScale.Linear,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual([10, 20, 30]);
|
||||
// Mean gap is 10, and the underflow edge never crosses zero.
|
||||
expect(min).toBe(0);
|
||||
});
|
||||
|
||||
it('sorts and de-duplicates bounds', () => {
|
||||
const { rows, splits } = resolveHeatmapYAxis(
|
||||
[256, 128, 256, Number.NaN],
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual([Math.log10(128), Math.log10(256)]);
|
||||
expect(rows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('gives a single bound an underflow and an overflow row', () => {
|
||||
const { rows, edges } = resolveHeatmapYAxis([100], HeatmapAxisScale.Log);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].isUnderflow).toBe(true);
|
||||
expect(rows[1].isOverflow).toBe(true);
|
||||
expect(edges).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('degrades to an empty axis with no bounds', () => {
|
||||
expect(resolveHeatmapYAxis([], HeatmapAxisScale.Log).rows).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('puts the overflow label on the row"s upper edge, clear of the last boundary', () => {
|
||||
const { overflowSplit, edges } = resolveHeatmapYAxis(
|
||||
BOUNDS,
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
// A full row above the last boundary tick, so the two labels cannot collide.
|
||||
expect(overflowSplit).toBe(edges[edges.length - 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRowIndex', () => {
|
||||
const { edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
|
||||
|
||||
it('finds the row containing a value', () => {
|
||||
expect(resolveRowIndex(edges, 200)).toBe(1);
|
||||
expect(resolveRowIndex(edges, 2000)).toBe(3);
|
||||
});
|
||||
|
||||
it('assigns a boundary to the row it opens', () => {
|
||||
expect(resolveRowIndex(edges, 256)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns the last row on the top edge', () => {
|
||||
expect(resolveRowIndex(edges, edges[edges.length - 1])).toBe(
|
||||
edges.length - 2,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null outside the grid', () => {
|
||||
expect(resolveRowIndex(edges, edges[0] - 1)).toBeNull();
|
||||
expect(resolveRowIndex(edges, edges[edges.length - 1] + 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null without at least one row', () => {
|
||||
expect(resolveRowIndex([5], 5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveColumnIndex', () => {
|
||||
const timestamps = [100, 160, 220, 280];
|
||||
const step = 60;
|
||||
|
||||
it('resolves by containment, not proximity', () => {
|
||||
// 155 is nearer to 160, but the observations at 155 belong to column 0.
|
||||
expect(resolveColumnIndex(timestamps, 155, step)).toBe(0);
|
||||
expect(resolveColumnIndex(timestamps, 160, step)).toBe(1);
|
||||
});
|
||||
|
||||
it('includes the column start and excludes its end', () => {
|
||||
expect(resolveColumnIndex(timestamps, 100, step)).toBe(0);
|
||||
expect(resolveColumnIndex(timestamps, 159.9, step)).toBe(0);
|
||||
});
|
||||
|
||||
it('covers the trailing column using the step, not the next timestamp', () => {
|
||||
expect(resolveColumnIndex(timestamps, 330, step)).toBe(3);
|
||||
expect(resolveColumnIndex(timestamps, 340, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null before the first column', () => {
|
||||
expect(resolveColumnIndex(timestamps, 99, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null with no columns', () => {
|
||||
expect(resolveColumnIndex([], 100, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the last column open when the step is unknown', () => {
|
||||
expect(resolveColumnIndex(timestamps, 10_000, 0)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRowLabel', () => {
|
||||
const format = (value: number): string => `${value}ms`;
|
||||
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
it('labels the underflow row by its only real bound', () => {
|
||||
expect(formatRowLabel(rows[0], format)).toBe('≤ 128ms');
|
||||
});
|
||||
|
||||
it('labels the overflow row by its only real bound', () => {
|
||||
expect(formatRowLabel(rows[rows.length - 1], format)).toBe('> 4096ms');
|
||||
});
|
||||
|
||||
it('labels an interior row as a range', () => {
|
||||
expect(formatRowLabel(rows[1], format)).toBe('128ms – 256ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decimateAxisSplits', () => {
|
||||
const splits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const domain = { min: 0, max: 10 };
|
||||
|
||||
it('keeps every tick when they all fit', () => {
|
||||
expect(
|
||||
decimateAxisSplits({ ...domain, splits, plotHeight: 400, minGapPx: 18 }),
|
||||
).toStrictEqual(splits);
|
||||
});
|
||||
|
||||
it('thins to whatever fits at the available height', () => {
|
||||
// 11 ticks over 100px is 10px apart; an 18px floor keeps every other one.
|
||||
expect(
|
||||
decimateAxisSplits({ ...domain, splits, plotHeight: 100, minGapPx: 18 }),
|
||||
).toStrictEqual([0, 2, 4, 6, 8, 10]);
|
||||
});
|
||||
|
||||
it('always keeps the topmost tick, so the overflow edge survives thinning', () => {
|
||||
const thinned = decimateAxisSplits({
|
||||
...domain,
|
||||
splits,
|
||||
plotHeight: 40,
|
||||
minGapPx: 18,
|
||||
});
|
||||
|
||||
expect(thinned[thinned.length - 1]).toBe(10);
|
||||
});
|
||||
|
||||
it('returns ascending positions', () => {
|
||||
const thinned = decimateAxisSplits({
|
||||
...domain,
|
||||
splits,
|
||||
plotHeight: 60,
|
||||
minGapPx: 18,
|
||||
});
|
||||
|
||||
expect([...thinned].sort((a, b) => a - b)).toStrictEqual(thinned);
|
||||
});
|
||||
|
||||
it('thins by pixel distance, not index, so uneven rows are handled', () => {
|
||||
// Three boundaries bunched at the bottom of a wide linear domain: only the
|
||||
// first and the far-away last are far enough apart to both get labels.
|
||||
expect(
|
||||
decimateAxisSplits({
|
||||
splits: [1, 2, 3, 1000],
|
||||
min: 0,
|
||||
max: 1000,
|
||||
plotHeight: 200,
|
||||
minGapPx: 18,
|
||||
}),
|
||||
).toStrictEqual([3, 1000]);
|
||||
});
|
||||
|
||||
it('leaves the tick set alone when it cannot measure', () => {
|
||||
expect(
|
||||
decimateAxisSplits({ ...domain, splits, plotHeight: 0, minGapPx: 18 }),
|
||||
).toStrictEqual(splits);
|
||||
expect(
|
||||
decimateAxisSplits({
|
||||
splits,
|
||||
min: 5,
|
||||
max: 5,
|
||||
plotHeight: 400,
|
||||
minGapPx: 18,
|
||||
}),
|
||||
).toStrictEqual(splits);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeatmapYAxis — symmetric log', () => {
|
||||
// The OTel SDK default explicit bucket boundaries, which start at zero.
|
||||
const OTEL = [
|
||||
0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000,
|
||||
];
|
||||
// Clock skew in ms — a logs/traces field that straddles zero.
|
||||
const SKEW = [-1000, -100, -10, -1, 0, 1, 10, 100, 1000];
|
||||
|
||||
const PLOT_HEIGHT = 250;
|
||||
|
||||
/** Row heights in axis units, which map linearly to pixels. */
|
||||
function rowHeights(bounds: number[]): number[] {
|
||||
const { edges } = resolveHeatmapYAxis(bounds, HeatmapAxisScale.Log);
|
||||
return edges.slice(1).map((edge, index) => edge - edges[index]);
|
||||
}
|
||||
|
||||
/** Shortest row, in pixels, for a plot of `PLOT_HEIGHT`. */
|
||||
function shortestRowPx(bounds: number[], scale: HeatmapAxisScale): number {
|
||||
const { edges } = resolveHeatmapYAxis(bounds, scale);
|
||||
const span = edges[edges.length - 1] - edges[0];
|
||||
const heights = edges
|
||||
.slice(1)
|
||||
.map((edge, index) => ((edge - edges[index]) / span) * PLOT_HEIGHT);
|
||||
return Math.min(...heights);
|
||||
}
|
||||
|
||||
it('keeps a zero boundary on a log axis instead of giving up to linear', () => {
|
||||
const { splits } = resolveHeatmapYAxis([0, 5, 10], HeatmapAxisScale.Log);
|
||||
|
||||
// A linear fallback would leave the boundaries untransformed.
|
||||
expect(splits).not.toStrictEqual([0, 5, 10]);
|
||||
});
|
||||
|
||||
it('gives every row a usable height for the OTel default boundaries', () => {
|
||||
// Linear squeezes the 0–100ms buckets — where the data is — under a pixel.
|
||||
expect(shortestRowPx(OTEL, HeatmapAxisScale.Linear)).toBeLessThan(1);
|
||||
expect(shortestRowPx(OTEL, HeatmapAxisScale.Log)).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('gives the zero-crossing row a full decade, since it cannot be compressed', () => {
|
||||
const heights = rowHeights(OTEL);
|
||||
const { rows } = resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Log);
|
||||
const nearZero = rows.findIndex((row) => row.lower === 0 && row.upper === 5);
|
||||
|
||||
// One axis unit — the same space a decade gets above the threshold.
|
||||
expect(heights[nearZero]).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('places boundaries either side of zero symmetrically', () => {
|
||||
const heights = rowHeights(SKEW);
|
||||
|
||||
expect(Math.max(...heights) - Math.min(...heights)).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('keeps negative boundaries ascending', () => {
|
||||
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Log);
|
||||
|
||||
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
|
||||
});
|
||||
|
||||
it('round-trips a boundary back to its bucket value', () => {
|
||||
const { splits, toBucketValue } = resolveHeatmapYAxis(
|
||||
SKEW,
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(
|
||||
splits.map((split) => Math.round(toBucketValue(split) * 1e6) / 1e6),
|
||||
).toStrictEqual(SKEW);
|
||||
});
|
||||
|
||||
it('derives the linear threshold from the smallest non-zero boundary', () => {
|
||||
// Threshold 10 puts -10 at -1 and 0 at 0 in axis space.
|
||||
const { edges, rows } = resolveHeatmapYAxis(
|
||||
[-100, -10, 0, 10, 100],
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
const crossing = rows.findIndex(
|
||||
(row) => row.lower === -10 && row.upper === 0,
|
||||
);
|
||||
|
||||
expect(edges[crossing]).toBeCloseTo(-1, 6);
|
||||
expect(edges[crossing + 1]).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('leaves an all-positive layout on a plain log axis', () => {
|
||||
const { splits } = resolveHeatmapYAxis(
|
||||
[128, 256, 1024],
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual([128, 256, 1024].map((b) => Math.log10(b)));
|
||||
});
|
||||
|
||||
it('falls back to linear when every boundary is zero', () => {
|
||||
const { splits } = resolveHeatmapYAxis([0], HeatmapAxisScale.Log);
|
||||
|
||||
expect(splits).toStrictEqual([0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { resolveHeatmapGrid } from '../grid';
|
||||
import { HeatmapSeries } from '../types';
|
||||
|
||||
const BUCKETS = [10, 20];
|
||||
const STEP = 60;
|
||||
|
||||
/** Two groups over two columns, each missing a value the other reports. */
|
||||
const TWO_GROUPS: HeatmapSeries[] = [
|
||||
{
|
||||
label: 'cart',
|
||||
points: [
|
||||
{ timestamp: 60, counts: [1, 2, 3] },
|
||||
{ timestamp: 120, counts: [null, 5, 6] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'checkout',
|
||||
points: [
|
||||
{ timestamp: 60, counts: [10, 20, 30] },
|
||||
{ timestamp: 120, counts: [40, null, 60] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function resolve(
|
||||
overrides: Partial<Parameters<typeof resolveHeatmapGrid>[0]> = {},
|
||||
): ReturnType<typeof resolveHeatmapGrid> {
|
||||
return resolveHeatmapGrid({
|
||||
buckets: BUCKETS,
|
||||
step: STEP,
|
||||
series: TWO_GROUPS,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('resolveHeatmapGrid', () => {
|
||||
it('pivots per-timestamp count arrays into one row per bucket', () => {
|
||||
const { counts } = resolve({ series: [TWO_GROUPS[0]] });
|
||||
|
||||
// 2 boundaries describe 3 rows; each row spans both columns.
|
||||
expect(counts).toStrictEqual([
|
||||
[1, null],
|
||||
[2, 5],
|
||||
[3, 6],
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries the bounds and step through untouched', () => {
|
||||
const { bounds, step } = resolve();
|
||||
|
||||
expect(bounds).toStrictEqual(BUCKETS);
|
||||
expect(step).toBe(STEP);
|
||||
});
|
||||
|
||||
it('sums every group for the combined view', () => {
|
||||
const { counts } = resolve();
|
||||
|
||||
expect(counts[0]).toStrictEqual([11, 40]);
|
||||
expect(counts[2]).toStrictEqual([33, 66]);
|
||||
});
|
||||
|
||||
it('keeps one group"s count where the other has no data', () => {
|
||||
const { counts } = resolve();
|
||||
|
||||
// cart is null at 120 in row 0 while checkout reports 40.
|
||||
expect(counts[0][1]).toBe(40);
|
||||
// checkout is null at 120 in row 1 while cart reports 5.
|
||||
expect(counts[1][1]).toBe(5);
|
||||
});
|
||||
|
||||
it('reports a cell as no-data only when every group is missing it', () => {
|
||||
const { counts } = resolve({
|
||||
buckets: [10],
|
||||
series: [
|
||||
{ label: 'a', points: [{ timestamp: 60, counts: [null, null] }] },
|
||||
{ label: 'b', points: [{ timestamp: 60, counts: [null, null] }] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(counts).toStrictEqual([[null], [null]]);
|
||||
});
|
||||
|
||||
it('distinguishes a zero count from no data', () => {
|
||||
const { counts } = resolve({
|
||||
buckets: [10],
|
||||
series: [{ label: 'a', points: [{ timestamp: 60, counts: [0, null] }] }],
|
||||
});
|
||||
|
||||
expect(counts[0][0]).toBe(0);
|
||||
expect(counts[1][0]).toBeNull();
|
||||
});
|
||||
|
||||
it('sums only the groups the legend has enabled', () => {
|
||||
const { counts } = resolve({ visibleGroups: ['cart'] });
|
||||
|
||||
expect(counts[0]).toStrictEqual([1, null]);
|
||||
expect(counts[2]).toStrictEqual([3, 6]);
|
||||
});
|
||||
|
||||
it('sums every group when the legend passes nothing', () => {
|
||||
const { counts } = resolve({ visibleGroups: undefined });
|
||||
|
||||
expect(counts[0]).toStrictEqual([11, 40]);
|
||||
});
|
||||
|
||||
it('ignores an enabled label that left the result', () => {
|
||||
const { counts } = resolve({ visibleGroups: ['cart', 'gone'] });
|
||||
|
||||
expect(counts[0]).toStrictEqual([1, null]);
|
||||
});
|
||||
|
||||
it('empties the grid when every group is excluded', () => {
|
||||
const { timestamps, counts } = resolve({ visibleGroups: [] });
|
||||
|
||||
expect(timestamps).toStrictEqual([]);
|
||||
expect(counts.every((row) => row.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('unions timestamps when groups do not align', () => {
|
||||
const { timestamps, counts } = resolve({
|
||||
buckets: [10],
|
||||
series: [
|
||||
{ label: 'a', points: [{ timestamp: 60, counts: [1, 2] }] },
|
||||
{ label: 'b', points: [{ timestamp: 180, counts: [3, 4] }] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(timestamps).toStrictEqual([60, 180]);
|
||||
expect(counts[0]).toStrictEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('sorts columns ascending regardless of response order', () => {
|
||||
const { timestamps } = resolve({
|
||||
buckets: [10],
|
||||
series: [
|
||||
{
|
||||
label: 'a',
|
||||
points: [
|
||||
{ timestamp: 180, counts: [1, 2] },
|
||||
{ timestamp: 60, counts: [3, 4] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(timestamps).toStrictEqual([60, 180]);
|
||||
});
|
||||
|
||||
it('pads rows the response left short', () => {
|
||||
const { counts } = resolve({
|
||||
buckets: [10, 20, 30],
|
||||
series: [{ label: 'a', points: [{ timestamp: 60, counts: [1, 2] }] }],
|
||||
});
|
||||
|
||||
expect(counts).toStrictEqual([[1], [2], [null], [null]]);
|
||||
});
|
||||
|
||||
it('ignores counts beyond the bucket rows', () => {
|
||||
const { counts } = resolve({
|
||||
buckets: [10],
|
||||
series: [{ label: 'a', points: [{ timestamp: 60, counts: [1, 2, 99] }] }],
|
||||
});
|
||||
|
||||
expect(counts).toStrictEqual([[1], [2]]);
|
||||
});
|
||||
|
||||
it('degrades to an empty grid with no buckets or no series', () => {
|
||||
expect(resolve({ buckets: [] })).toStrictEqual({
|
||||
bounds: [],
|
||||
timestamps: [],
|
||||
step: 0,
|
||||
counts: [],
|
||||
});
|
||||
expect(resolve({ series: [] }).counts).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { DEFAULT_HEATMAP_COLORS } from '../colorScale';
|
||||
import { resolveHeatmapYAxis } from '../geometry';
|
||||
import { createHeatmapHooks } from '../heatmapPlugin';
|
||||
import { HeatmapAxisScale, HeatmapCell } from '../types';
|
||||
|
||||
const BOUNDS = [100, 1000];
|
||||
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
|
||||
const TIMESTAMPS = [1000, 1060, 1120];
|
||||
const STEP = 60;
|
||||
const PLOT_WIDTH = 300;
|
||||
const PLOT_HEIGHT = 300;
|
||||
|
||||
// Three rows for two bounds, three columns; row 1 column 1 is a data gap.
|
||||
const DATA = [
|
||||
TIMESTAMPS,
|
||||
[1, 2, 3],
|
||||
[4, null, 6],
|
||||
[7, 8, 9],
|
||||
] as unknown as uPlot.AlignedData;
|
||||
|
||||
interface FakeContext {
|
||||
fillRect: jest.Mock;
|
||||
fills: string[];
|
||||
}
|
||||
|
||||
interface FakePlot {
|
||||
plot: uPlot;
|
||||
context: FakeContext;
|
||||
setSeries: jest.Mock;
|
||||
over: HTMLDivElement;
|
||||
}
|
||||
|
||||
function createFakePlot(cursor: { left: number; top: number }): FakePlot {
|
||||
const over = document.createElement('div');
|
||||
Object.defineProperty(over, 'clientWidth', { value: PLOT_WIDTH });
|
||||
Object.defineProperty(over, 'clientHeight', { value: PLOT_HEIGHT });
|
||||
|
||||
const fills: string[] = [];
|
||||
const fillRect = jest.fn();
|
||||
const context = { fills, fillRect };
|
||||
const setSeries = jest.fn();
|
||||
|
||||
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
|
||||
const ySpan = Y_AXIS.max - Y_AXIS.min;
|
||||
|
||||
const ctx = {
|
||||
save: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
beginPath: jest.fn(),
|
||||
rect: jest.fn(),
|
||||
clip: jest.fn(),
|
||||
moveTo: jest.fn(),
|
||||
lineTo: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
setLineDash: jest.fn(),
|
||||
createPattern: jest.fn(() => null),
|
||||
set fillStyle(value: string) {
|
||||
fills.push(value);
|
||||
},
|
||||
fillRect: (...args: number[]): void => {
|
||||
fillRect(...args);
|
||||
},
|
||||
};
|
||||
|
||||
const plot = {
|
||||
data: DATA,
|
||||
cursor,
|
||||
over,
|
||||
setSeries,
|
||||
ctx,
|
||||
bbox: { left: 0, top: 0, width: PLOT_WIDTH, height: PLOT_HEIGHT },
|
||||
scales: { x: { min: TIMESTAMPS[0], max: TIMESTAMPS[2] + STEP } },
|
||||
// x grows left to right; y is inverted, so the highest bucket is at the top.
|
||||
valToPos: (value: number, scaleKey: string): number =>
|
||||
scaleKey === 'x'
|
||||
? ((value - TIMESTAMPS[0]) / xSpan) * PLOT_WIDTH
|
||||
: PLOT_HEIGHT - ((value - Y_AXIS.min) / ySpan) * PLOT_HEIGHT,
|
||||
posToVal: (pos: number, scaleKey: string): number =>
|
||||
scaleKey === 'x'
|
||||
? TIMESTAMPS[0] + (pos / PLOT_WIDTH) * xSpan
|
||||
: Y_AXIS.min + ((PLOT_HEIGHT - pos) / PLOT_HEIGHT) * ySpan,
|
||||
};
|
||||
|
||||
return { plot: plot as unknown as uPlot, context, setSeries, over };
|
||||
}
|
||||
|
||||
function createHooks(
|
||||
onHoverChange?: (cell: HeatmapCell | null) => void,
|
||||
dimOnHover = true,
|
||||
): ReturnType<typeof createHeatmapHooks> {
|
||||
return createHeatmapHooks({
|
||||
yAxis: Y_AXIS,
|
||||
step: STEP,
|
||||
colors: DEFAULT_HEATMAP_COLORS,
|
||||
isDarkMode: true,
|
||||
seriesColor: '#4e74f8',
|
||||
dimOnHover,
|
||||
onHoverChange,
|
||||
});
|
||||
}
|
||||
|
||||
describe('heatmap renderer — lifecycle', () => {
|
||||
it('mounts the hover overlay into the plot overlay and tears it down', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, over } = createFakePlot({ left: -10, top: -10 });
|
||||
|
||||
hooks.init(plot);
|
||||
expect(
|
||||
over.querySelector('[data-testid="heatmap-hover-overlay"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
hooks.destroy(plot);
|
||||
expect(
|
||||
over.querySelector('[data-testid="heatmap-hover-overlay"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('heatmap renderer — draw', () => {
|
||||
it('paints every cell of every visible column', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, context } = createFakePlot({ left: -10, top: -10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.draw(plot);
|
||||
|
||||
// 3 rows x 3 columns, less the one null cell that has no hatch pattern
|
||||
// available under jsdom.
|
||||
expect(context.fillRect).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it('gives a zero count the bottom-of-scale fill rather than skipping it', () => {
|
||||
const hooks = createHooks();
|
||||
const zeroed = [TIMESTAMPS, [0, 0, 0], [0, 0, 0], [0, 0, 0]];
|
||||
const { plot, context } = createFakePlot({ left: -10, top: -10 });
|
||||
(plot as { data: unknown }).data = zeroed;
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.draw(plot);
|
||||
|
||||
expect(context.fillRect).toHaveBeenCalledTimes(9);
|
||||
expect(new Set(context.fills).size).toBe(1);
|
||||
});
|
||||
|
||||
it('skips columns outside the current x range', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, context } = createFakePlot({ left: -10, top: -10 });
|
||||
(plot as { scales: unknown }).scales = {
|
||||
x: { min: TIMESTAMPS[0], max: TIMESTAMPS[0] + STEP },
|
||||
};
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.draw(plot);
|
||||
|
||||
// Only the first two columns overlap the range; the third starts past its end.
|
||||
// 2 columns x 3 rows, less the null cell in column 1.
|
||||
expect(context.fillRect).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('draws nothing without columns', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, context } = createFakePlot({ left: -10, top: -10 });
|
||||
(plot as { data: unknown }).data = [[]];
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.draw(plot);
|
||||
|
||||
expect(context.fillRect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('heatmap renderer — hover', () => {
|
||||
it('focuses the hovered row and reports the cell under the cursor', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
// Left third of the plot is column 0; the top third is the overflow row.
|
||||
const { plot, setSeries } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenCalledWith({ row: 2, column: 0, count: 7 });
|
||||
expect(setSeries).toHaveBeenCalledWith(3, { focus: true });
|
||||
});
|
||||
|
||||
it('reports a data gap as a null count instead of zero', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot } = createFakePlot({
|
||||
left: PLOT_WIDTH / 2,
|
||||
top: PLOT_HEIGHT / 2,
|
||||
});
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenCalledWith({
|
||||
row: 1,
|
||||
column: 1,
|
||||
count: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-report the same cell', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the overlay over the hovered cell and dims around it', () => {
|
||||
const hooks = createHooks(undefined, true);
|
||||
const { plot, over } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
const overlay = over.querySelector<HTMLDivElement>(
|
||||
'[data-testid="heatmap-hover-overlay"]',
|
||||
);
|
||||
expect(overlay?.style.display).toBe('block');
|
||||
// Column 0 spans the left third of a 300px plot.
|
||||
expect(overlay?.lastElementChild).toHaveStyle({
|
||||
left: '0px',
|
||||
width: '100px',
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses the dim rects when dimming is off', () => {
|
||||
const hooks = createHooks(undefined, false);
|
||||
const { plot, over } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
const overlay = over.querySelector<HTMLDivElement>(
|
||||
'[data-testid="heatmap-hover-overlay"]',
|
||||
);
|
||||
expect(overlay?.firstElementChild).toHaveStyle({
|
||||
width: '0px',
|
||||
height: '0px',
|
||||
});
|
||||
});
|
||||
|
||||
it('releases focus and hides the overlay when the cursor leaves', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot, over, setSeries } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
(plot as { cursor: { left: number; top: number } }).cursor = {
|
||||
left: -10,
|
||||
top: -10,
|
||||
};
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenLastCalledWith(null);
|
||||
expect(setSeries).toHaveBeenLastCalledWith(null, { focus: true });
|
||||
expect(
|
||||
over.querySelector<HTMLDivElement>('[data-testid="heatmap-hover-overlay"]')
|
||||
?.style.display,
|
||||
).toBe('none');
|
||||
});
|
||||
|
||||
it('clears the hover when the cursor is inside the plot but past the last column', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
(plot as { data: unknown }).data = [[], [], [], []];
|
||||
(plot as { cursor: { left: number; top: number } }).cursor = {
|
||||
left: 10,
|
||||
top: 10,
|
||||
};
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { getPaletteStops } from '../palettes';
|
||||
import { HeatmapColorPalette } from '../types';
|
||||
|
||||
const ALL_PALETTES = Object.values(HeatmapColorPalette);
|
||||
|
||||
/** Perceived brightness, good enough to tell a ramp's ends apart. */
|
||||
function luminance(hex: string): number {
|
||||
const value = parseInt(hex.slice(1), 16);
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const [r, g, b] = [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
describe('getPaletteStops', () => {
|
||||
it.each(ALL_PALETTES)('%s is a full ramp of valid colours', (palette) => {
|
||||
const stops = getPaletteStops(palette, true);
|
||||
|
||||
expect(stops).toHaveLength(9);
|
||||
stops.forEach((stop) => expect(stop).toMatch(/^#[0-9a-f]{6}$/));
|
||||
});
|
||||
|
||||
it.each(ALL_PALETTES)(
|
||||
'%s climbs from dark to bright on a dark panel',
|
||||
(palette) => {
|
||||
const stops = getPaletteStops(palette, true);
|
||||
|
||||
// Low counts must sit near the surface, whichever direction the ramp is
|
||||
// stored in — otherwise empty cells become the loudest thing on screen.
|
||||
expect(luminance(stops[0])).toBeLessThan(luminance(stops[stops.length - 1]));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL_PALETTES)(
|
||||
'%s falls from pale to saturated on a light panel',
|
||||
(palette) => {
|
||||
const stops = getPaletteStops(palette, false);
|
||||
|
||||
expect(luminance(stops[0])).toBeGreaterThan(
|
||||
luminance(stops[stops.length - 1]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL_PALETTES)('%s uses the same colours in both themes', (palette) => {
|
||||
// Only the polarity flips; the palette itself is theme-independent.
|
||||
expect([...getPaletteStops(palette, false)].reverse()).toStrictEqual(
|
||||
getPaletteStops(palette, true),
|
||||
);
|
||||
});
|
||||
|
||||
it('never mutates the stored ramp when reversing it', () => {
|
||||
const first = getPaletteStops(HeatmapColorPalette.Lava, false);
|
||||
const second = getPaletteStops(HeatmapColorPalette.Lava, false);
|
||||
|
||||
expect(first).toStrictEqual(second);
|
||||
});
|
||||
|
||||
it('falls back to the first ramp for an unknown palette', () => {
|
||||
const unknown = 'nope' as HeatmapColorPalette;
|
||||
|
||||
expect(getPaletteStops(unknown, true)).toStrictEqual(
|
||||
getPaletteStops(HeatmapColorPalette.Ice, true),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a neutral ramp for panels that already spend colour elsewhere', () => {
|
||||
const stops = getPaletteStops(HeatmapColorPalette.Graphite, true);
|
||||
|
||||
// Every stop is a grey: red, green and blue channels stay equal.
|
||||
stops.forEach((stop) => {
|
||||
expect(stop.slice(1, 3)).toBe(stop.slice(3, 5));
|
||||
expect(stop.slice(3, 5)).toBe(stop.slice(5, 7));
|
||||
});
|
||||
});
|
||||
});
|
||||
198
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/colorScale.ts
Normal file
198
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/colorScale.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { Color as DesignToken } from '@signozhq/design-tokens';
|
||||
import Color from 'color';
|
||||
|
||||
import { getPaletteStops } from './palettes';
|
||||
import {
|
||||
HeatmapColorMode,
|
||||
HeatmapColorOptions,
|
||||
HeatmapColorScale,
|
||||
HeatmapColorPalette,
|
||||
} from './types';
|
||||
|
||||
export const MIN_COLOR_STEPS = 2;
|
||||
export const MAX_COLOR_STEPS = 128;
|
||||
export const DEFAULT_COLOR_STEPS = 64;
|
||||
|
||||
/** Without a floor, the lowest counts read as "no data". */
|
||||
export const MIN_OPACITY_ALPHA = 0.1;
|
||||
|
||||
/** Used when neither an explicit fill nor a series colour is available. */
|
||||
export const DEFAULT_OPACITY_FILL = DesignToken.BG_ROBIN_500;
|
||||
|
||||
export const DEFAULT_HEATMAP_COLORS: HeatmapColorOptions = {
|
||||
mode: HeatmapColorMode.Palette,
|
||||
scale: HeatmapColorScale.Log,
|
||||
minCount: null,
|
||||
maxCount: null,
|
||||
palette: HeatmapColorPalette.Lava,
|
||||
steps: DEFAULT_COLOR_STEPS,
|
||||
fill: '',
|
||||
};
|
||||
|
||||
export interface CountDomain {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/** Highest count, ignoring `null`. 0 for an empty grid. */
|
||||
export function getMaxCount(counts: Array<Array<number | null>>): number {
|
||||
let max = 0;
|
||||
for (const row of counts) {
|
||||
for (const count of row) {
|
||||
if (count !== null && Number.isFinite(count) && count > max) {
|
||||
max = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/** Explicit clamps win; otherwise 0 to the grid's highest count. */
|
||||
export function resolveCountDomain(
|
||||
options: Pick<HeatmapColorOptions, 'minCount' | 'maxCount'>,
|
||||
counts: Array<Array<number | null>>,
|
||||
): CountDomain {
|
||||
const min = options.minCount ?? 0;
|
||||
const max = options.maxCount ?? getMaxCount(counts);
|
||||
return max > min ? { min, max } : { min, max: min };
|
||||
}
|
||||
|
||||
/** Position on the colour scale, 0..1. A degenerate domain collapses to 0 so an
|
||||
* all-zero grid renders at the bottom rather than disappearing. */
|
||||
export function normalizeCount({
|
||||
count,
|
||||
domain,
|
||||
scale,
|
||||
}: {
|
||||
count: number;
|
||||
domain: CountDomain;
|
||||
scale: HeatmapColorScale;
|
||||
}): number {
|
||||
const { min, max } = domain;
|
||||
if (!(max > min)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const clamped = Math.min(Math.max(count, min), max);
|
||||
|
||||
if (scale === HeatmapColorScale.Log) {
|
||||
// 0 and 1 both sit at the bottom; log of either is meaningless.
|
||||
const logMin = Math.log10(Math.max(min, 1));
|
||||
const logMax = Math.log10(Math.max(max, 1));
|
||||
if (!(logMax > logMin)) {
|
||||
return 0;
|
||||
}
|
||||
return (Math.log10(Math.max(clamped, 1)) - logMin) / (logMax - logMin);
|
||||
}
|
||||
|
||||
const linear = (clamped - min) / (max - min);
|
||||
return scale === HeatmapColorScale.Sqrt ? Math.sqrt(linear) : linear;
|
||||
}
|
||||
|
||||
export function clampColorSteps(steps: number): number {
|
||||
if (!Number.isFinite(steps)) {
|
||||
return DEFAULT_COLOR_STEPS;
|
||||
}
|
||||
return Math.min(Math.max(Math.round(steps), MIN_COLOR_STEPS), MAX_COLOR_STEPS);
|
||||
}
|
||||
|
||||
/** Colour at `t` (0..1) along a multi-stop ramp. */
|
||||
function sampleStops(stops: string[], t: number): string {
|
||||
if (stops.length === 0) {
|
||||
return 'transparent';
|
||||
}
|
||||
if (stops.length === 1) {
|
||||
return stops[0];
|
||||
}
|
||||
const scaled = Math.min(Math.max(t, 0), 1) * (stops.length - 1);
|
||||
const lower = Math.min(Math.floor(scaled), stops.length - 2);
|
||||
return Color(stops[lower])
|
||||
.mix(Color(stops[lower + 1]), scaled - lower)
|
||||
.hex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Colour the densest cells are drawn with — the palette's extreme, or the opacity
|
||||
* fill at full strength. Depends only on the options, not on the data, so callers
|
||||
* can read it before a grid exists.
|
||||
*/
|
||||
export function resolveExtremeColor({
|
||||
options,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
}: {
|
||||
options: HeatmapColorOptions;
|
||||
isDarkMode: boolean;
|
||||
seriesColor: string;
|
||||
}): string {
|
||||
if (options.mode === HeatmapColorMode.Opacity) {
|
||||
return options.fill || seriesColor || DEFAULT_OPACITY_FILL;
|
||||
}
|
||||
const stops = getPaletteStops(options.palette, isDarkMode);
|
||||
return stops[stops.length - 1] ?? DEFAULT_OPACITY_FILL;
|
||||
}
|
||||
|
||||
export interface HeatmapColorResolver {
|
||||
/** `null` for a `null` count, which must be hatched. */
|
||||
colorFor: (count: number | null) => string | null;
|
||||
/** 0..1, or `null` for a `null` count. */
|
||||
positionOf: (count: number | null) => number | null;
|
||||
/** Low to high. The colour bar renders exactly these. */
|
||||
ramp: string[];
|
||||
domain: CountDomain;
|
||||
}
|
||||
|
||||
/** Palette mode walks a sequential ramp; opacity mode varies the alpha of one
|
||||
* fill, so the grid matches its group's legend swatch. */
|
||||
export function createHeatmapColorResolver({
|
||||
options,
|
||||
domain,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
}: {
|
||||
options: HeatmapColorOptions;
|
||||
domain: CountDomain;
|
||||
isDarkMode: boolean;
|
||||
/** Opacity-mode fill when `options.fill` is empty. */
|
||||
seriesColor: string;
|
||||
}): HeatmapColorResolver {
|
||||
const steps = clampColorSteps(options.steps);
|
||||
const positions = Array.from({ length: steps }, (_, index) =>
|
||||
steps === 1 ? 0 : index / (steps - 1),
|
||||
);
|
||||
|
||||
let ramp: string[];
|
||||
if (options.mode === HeatmapColorMode.Opacity) {
|
||||
const base = Color(options.fill || seriesColor || DEFAULT_OPACITY_FILL);
|
||||
ramp = positions.map((t) =>
|
||||
base
|
||||
.alpha(MIN_OPACITY_ALPHA + t * (1 - MIN_OPACITY_ALPHA))
|
||||
.rgb()
|
||||
.string(),
|
||||
);
|
||||
} else {
|
||||
const stops = getPaletteStops(options.palette, isDarkMode);
|
||||
ramp = positions.map((t) => sampleStops(stops, t));
|
||||
}
|
||||
|
||||
const positionOf = (count: number | null): number | null => {
|
||||
if (count === null || !Number.isFinite(count)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeCount({ count, domain, scale: options.scale });
|
||||
};
|
||||
|
||||
return {
|
||||
positionOf,
|
||||
colorFor: (count): string | null => {
|
||||
const t = positionOf(count);
|
||||
if (t === null) {
|
||||
return null;
|
||||
}
|
||||
const index = Math.min(Math.floor(t * steps), steps - 1);
|
||||
return ramp[index];
|
||||
},
|
||||
ramp,
|
||||
domain,
|
||||
};
|
||||
}
|
||||
297
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/geometry.ts
Normal file
297
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/geometry.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { HeatmapAxisScale, HeatmapRow, HeatmapYAxis } from './types';
|
||||
|
||||
/** Used when the ratio cannot be inferred, i.e. a single boundary. */
|
||||
const FALLBACK_LOG_RATIO = 2;
|
||||
|
||||
const EMPTY_Y_AXIS: HeatmapYAxis = {
|
||||
rows: [],
|
||||
edges: [],
|
||||
splits: [],
|
||||
overflowSplit: null,
|
||||
toBucketValue: (axisValue: number): number => axisValue,
|
||||
min: 0,
|
||||
max: 1,
|
||||
};
|
||||
|
||||
/** Ascending, finite, de-duplicated boundaries. */
|
||||
function normalizeBounds(bounds: number[]): number[] {
|
||||
const sorted = bounds
|
||||
.filter((bound) => Number.isFinite(bound))
|
||||
.sort((a, b) => a - b);
|
||||
return sorted.filter(
|
||||
(bound, index) => index === 0 || bound !== sorted[index - 1],
|
||||
);
|
||||
}
|
||||
|
||||
/** True when a plain log axis can place every boundary. */
|
||||
export function canUseLogAxis(bounds: number[]): boolean {
|
||||
return bounds.length > 0 && bounds.every((bound) => bound > 0);
|
||||
}
|
||||
|
||||
interface AxisTransform {
|
||||
toAxisValue: (value: number) => number;
|
||||
toBucketValue: (axisValue: number) => number;
|
||||
}
|
||||
|
||||
const LINEAR_TRANSFORM: AxisTransform = {
|
||||
toAxisValue: (value) => value,
|
||||
toBucketValue: (axisValue) => axisValue,
|
||||
};
|
||||
|
||||
const LOG_TRANSFORM: AxisTransform = {
|
||||
toAxisValue: (value) => Math.log10(value),
|
||||
toBucketValue: (axisValue) => 10 ** axisValue,
|
||||
};
|
||||
|
||||
/**
|
||||
* Where "near zero" starts, taken as the smallest non-zero boundary magnitude. The
|
||||
* bucket layout already declares it, so it never needs to be configured.
|
||||
*/
|
||||
function resolveLinearThreshold(bounds: number[]): number {
|
||||
let threshold = Number.POSITIVE_INFINITY;
|
||||
for (const bound of bounds) {
|
||||
const magnitude = Math.abs(bound);
|
||||
if (magnitude > 0 && magnitude < threshold) {
|
||||
threshold = magnitude;
|
||||
}
|
||||
}
|
||||
return Number.isFinite(threshold) ? threshold : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symmetric log: linear within ±threshold, logarithmic beyond, mirrored across
|
||||
* zero. Bucketing an arbitrary logs/traces field can straddle zero — clock skew,
|
||||
* deltas, balances — which a plain log cannot place at all, and which a linear axis
|
||||
* squeezes into sub-pixel rows exactly where the interesting data sits.
|
||||
*
|
||||
* The gradient kink at ±threshold is invisible here: the threshold *is* a boundary,
|
||||
* so it lands on a row edge, and row edges are already discrete.
|
||||
*/
|
||||
function createSymlogTransform(threshold: number): AxisTransform {
|
||||
return {
|
||||
toAxisValue: (value) =>
|
||||
Math.abs(value) <= threshold
|
||||
? value / threshold
|
||||
: Math.sign(value) * (1 + Math.log10(Math.abs(value) / threshold)),
|
||||
toBucketValue: (axisValue) =>
|
||||
Math.abs(axisValue) <= 1
|
||||
? axisValue * threshold
|
||||
: Math.sign(axisValue) * threshold * 10 ** (Math.abs(axisValue) - 1),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAxisTransform(
|
||||
bounds: number[],
|
||||
scale: HeatmapAxisScale,
|
||||
): AxisTransform {
|
||||
if (scale !== HeatmapAxisScale.Log) {
|
||||
return LINEAR_TRANSFORM;
|
||||
}
|
||||
if (canUseLogAxis(bounds)) {
|
||||
return LOG_TRANSFORM;
|
||||
}
|
||||
// All-zero bounds have no magnitude to scale against.
|
||||
if (!bounds.some((bound) => bound !== 0)) {
|
||||
return LINEAR_TRANSFORM;
|
||||
}
|
||||
return createSymlogTransform(resolveLinearThreshold(bounds));
|
||||
}
|
||||
|
||||
/**
|
||||
* The open-ended rows still need a height, so each gets the grid's typical bucket
|
||||
* width — the mean gap in axis space, which on a geometric layout is exactly one
|
||||
* bucket ratio. Linear stays in value space so it can refuse to cross zero.
|
||||
*/
|
||||
function resolveOuterEdges(
|
||||
bounds: number[],
|
||||
transform: AxisTransform,
|
||||
isLinear: boolean,
|
||||
): { lower: number; upper: number } {
|
||||
const first = bounds[0];
|
||||
const last = bounds[bounds.length - 1];
|
||||
|
||||
if (isLinear) {
|
||||
const gap = bounds.length > 1 ? (last - first) / (bounds.length - 1) : 0;
|
||||
const safeGap = gap > 0 ? gap : Math.abs(first) || 1;
|
||||
// Never extend below zero unless the boundaries already do.
|
||||
const lower = first > 0 ? Math.max(0, first - safeGap) : first - safeGap;
|
||||
return { lower, upper: last + safeGap };
|
||||
}
|
||||
|
||||
const axisFirst = transform.toAxisValue(first);
|
||||
const axisLast = transform.toAxisValue(last);
|
||||
const fallback = Math.log10(FALLBACK_LOG_RATIO);
|
||||
const gap =
|
||||
bounds.length > 1 ? (axisLast - axisFirst) / (bounds.length - 1) : fallback;
|
||||
const safeGap = gap > 0 ? gap : fallback;
|
||||
|
||||
return {
|
||||
lower: transform.toBucketValue(axisFirst - safeGap),
|
||||
upper: transform.toBucketValue(axisLast + safeGap),
|
||||
};
|
||||
}
|
||||
|
||||
/** N boundaries produce N+1 rows: an underflow row below the first, and the
|
||||
* `+Inf` overflow row above the last. */
|
||||
export function resolveHeatmapYAxis(
|
||||
bounds: number[],
|
||||
scale: HeatmapAxisScale,
|
||||
): HeatmapYAxis {
|
||||
const normalized = normalizeBounds(bounds);
|
||||
if (normalized.length === 0) {
|
||||
return EMPTY_Y_AXIS;
|
||||
}
|
||||
|
||||
const transform = resolveAxisTransform(normalized, scale);
|
||||
const isLinear = transform === LINEAR_TRANSFORM;
|
||||
const { toAxisValue, toBucketValue } = transform;
|
||||
|
||||
const { lower, upper } = resolveOuterEdges(normalized, transform, isLinear);
|
||||
const last = normalized[normalized.length - 1];
|
||||
|
||||
const rows: HeatmapRow[] = [
|
||||
{ lower, upper: normalized[0], isUnderflow: true, isOverflow: false },
|
||||
];
|
||||
for (let index = 1; index < normalized.length; index += 1) {
|
||||
rows.push({
|
||||
lower: normalized[index - 1],
|
||||
upper: normalized[index],
|
||||
isUnderflow: false,
|
||||
isOverflow: false,
|
||||
});
|
||||
}
|
||||
rows.push({ lower: last, upper, isUnderflow: false, isOverflow: true });
|
||||
|
||||
const edges = [
|
||||
toAxisValue(lower),
|
||||
...normalized.map(toAxisValue),
|
||||
toAxisValue(upper),
|
||||
];
|
||||
|
||||
return {
|
||||
rows,
|
||||
edges,
|
||||
splits: normalized.map(toAxisValue),
|
||||
overflowSplit: toAxisValue(upper),
|
||||
toBucketValue,
|
||||
min: edges[0],
|
||||
max: edges[edges.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
/** Row containing `axisValue`, or `null` when it falls outside the grid. */
|
||||
export function resolveRowIndex(
|
||||
edges: number[],
|
||||
axisValue: number,
|
||||
): number | null {
|
||||
if (edges.length < 2) {
|
||||
return null;
|
||||
}
|
||||
if (axisValue < edges[0] || axisValue > edges[edges.length - 1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let low = 0;
|
||||
let high = edges.length - 2;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (axisValue < edges[mid]) {
|
||||
high = mid - 1;
|
||||
} else if (axisValue >= edges[mid + 1]) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
return mid;
|
||||
}
|
||||
}
|
||||
// Exactly on the top edge.
|
||||
return edges.length - 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* A containment test, not a nearest-timestamp lookup: uPlot's own `cursor.idx`
|
||||
* snaps to the closest boundary and would report the next column as soon as the
|
||||
* cursor passed a cell's midpoint.
|
||||
*/
|
||||
export function resolveColumnIndex(
|
||||
timestamps: ArrayLike<number>,
|
||||
xValue: number,
|
||||
step: number,
|
||||
): number | null {
|
||||
if (timestamps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let low = 0;
|
||||
let high = timestamps.length - 1;
|
||||
let candidate = -1;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (timestamps[mid] <= xValue) {
|
||||
candidate = mid;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate < 0) {
|
||||
return null;
|
||||
}
|
||||
const width = step > 0 ? step : Number.POSITIVE_INFINITY;
|
||||
return xValue < timestamps[candidate] + width ? candidate : null;
|
||||
}
|
||||
|
||||
/** The open-ended rows are labelled by their one real boundary; the synthetic
|
||||
* edge is a drawing device, not a value. */
|
||||
export function formatRowLabel(
|
||||
row: HeatmapRow,
|
||||
formatValue: (value: number) => string,
|
||||
): string {
|
||||
if (row.isOverflow) {
|
||||
return `> ${formatValue(row.lower)}`;
|
||||
}
|
||||
if (row.isUnderflow) {
|
||||
return `≤ ${formatValue(row.upper)}`;
|
||||
}
|
||||
return `${formatValue(row.lower)} – ${formatValue(row.upper)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops boundary ticks that would overlap. Filters by pixel distance rather than
|
||||
* index, since linear rows are not the same height, and walks down from the top
|
||||
* so the `∞` edge survives whatever else is dropped.
|
||||
*/
|
||||
export function decimateAxisSplits({
|
||||
splits,
|
||||
min,
|
||||
max,
|
||||
plotHeight,
|
||||
minGapPx,
|
||||
}: {
|
||||
/** Candidates in axis space, ascending. */
|
||||
splits: number[];
|
||||
min: number;
|
||||
max: number;
|
||||
/** Plotting area height, in CSS pixels. */
|
||||
plotHeight: number;
|
||||
minGapPx: number;
|
||||
}): number[] {
|
||||
if (splits.length < 2 || plotHeight <= 0 || minGapPx <= 0 || !(max > min)) {
|
||||
return splits;
|
||||
}
|
||||
|
||||
const pixelsPerUnit = plotHeight / (max - min);
|
||||
const kept: number[] = [];
|
||||
let lastPosition = 0;
|
||||
|
||||
for (let index = splits.length - 1; index >= 0; index -= 1) {
|
||||
// Axis values grow upward, pixel offsets downward.
|
||||
const position = (max - splits[index]) * pixelsPerUnit;
|
||||
if (kept.length === 0 || position - lastPosition >= minGapPx) {
|
||||
kept.push(splits[index]);
|
||||
lastPosition = position;
|
||||
}
|
||||
}
|
||||
|
||||
return kept.reverse();
|
||||
}
|
||||
99
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/grid.ts
Normal file
99
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/grid.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { HeatmapGrid, HeatmapSeries } from './types';
|
||||
|
||||
const EMPTY_GRID: HeatmapGrid = {
|
||||
bounds: [],
|
||||
timestamps: [],
|
||||
step: 0,
|
||||
counts: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Highest single-cell count each group reaches. Read against the same domain the
|
||||
* colour bar uses, this is where a group sits on that bar.
|
||||
*/
|
||||
export function resolveGroupPeaks(
|
||||
series: HeatmapSeries[],
|
||||
): Map<string, number> {
|
||||
const peaks = new Map<string, number>();
|
||||
series.forEach((entry) => {
|
||||
let peak = 0;
|
||||
entry.points.forEach((point) =>
|
||||
point.counts.forEach((count) => {
|
||||
if (count !== null && count > peak) {
|
||||
peak = count;
|
||||
}
|
||||
}),
|
||||
);
|
||||
peaks.set(entry.label, peak);
|
||||
});
|
||||
return peaks;
|
||||
}
|
||||
|
||||
/** Groups the legend currently has enabled. `undefined` means all of them. */
|
||||
function resolveVisible(
|
||||
series: HeatmapSeries[],
|
||||
visibleGroups: string[] | undefined,
|
||||
): HeatmapSeries[] {
|
||||
if (visibleGroups === undefined) {
|
||||
return series;
|
||||
}
|
||||
const allowed = new Set(visibleGroups);
|
||||
return series.filter((entry) => allowed.has(entry.label));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pivots the response's column-major counts into the row-major grid the renderer
|
||||
* draws, and sums the enabled groups — counts are additive, so the sum is exact and
|
||||
* needs no extra request. A cell is `null` only when no group contributed to it.
|
||||
*/
|
||||
export function resolveHeatmapGrid({
|
||||
buckets,
|
||||
step,
|
||||
series,
|
||||
visibleGroups,
|
||||
}: {
|
||||
buckets: number[];
|
||||
/** Column width in seconds. */
|
||||
step: number;
|
||||
series: HeatmapSeries[];
|
||||
/** Labels the legend has enabled. `undefined` sums every group. */
|
||||
visibleGroups?: string[];
|
||||
}): HeatmapGrid {
|
||||
if (buckets.length === 0 || series.length === 0) {
|
||||
return EMPTY_GRID;
|
||||
}
|
||||
|
||||
const selected = resolveVisible(series, visibleGroups);
|
||||
|
||||
// Groups are not guaranteed to share timestamps, so the columns are their union.
|
||||
const timestampSet = new Set<number>();
|
||||
selected.forEach((entry) => {
|
||||
entry.points.forEach((point) => timestampSet.add(point.timestamp));
|
||||
});
|
||||
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
|
||||
const columnOf = new Map(timestamps.map((value, index) => [value, index]));
|
||||
|
||||
// N boundaries describe N+1 rows: the underflow row and the `+Inf` overflow row.
|
||||
const rowCount = buckets.length + 1;
|
||||
const counts: Array<Array<number | null>> = Array.from(
|
||||
{ length: rowCount },
|
||||
() => new Array<number | null>(timestamps.length).fill(null),
|
||||
);
|
||||
|
||||
selected.forEach((entry) => {
|
||||
entry.points.forEach((point) => {
|
||||
const column = columnOf.get(point.timestamp);
|
||||
if (column === undefined) {
|
||||
return;
|
||||
}
|
||||
point.counts.forEach((count, row) => {
|
||||
if (row >= rowCount || count === null || count === undefined) {
|
||||
return;
|
||||
}
|
||||
counts[row][column] = (counts[row][column] ?? 0) + count;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { bounds: buckets, timestamps, step, counts };
|
||||
}
|
||||
170
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin.ts
Normal file
170
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
createHeatmapColorResolver,
|
||||
HeatmapColorResolver,
|
||||
resolveCountDomain,
|
||||
} from './colorScale';
|
||||
import { resolveColumnIndex, resolveRowIndex } from './geometry';
|
||||
import {
|
||||
createHoverOverlay,
|
||||
HeatmapHoverOverlay,
|
||||
showHoverOverlay,
|
||||
} from './hoverOverlay';
|
||||
import { createHatchPattern, drawCells, drawOverflowBoundary } from './paint';
|
||||
import { HeatmapCell, HeatmapColorOptions, HeatmapYAxis } from './types';
|
||||
|
||||
export interface HeatmapRenderOptions {
|
||||
yAxis: HeatmapYAxis;
|
||||
/** Column width in seconds. */
|
||||
step: number;
|
||||
colors: HeatmapColorOptions;
|
||||
isDarkMode: boolean;
|
||||
/** Opacity-mode fill when `colors.fill` is empty. */
|
||||
seriesColor: string;
|
||||
/** Default true. */
|
||||
dimOnHover?: boolean;
|
||||
/** `null` when the cursor leaves. */
|
||||
onHoverChange?: (cell: HeatmapCell | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered through `UPlotConfigBuilder.addHook`, not as a `uPlot.Plugin`: uPlot
|
||||
* appends plugin hooks *after* the hook arrays, and `setCursor` must run before
|
||||
* TooltipPlugin's so the focused row is resolved when the tooltip positions
|
||||
* itself. As a plugin it trails a frame and the tooltip flashes at the origin.
|
||||
*/
|
||||
export interface HeatmapHooks {
|
||||
init: (u: uPlot) => void;
|
||||
draw: (u: uPlot) => void;
|
||||
setCursor: (u: uPlot) => void;
|
||||
destroy: (u: uPlot) => void;
|
||||
}
|
||||
|
||||
export function createHeatmapHooks({
|
||||
yAxis,
|
||||
step,
|
||||
colors,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
dimOnHover = true,
|
||||
onHoverChange,
|
||||
}: HeatmapRenderOptions): HeatmapHooks {
|
||||
let overlay: HeatmapHoverOverlay | null = null;
|
||||
let hovered: HeatmapCell | null = null;
|
||||
let hatchPattern: CanvasPattern | null = null;
|
||||
|
||||
// On auto, the domain comes from the data, but these hooks are captured once at
|
||||
// config-build time. Resolving lazily keeps a refetch on uPlot's `setData` path
|
||||
// rather than forcing a rebuild.
|
||||
let cachedData: uPlot.AlignedData | null = null;
|
||||
let cachedResolver: HeatmapColorResolver | null = null;
|
||||
|
||||
function getResolver(u: uPlot): HeatmapColorResolver {
|
||||
if (cachedResolver && cachedData === u.data) {
|
||||
return cachedResolver;
|
||||
}
|
||||
cachedResolver = createHeatmapColorResolver({
|
||||
options: colors,
|
||||
domain: resolveCountDomain(
|
||||
colors,
|
||||
u.data.slice(1) as Array<Array<number | null>>,
|
||||
),
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
});
|
||||
cachedData = u.data;
|
||||
return cachedResolver;
|
||||
}
|
||||
|
||||
function clearHover(u: uPlot): void {
|
||||
if (overlay) {
|
||||
overlay.container.style.display = 'none';
|
||||
}
|
||||
if (hovered === null) {
|
||||
return;
|
||||
}
|
||||
hovered = null;
|
||||
u.setSeries(null, { focus: true });
|
||||
onHoverChange?.(null);
|
||||
}
|
||||
|
||||
return {
|
||||
init: (u: uPlot): void => {
|
||||
overlay = createHoverOverlay(isDarkMode);
|
||||
u.over.appendChild(overlay.container);
|
||||
},
|
||||
|
||||
draw: (u: uPlot): void => {
|
||||
const timestamps = u.data[0] as ArrayLike<number> | undefined;
|
||||
if (!timestamps?.length || yAxis.rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
const { ctx } = u;
|
||||
hatchPattern ??= createHatchPattern(ctx, isDarkMode);
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
|
||||
ctx.clip();
|
||||
drawCells({ u, yAxis, step, resolver: getResolver(u), hatchPattern });
|
||||
ctx.restore();
|
||||
|
||||
drawOverflowBoundary({ u, yAxis, isDarkMode });
|
||||
},
|
||||
|
||||
setCursor: (u: uPlot): void => {
|
||||
const { left = -10, top = -10 } = u.cursor;
|
||||
if (left < 0 || top < 0) {
|
||||
clearHover(u);
|
||||
return;
|
||||
}
|
||||
|
||||
const column = resolveColumnIndex(
|
||||
u.data[0] as ArrayLike<number>,
|
||||
u.posToVal(left, 'x'),
|
||||
step,
|
||||
);
|
||||
const row = resolveRowIndex(yAxis.edges, u.posToVal(top, 'y'));
|
||||
if (column === null || row === null) {
|
||||
clearHover(u);
|
||||
return;
|
||||
}
|
||||
if (hovered?.row === row && hovered?.column === column) {
|
||||
return;
|
||||
}
|
||||
|
||||
hovered = {
|
||||
row,
|
||||
column,
|
||||
count:
|
||||
(u.data[row + 1] as Array<number | null> | undefined)?.[column] ?? null,
|
||||
};
|
||||
// Drives TooltipPlugin, which only shows a tooltip for a focused series.
|
||||
// uPlot's own focus is disabled here: it picks the series nearest in value
|
||||
// space, and a heatmap's value is a colour, not a y coordinate.
|
||||
u.setSeries(row + 1, { focus: true });
|
||||
if (overlay) {
|
||||
showHoverOverlay({
|
||||
overlay,
|
||||
u,
|
||||
yAxis,
|
||||
step,
|
||||
row,
|
||||
column,
|
||||
dim: dimOnHover,
|
||||
});
|
||||
}
|
||||
onHoverChange?.(hovered);
|
||||
},
|
||||
|
||||
destroy: (): void => {
|
||||
overlay?.container.remove();
|
||||
overlay = null;
|
||||
hovered = null;
|
||||
hatchPattern = null;
|
||||
cachedData = null;
|
||||
cachedResolver = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
118
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/hoverOverlay.ts
Normal file
118
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/hoverOverlay.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { HeatmapYAxis } from './types';
|
||||
|
||||
const HIGHLIGHT_BORDER_WIDTH = 1;
|
||||
/** ~55% alpha. */
|
||||
const DIM_ALPHA = '8C';
|
||||
|
||||
export interface HeatmapHoverOverlay {
|
||||
container: HTMLDivElement;
|
||||
highlight: HTMLDivElement;
|
||||
/** Four corner rects whose complement is the hovered row/column cross. */
|
||||
dims: HTMLDivElement[];
|
||||
}
|
||||
|
||||
function createOverlayElement(): HTMLDivElement {
|
||||
const element = document.createElement('div');
|
||||
element.style.position = 'absolute';
|
||||
element.style.pointerEvents = 'none';
|
||||
return element;
|
||||
}
|
||||
|
||||
function setRect(
|
||||
element: HTMLDivElement,
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
element.style.left = `${left}px`;
|
||||
element.style.top = `${top}px`;
|
||||
element.style.width = `${Math.max(0, width)}px`;
|
||||
element.style.height = `${Math.max(0, height)}px`;
|
||||
}
|
||||
|
||||
/** Kept out of the canvas so moving between cells repositions a few nodes
|
||||
* instead of repainting the grid. */
|
||||
export function createHoverOverlay(isDarkMode: boolean): HeatmapHoverOverlay {
|
||||
const container = createOverlayElement();
|
||||
container.style.inset = '0';
|
||||
container.style.display = 'none';
|
||||
container.setAttribute('data-testid', 'heatmap-hover-overlay');
|
||||
|
||||
const dimColor = `${
|
||||
isDarkMode ? Color.BG_INK_500 : Color.BG_VANILLA_100
|
||||
}${DIM_ALPHA}`;
|
||||
const dims = Array.from({ length: 4 }, () => {
|
||||
const dim = createOverlayElement();
|
||||
dim.style.background = dimColor;
|
||||
container.appendChild(dim);
|
||||
return dim;
|
||||
});
|
||||
|
||||
const highlight = createOverlayElement();
|
||||
highlight.style.border = `${HIGHLIGHT_BORDER_WIDTH}px solid ${
|
||||
isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_300
|
||||
}`;
|
||||
highlight.style.boxSizing = 'border-box';
|
||||
container.appendChild(highlight);
|
||||
|
||||
return { container, highlight, dims };
|
||||
}
|
||||
|
||||
/** Positions the highlight, and the four corner rects so only the hovered row
|
||||
* and column stay at full contrast. */
|
||||
export function showHoverOverlay({
|
||||
overlay,
|
||||
u,
|
||||
yAxis,
|
||||
step,
|
||||
row,
|
||||
column,
|
||||
dim,
|
||||
}: {
|
||||
overlay: HeatmapHoverOverlay;
|
||||
u: uPlot;
|
||||
yAxis: HeatmapYAxis;
|
||||
step: number;
|
||||
row: number;
|
||||
column: number;
|
||||
dim: boolean;
|
||||
}): void {
|
||||
const timestamps = u.data[0] as ArrayLike<number>;
|
||||
const width = u.over.clientWidth;
|
||||
const height = u.over.clientHeight;
|
||||
|
||||
const cellLeft = u.valToPos(timestamps[column], 'x');
|
||||
const cellRight = u.valToPos(timestamps[column] + step, 'x');
|
||||
const cellTop = u.valToPos(yAxis.edges[row + 1], 'y');
|
||||
const cellBottom = u.valToPos(yAxis.edges[row], 'y');
|
||||
|
||||
setRect(
|
||||
overlay.highlight,
|
||||
cellLeft,
|
||||
cellTop,
|
||||
cellRight - cellLeft,
|
||||
cellBottom - cellTop,
|
||||
);
|
||||
|
||||
const [topLeft, topRight, bottomLeft, bottomRight] = overlay.dims;
|
||||
if (dim) {
|
||||
setRect(topLeft, 0, 0, cellLeft, cellTop);
|
||||
setRect(topRight, cellRight, 0, width - cellRight, cellTop);
|
||||
setRect(bottomLeft, 0, cellBottom, cellLeft, height - cellBottom);
|
||||
setRect(
|
||||
bottomRight,
|
||||
cellRight,
|
||||
cellBottom,
|
||||
width - cellRight,
|
||||
height - cellBottom,
|
||||
);
|
||||
} else {
|
||||
overlay.dims.forEach((element) => setRect(element, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
overlay.container.style.display = 'block';
|
||||
}
|
||||
128
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/paint.ts
Normal file
128
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/paint.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { HeatmapColorResolver } from './colorScale';
|
||||
import { HeatmapYAxis } from './types';
|
||||
|
||||
/** Cells at least this wide/tall keep a hairline separator. */
|
||||
const MIN_CELL_SIZE_FOR_GAP = 4;
|
||||
const HATCH_TILE_SIZE = 6;
|
||||
const OVERFLOW_DASH: [number, number] = [4, 3];
|
||||
|
||||
/** Hatch for `null` cells: a gap must never share the bottom-of-scale fill, or a
|
||||
* scrape outage reads as a quiet period. */
|
||||
export function createHatchPattern(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
isDarkMode: boolean,
|
||||
): CanvasPattern | null {
|
||||
const pxRatio = uPlot.pxRatio;
|
||||
const size = Math.max(2, Math.round(HATCH_TILE_SIZE * pxRatio));
|
||||
const tile = document.createElement('canvas');
|
||||
tile.width = size;
|
||||
tile.height = size;
|
||||
|
||||
const tileCtx = tile.getContext('2d');
|
||||
if (!tileCtx) {
|
||||
return null;
|
||||
}
|
||||
tileCtx.strokeStyle = isDarkMode
|
||||
? `${Color.BG_VANILLA_400}59`
|
||||
: `${Color.BG_INK_300}40`;
|
||||
tileCtx.lineWidth = Math.max(1, pxRatio);
|
||||
tileCtx.beginPath();
|
||||
// Three strokes keep the pattern continuous across tile seams.
|
||||
tileCtx.moveTo(0, size);
|
||||
tileCtx.lineTo(size, 0);
|
||||
tileCtx.moveTo(-size / 2, size / 2);
|
||||
tileCtx.lineTo(size / 2, -size / 2);
|
||||
tileCtx.moveTo(size / 2, size * 1.5);
|
||||
tileCtx.lineTo(size * 1.5, size / 2);
|
||||
tileCtx.stroke();
|
||||
|
||||
return ctx.createPattern(tile, 'repeat');
|
||||
}
|
||||
|
||||
/** One canvas pass. Offscreen columns are skipped rather than clipped. */
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function drawCells({
|
||||
u,
|
||||
yAxis,
|
||||
step,
|
||||
resolver,
|
||||
hatchPattern,
|
||||
}: {
|
||||
u: uPlot;
|
||||
yAxis: HeatmapYAxis;
|
||||
step: number;
|
||||
resolver: HeatmapColorResolver;
|
||||
hatchPattern: CanvasPattern | null;
|
||||
}): void {
|
||||
const { ctx } = u;
|
||||
const timestamps = u.data[0] as ArrayLike<number>;
|
||||
const { rows, edges } = yAxis;
|
||||
const pxRatio = uPlot.pxRatio;
|
||||
|
||||
const xMin = u.scales.x.min ?? timestamps[0];
|
||||
const xMax = u.scales.x.max ?? timestamps[timestamps.length - 1] + step;
|
||||
const rowEdgePositions = edges.map((edge) => u.valToPos(edge, 'y', true));
|
||||
|
||||
for (let column = 0; column < timestamps.length; column += 1) {
|
||||
const columnStart = timestamps[column];
|
||||
const columnEnd = columnStart + step;
|
||||
if (columnEnd < xMin || columnStart > xMax) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const left = u.valToPos(columnStart, 'x', true);
|
||||
const rawWidth = u.valToPos(columnEnd, 'x', true) - left;
|
||||
const gapX = rawWidth > MIN_CELL_SIZE_FOR_GAP * pxRatio ? pxRatio : 0;
|
||||
const width = Math.max(1, rawWidth - gapX);
|
||||
|
||||
for (let row = 0; row < rows.length; row += 1) {
|
||||
const top = rowEdgePositions[row + 1];
|
||||
const rawHeight = rowEdgePositions[row] - top;
|
||||
const gapY = rawHeight > MIN_CELL_SIZE_FOR_GAP * pxRatio ? pxRatio : 0;
|
||||
|
||||
const count = (u.data[row + 1] as Array<number | null> | undefined)?.[
|
||||
column
|
||||
];
|
||||
const fill = resolver.colorFor(count ?? null);
|
||||
|
||||
if (fill === null && hatchPattern === null) {
|
||||
continue;
|
||||
}
|
||||
ctx.fillStyle = fill ?? (hatchPattern as CanvasPattern);
|
||||
ctx.fillRect(left, top, width, Math.max(1, rawHeight - gapY));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The `+Inf` row is unbounded, so its height is a drawing convenience and
|
||||
* should not be compared with the real buckets. */
|
||||
export function drawOverflowBoundary({
|
||||
u,
|
||||
yAxis,
|
||||
isDarkMode,
|
||||
}: {
|
||||
u: uPlot;
|
||||
yAxis: HeatmapYAxis;
|
||||
isDarkMode: boolean;
|
||||
}): void {
|
||||
const overflowIndex = yAxis.rows.length - 1;
|
||||
if (overflowIndex < 1 || !yAxis.rows[overflowIndex].isOverflow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ctx } = u;
|
||||
const y = Math.round(u.valToPos(yAxis.edges[overflowIndex], 'y', true));
|
||||
|
||||
ctx.save();
|
||||
ctx.setLineDash(OVERFLOW_DASH);
|
||||
ctx.lineWidth = Math.max(1, uPlot.pxRatio);
|
||||
ctx.strokeStyle = isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_300;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(u.bbox.left, y);
|
||||
ctx.lineTo(u.bbox.left + u.bbox.width, y);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
167
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/palettes.ts
Normal file
167
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/palettes.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { HeatmapColorPalette } from './types';
|
||||
|
||||
interface PaletteDefinition {
|
||||
/** Evenly spaced, one end of the ramp to the other. */
|
||||
stops: string[];
|
||||
/** `true` when `stops[0]` is the dark end. */
|
||||
darkFirst: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop values come from the long-established public palette families —
|
||||
* ColorBrewer for the hue ramps, matplotlib's perceptual set for the rest.
|
||||
*/
|
||||
const PALETTES: Record<HeatmapColorPalette, PaletteDefinition> = {
|
||||
[HeatmapColorPalette.Ice]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#f7fbff',
|
||||
'#deebf7',
|
||||
'#c3dbee',
|
||||
'#9cc8e2',
|
||||
'#6daed5',
|
||||
'#4391c6',
|
||||
'#2271b4',
|
||||
'#0c5198',
|
||||
'#08306b',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Moss]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#f7fcf5',
|
||||
'#e3f4de',
|
||||
'#c6e8bf',
|
||||
'#a0d89b',
|
||||
'#73c378',
|
||||
'#45aa5d',
|
||||
'#228b45',
|
||||
'#066b2d',
|
||||
'#00441b',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Rust]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#fff5f0',
|
||||
'#feddcf',
|
||||
'#fcbaa1',
|
||||
'#fc9273',
|
||||
'#f9694c',
|
||||
'#eb3d2f',
|
||||
'#cb1c1e',
|
||||
'#a10e15',
|
||||
'#67000d',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Graphite]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#ffffff',
|
||||
'#efefef',
|
||||
'#d8d8d8',
|
||||
'#bbbbbb',
|
||||
'#979797',
|
||||
'#737373',
|
||||
'#505050',
|
||||
'#262626',
|
||||
'#000000',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Ember]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#ffffcc',
|
||||
'#ffeda0',
|
||||
'#fed676',
|
||||
'#feb250',
|
||||
'#fd893c',
|
||||
'#f8502b',
|
||||
'#e11e20',
|
||||
'#b90424',
|
||||
'#800026',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Lagoon]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#ffffd9',
|
||||
'#eaf7b8',
|
||||
'#c1e7b5',
|
||||
'#81cebb',
|
||||
'#45b4c2',
|
||||
'#248fbd',
|
||||
'#2260a9',
|
||||
'#20378d',
|
||||
'#081d58',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Orchid]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#fff7f3',
|
||||
'#fddfdc',
|
||||
'#fcc3c3',
|
||||
'#fa9cb4',
|
||||
'#f369a3',
|
||||
'#da3495',
|
||||
'#ad0a81',
|
||||
'#7b0176',
|
||||
'#49006a',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Verdant]: {
|
||||
darkFirst: true,
|
||||
stops: [
|
||||
'#440154',
|
||||
'#472d7b',
|
||||
'#3b528b',
|
||||
'#2c728e',
|
||||
'#21918c',
|
||||
'#28ae80',
|
||||
'#5ec962',
|
||||
'#addc30',
|
||||
'#fde725',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Lava]: {
|
||||
darkFirst: true,
|
||||
stops: [
|
||||
'#000004',
|
||||
'#1d1147',
|
||||
'#51127c',
|
||||
'#832681',
|
||||
'#b73779',
|
||||
'#e75263',
|
||||
'#fc8961',
|
||||
'#fec488',
|
||||
'#fcfdbf',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Beacon]: {
|
||||
darkFirst: true,
|
||||
stops: [
|
||||
'#002051',
|
||||
'#11366c',
|
||||
'#3c4d6e',
|
||||
'#62646f',
|
||||
'#7f7c75',
|
||||
'#9a9478',
|
||||
'#bbaf71',
|
||||
'#e2cb5c',
|
||||
'#fdea45',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Stops oriented low-count first for the active theme. At the wrong polarity,
|
||||
* empty cells become the loudest thing on screen. */
|
||||
export function getPaletteStops(
|
||||
palette: HeatmapColorPalette,
|
||||
isDarkMode: boolean,
|
||||
): string[] {
|
||||
const definition = PALETTES[palette] ?? PALETTES[HeatmapColorPalette.Ice];
|
||||
return definition.darkFirst === isDarkMode
|
||||
? definition.stops
|
||||
: [...definition.stops].reverse();
|
||||
}
|
||||
113
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/types.ts
Normal file
113
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/types.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
export enum HeatmapColorScale {
|
||||
Log = 'log',
|
||||
Sqrt = 'sqrt',
|
||||
Linear = 'linear',
|
||||
}
|
||||
|
||||
export enum HeatmapColorMode {
|
||||
Palette = 'palette',
|
||||
Opacity = 'opacity',
|
||||
}
|
||||
|
||||
/** Sequential ramps only: colour means "count", so a midpoint or hue cycle would
|
||||
* read as a threshold that does not exist. */
|
||||
export enum HeatmapColorPalette {
|
||||
Ice = 'ice',
|
||||
Moss = 'moss',
|
||||
Rust = 'rust',
|
||||
Graphite = 'graphite',
|
||||
Ember = 'ember',
|
||||
Lagoon = 'lagoon',
|
||||
Orchid = 'orchid',
|
||||
Verdant = 'verdant',
|
||||
Lava = 'lava',
|
||||
Beacon = 'beacon',
|
||||
}
|
||||
|
||||
export interface HeatmapColorOptions {
|
||||
mode: HeatmapColorMode;
|
||||
scale: HeatmapColorScale;
|
||||
/** `null` derives it, which is always 0 — a count of 0 belongs at the bottom. */
|
||||
minCount: number | null;
|
||||
/** `null` derives it from the grid's highest count. */
|
||||
maxCount: number | null;
|
||||
palette: HeatmapColorPalette;
|
||||
/** Colour steps the ramp is quantised into, 2..128. Unrelated to `step`, the
|
||||
* column width in seconds. */
|
||||
steps: number;
|
||||
/** Opacity mode. Empty falls back to the caller's series colour. */
|
||||
fill: string;
|
||||
}
|
||||
|
||||
/** Row-height distribution of the bucket axis. */
|
||||
export enum HeatmapAxisScale {
|
||||
Log = 'log',
|
||||
Linear = 'linear',
|
||||
}
|
||||
|
||||
export interface HeatmapSeriesPoint {
|
||||
/** Column start, in seconds. */
|
||||
timestamp: number;
|
||||
/** One per bucket row, lowest first. `null` is "no data", never `0`. */
|
||||
counts: Array<number | null>;
|
||||
}
|
||||
|
||||
export interface HeatmapSeriesLabel {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface HeatmapSeries {
|
||||
/** Group label, as the legend names it. Empty when there is no grouping. */
|
||||
label: string;
|
||||
/** The pairs behind `label`, letting the tooltip name rows by value alone. */
|
||||
labels?: HeatmapSeriesLabel[];
|
||||
points: HeatmapSeriesPoint[];
|
||||
}
|
||||
|
||||
/** Counts pivoted into rows and aligned to one column axis. Internal to the
|
||||
* chart, which resolves it from `buckets` and `series`. */
|
||||
export interface HeatmapGrid {
|
||||
/** Ascending. N boundaries describe N+1 rows, including the `+Inf` overflow. */
|
||||
bounds: number[];
|
||||
/** Column starts, in seconds. */
|
||||
timestamps: number[];
|
||||
/** Column width in seconds. Cells span `[timestamps[j], timestamps[j] + step)`,
|
||||
* and the last column has no successor to infer it from. */
|
||||
step: number;
|
||||
/** `counts[row][column]`, row 0 lowest. `null` (no data) renders hatched, `0`
|
||||
* at the bottom of the scale — conflating them hides an outage. */
|
||||
counts: Array<Array<number | null>>;
|
||||
}
|
||||
|
||||
export interface HeatmapRow {
|
||||
/** Synthetic on the underflow row. */
|
||||
lower: number;
|
||||
/** Synthetic on the overflow row. */
|
||||
upper: number;
|
||||
isUnderflow: boolean;
|
||||
isOverflow: boolean;
|
||||
}
|
||||
|
||||
/** The bucket axis in uPlot y-scale space. A log axis is log10 values on a
|
||||
* *linear* scale, not uPlot's log distribution, so boundaries stay exactly on
|
||||
* ticks and uPlot's decade-only label filter cannot hide them. */
|
||||
export interface HeatmapYAxis {
|
||||
rows: HeatmapRow[];
|
||||
/** Row edges, ascending. Length is `rows.length + 1`. */
|
||||
edges: number[];
|
||||
/** Real bucket boundaries — one tick each. */
|
||||
splits: number[];
|
||||
/** Where the `∞` tick goes: the overflow row's upper edge, not its centre,
|
||||
* which would sit half a row from the last boundary and collide with it. */
|
||||
overflowSplit: number | null;
|
||||
toBucketValue: (axisValue: number) => number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface HeatmapCell {
|
||||
row: number;
|
||||
column: number;
|
||||
count: number | null;
|
||||
}
|
||||
@@ -41,6 +41,9 @@ export default function ChartWrapper({
|
||||
customTooltip,
|
||||
pinnedTooltipElement,
|
||||
tooltipPortalRoot,
|
||||
customLegend,
|
||||
legendLabels,
|
||||
contentFooter,
|
||||
'data-testid': testId,
|
||||
}: ChartWrapperProps): JSX.Element {
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
@@ -61,6 +64,10 @@ export default function ChartWrapper({
|
||||
if (!showLegend) {
|
||||
return null;
|
||||
}
|
||||
// Charts whose legend does not list uPlot series supply their own.
|
||||
if (customLegend) {
|
||||
return customLegend(averageLegendWidth);
|
||||
}
|
||||
return (
|
||||
<UPlotLegend
|
||||
config={config}
|
||||
@@ -69,7 +76,7 @@ export default function ChartWrapper({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[config, legendConfig.position, showLegend],
|
||||
[config, legendConfig.position, showLegend, customLegend],
|
||||
);
|
||||
|
||||
const renderTooltipCallback = useCallback(
|
||||
@@ -100,6 +107,8 @@ export default function ChartWrapper({
|
||||
containerHeight={containerHeight}
|
||||
legendConfig={legendConfig}
|
||||
legendComponent={legendComponent}
|
||||
seriesLabels={legendLabels}
|
||||
contentFooter={contentFooter}
|
||||
layoutChildren={layoutChildren}
|
||||
>
|
||||
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
|
||||
|
||||
310
frontend/src/lib/visualization/charts/Heatmap/Heatmap.tsx
Normal file
310
frontend/src/lib/visualization/charts/Heatmap/Heatmap.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import ColorBar from 'lib/uPlotV2/components/ColorBar/ColorBar';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import HeatmapTooltip from 'lib/uPlotV2/components/Tooltip/components/HeatmapTooltip/HeatmapTooltip';
|
||||
import {
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
createHeatmapColorResolver,
|
||||
DEFAULT_HEATMAP_COLORS,
|
||||
resolveCountDomain,
|
||||
resolveExtremeColor,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
resolveGroupPeaks,
|
||||
resolveHeatmapGrid,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapCell,
|
||||
HeatmapColorMode,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { HeatmapChartProps } from 'lib/visualization/charts/types';
|
||||
import { useHeatmapGroupLegend } from './useHeatmapGroupLegend';
|
||||
import { buildHeatmapConfig, prepareHeatmapChartData } from './utils';
|
||||
|
||||
/** Vertical space the colour bar takes out of the container. */
|
||||
const COLOR_BAR_HEIGHT = 28;
|
||||
|
||||
/**
|
||||
* Columns are time slices, rows are bucket ranges, cell colour is the observation
|
||||
* count — so a distribution can be watched changing shape instead of collapsing to
|
||||
* percentile lines. Drawn on canvas (see `createHeatmapHooks`): a 40 × 240 grid is
|
||||
* ~9,600 cells, far past what per-cell DOM carries.
|
||||
*/
|
||||
export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
const {
|
||||
id,
|
||||
buckets,
|
||||
step,
|
||||
series,
|
||||
width,
|
||||
height,
|
||||
isDarkMode,
|
||||
axisScale = HeatmapAxisScale.Log,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
showVisualMap = true,
|
||||
showLegend = true,
|
||||
legendPosition = LegendPosition.BOTTOM,
|
||||
dimOnHover = true,
|
||||
showTooltip = true,
|
||||
canPinTooltip = false,
|
||||
pinKey,
|
||||
seriesColor,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
onCellClick,
|
||||
renderTooltipFooter,
|
||||
tooltipPortalRoot,
|
||||
layoutChildren,
|
||||
'data-testid': testId,
|
||||
} = props;
|
||||
|
||||
const [hoveredCell, setHoveredCell] = useState<HeatmapCell | null>(null);
|
||||
const hoveredCellRef = useRef<HeatmapCell | null>(null);
|
||||
const onCellClickRef = useRef(onCellClick);
|
||||
onCellClickRef.current = onCellClick;
|
||||
|
||||
const groups = useMemo(() => series.map((entry) => entry.label), [series]);
|
||||
|
||||
// One series has nothing to choose between.
|
||||
const hasGroupLegend = showLegend && groups.length > 1;
|
||||
|
||||
const colors = useMemo(
|
||||
() => ({ ...DEFAULT_HEATMAP_COLORS, ...props.colors }),
|
||||
[props.colors],
|
||||
);
|
||||
|
||||
// The opacity fill no longer follows a group colour: with several groups enabled
|
||||
// at once there is no single one to follow.
|
||||
const resolvedSeriesColor = seriesColor ?? DEFAULT_HEATMAP_COLORS.fill;
|
||||
|
||||
// Opacity mode keeps the solid fill; a partially transparent marker is hard to
|
||||
// read against the panel.
|
||||
const extremeColor = resolveExtremeColor({
|
||||
options: colors,
|
||||
isDarkMode,
|
||||
seriesColor: resolvedSeriesColor,
|
||||
});
|
||||
|
||||
const {
|
||||
visibleGroups,
|
||||
focusedSeriesIndex,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
} = useHeatmapGroupLegend({ groups });
|
||||
|
||||
const grid = useMemo(
|
||||
() => resolveHeatmapGrid({ buckets, step, series, visibleGroups }),
|
||||
[buckets, step, series, visibleGroups],
|
||||
);
|
||||
|
||||
const yAxis = useMemo(
|
||||
() => resolveHeatmapYAxis(grid.bounds, axisScale),
|
||||
[grid.bounds, axisScale],
|
||||
);
|
||||
|
||||
const hasGrid = yAxis.rows.length > 0 && grid.timestamps.length > 0;
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
hasGrid
|
||||
? prepareHeatmapChartData(grid, yAxis.rows.length)
|
||||
: ([[]] as unknown as ReturnType<typeof prepareHeatmapChartData>),
|
||||
[grid, yAxis.rows.length, hasGrid],
|
||||
);
|
||||
|
||||
const colorResolver = useMemo(
|
||||
() =>
|
||||
createHeatmapColorResolver({
|
||||
options: colors,
|
||||
domain: resolveCountDomain(colors, grid.counts),
|
||||
isDarkMode,
|
||||
seriesColor: resolvedSeriesColor,
|
||||
}),
|
||||
[colors, grid.counts, isDarkMode, resolvedSeriesColor],
|
||||
);
|
||||
|
||||
// Stable: the renderer captures it at config-build time, so a new identity would
|
||||
// recreate the plot on every hover.
|
||||
const handleHoverChange = useCallback((cell: HeatmapCell | null): void => {
|
||||
hoveredCellRef.current = cell;
|
||||
setHoveredCell(cell);
|
||||
}, []);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildHeatmapConfig({
|
||||
id,
|
||||
grid,
|
||||
yAxis,
|
||||
colors,
|
||||
isDarkMode,
|
||||
seriesColor: resolvedSeriesColor,
|
||||
dimOnHover,
|
||||
onHoverChange: handleHoverChange,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
}),
|
||||
[
|
||||
id,
|
||||
grid,
|
||||
yAxis,
|
||||
colors,
|
||||
isDarkMode,
|
||||
resolvedSeriesColor,
|
||||
dimOnHover,
|
||||
handleHoverChange,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
],
|
||||
);
|
||||
|
||||
// Each marker takes the ramp colour for where that group's densest cell falls on
|
||||
// the colour bar, so a swatch reads against the same scale as the grid.
|
||||
const groupPeaks = useMemo(() => resolveGroupPeaks(series), [series]);
|
||||
const isPaletteMode = colors.mode === HeatmapColorMode.Palette;
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
groups.map((group, index) => ({
|
||||
// +1 mirrors uPlot's 1-based data series, so the shared legend's index
|
||||
// handling is identical across charts.
|
||||
seriesIndex: index + 1,
|
||||
label: group,
|
||||
color: isPaletteMode
|
||||
? (colorResolver.colorFor(groupPeaks.get(group) ?? 0) ?? extremeColor)
|
||||
: extremeColor,
|
||||
show: visibleGroups.includes(group),
|
||||
})),
|
||||
[
|
||||
groups,
|
||||
visibleGroups,
|
||||
isPaletteMode,
|
||||
colorResolver,
|
||||
groupPeaks,
|
||||
extremeColor,
|
||||
],
|
||||
);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => (
|
||||
<HeatmapTooltip
|
||||
{...args}
|
||||
id={id}
|
||||
yAxis={yAxis}
|
||||
step={grid.step}
|
||||
series={series}
|
||||
visibleGroups={visibleGroups}
|
||||
groupColor={extremeColor}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
timezone={timezone}
|
||||
canPinTooltip={canPinTooltip}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
/>
|
||||
),
|
||||
[
|
||||
id,
|
||||
yAxis,
|
||||
grid.step,
|
||||
series,
|
||||
visibleGroups,
|
||||
extremeColor,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
canPinTooltip,
|
||||
renderTooltipFooter,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClick = useCallback((clickData: ChartClickData): void => {
|
||||
if (hoveredCellRef.current) {
|
||||
onCellClickRef.current?.(hoveredCellRef.current, clickData);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const groupLegend = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => (
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={legendPosition}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
/>
|
||||
),
|
||||
[
|
||||
legendItems,
|
||||
legendPosition,
|
||||
focusedSeriesIndex,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
],
|
||||
);
|
||||
|
||||
const visualMap = useMemo(() => {
|
||||
if (!showVisualMap || !hasGrid) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ColorBar
|
||||
label="count"
|
||||
ramp={colorResolver.ramp}
|
||||
minLabel={colorResolver.domain.min.toLocaleString()}
|
||||
maxLabel={colorResolver.domain.max.toLocaleString()}
|
||||
markerPosition={colorResolver.positionOf(hoveredCell?.count ?? null)}
|
||||
/>
|
||||
);
|
||||
}, [showVisualMap, hasGrid, colorResolver, hoveredCell]);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
config={config}
|
||||
data={data}
|
||||
width={width}
|
||||
height={
|
||||
showVisualMap && hasGrid ? Math.max(0, height - COLOR_BAR_HEIGHT) : height
|
||||
}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
showLegend={hasGroupLegend}
|
||||
customLegend={groupLegend}
|
||||
legendLabels={groups}
|
||||
showTooltip={showTooltip}
|
||||
canPinTooltip={canPinTooltip}
|
||||
pinKey={pinKey}
|
||||
onClick={onCellClick ? handleClick : undefined}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
customTooltip={renderTooltip}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
tooltipPortalRoot={tooltipPortalRoot}
|
||||
contentFooter={visualMap}
|
||||
layoutChildren={layoutChildren}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type React from 'react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import {
|
||||
createHeatmapColorResolver,
|
||||
DEFAULT_HEATMAP_COLORS,
|
||||
resolveCountDomain,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import { resolveHeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
|
||||
import {
|
||||
HeatmapColorMode,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
import Heatmap from '../Heatmap';
|
||||
|
||||
// The shared Legend virtualises its items; render them all so they are queryable.
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<div>
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex}>{itemContent(index, item)}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const BUCKETS = [128, 256, 1024];
|
||||
const STEP = 60;
|
||||
|
||||
/** Two groups whose counts sum to a peak of 1,204 in the combined view. */
|
||||
const SERIES: HeatmapSeries[] = [
|
||||
{
|
||||
label: 'service.name=cart',
|
||||
points: [
|
||||
{ timestamp: 1000, counts: [1, 4, 7, 10] },
|
||||
{ timestamp: 1060, counts: [2, null, 8, 11] },
|
||||
{ timestamp: 1120, counts: [3, 6, 9, 1200] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'service.name=checkout',
|
||||
points: [{ timestamp: 1120, counts: [0, 0, 0, 4] }],
|
||||
},
|
||||
];
|
||||
|
||||
function renderHeatmap(
|
||||
props: Partial<React.ComponentProps<typeof Heatmap>> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<Heatmap
|
||||
id="panel-1"
|
||||
buckets={BUCKETS}
|
||||
step={STEP}
|
||||
series={SERIES}
|
||||
width={800}
|
||||
height={400}
|
||||
isDarkMode
|
||||
data-testid="heatmap"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Heatmap', () => {
|
||||
it('renders the plot container', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(screen.getByTestId('heatmap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the colour bar with the resolved count domain', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(screen.getByTestId('color-bar')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,204')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('puts the colour bar against the plot, with the legend after it', () => {
|
||||
renderHeatmap();
|
||||
|
||||
const bar = screen.getByTestId('color-bar');
|
||||
const legend = screen.getByText('service.name=cart').closest('.legend-item');
|
||||
// The bar is the scale key for the grid, so it reads before the controls.
|
||||
expect(
|
||||
bar.compareDocumentPosition(legend as Node) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the colour bar inside the chart column, not below the legend', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('color-bar').closest('.chart-layout__content'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides the colour bar when the visual map is off', () => {
|
||||
renderHeatmap({ showVisualMap: false });
|
||||
|
||||
expect(screen.queryByTestId('color-bar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels the colour bar with an explicit clamp instead of the data range', () => {
|
||||
renderHeatmap({ colors: { minCount: 5, maxCount: 500 } });
|
||||
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
expect(screen.getByText('500')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the no-data state when the metric has no buckets', () => {
|
||||
renderHeatmap({ buckets: [] });
|
||||
|
||||
expect(screen.getByText('No Data')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('color-bar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the no-data state when no columns came back', () => {
|
||||
renderHeatmap({ series: [] });
|
||||
|
||||
expect(screen.getByText('No Data')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Heatmap group legend', () => {
|
||||
const CART = 'service.name=cart';
|
||||
const CHECKOUT = 'service.name=checkout';
|
||||
|
||||
function legendItem(label: string): HTMLElement {
|
||||
const item = screen.getByText(label).closest('.legend-item');
|
||||
if (!item) {
|
||||
throw new Error(`no legend item for ${label}`);
|
||||
}
|
||||
return item as HTMLElement;
|
||||
}
|
||||
|
||||
function marker(label: string): HTMLElement {
|
||||
const element = legendItem(label).querySelector<HTMLElement>(
|
||||
'[data-is-legend-marker]',
|
||||
);
|
||||
if (!element) {
|
||||
throw new Error(`no marker for ${label}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
it('lists the groups, with no combined-view entry', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(screen.getByText(CART)).toBeInTheDocument();
|
||||
expect(screen.getByText(CHECKOUT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/all groups/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables every group to begin with', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(legendItem(CART)).not.toHaveClass('legend-item-off');
|
||||
expect(legendItem(CHECKOUT)).not.toHaveClass('legend-item-off');
|
||||
});
|
||||
|
||||
it('isolates a group when its label is clicked', async () => {
|
||||
renderHeatmap();
|
||||
|
||||
await userEvent.click(screen.getByText(CART));
|
||||
|
||||
expect(legendItem(CART)).not.toHaveClass('legend-item-off');
|
||||
expect(legendItem(CHECKOUT)).toHaveClass('legend-item-off');
|
||||
});
|
||||
|
||||
it('restores every group when the isolated label is clicked again', async () => {
|
||||
renderHeatmap();
|
||||
|
||||
await userEvent.click(screen.getByText(CART));
|
||||
await userEvent.click(screen.getByText(CART));
|
||||
|
||||
expect(legendItem(CHECKOUT)).not.toHaveClass('legend-item-off');
|
||||
});
|
||||
|
||||
it('excludes just one group when its marker is clicked', async () => {
|
||||
renderHeatmap();
|
||||
|
||||
await userEvent.click(marker(CHECKOUT));
|
||||
|
||||
expect(legendItem(CHECKOUT)).toHaveClass('legend-item-off');
|
||||
expect(legendItem(CART)).not.toHaveClass('legend-item-off');
|
||||
});
|
||||
|
||||
/** The ramp the cells and colour bar are drawn from, for the default options. */
|
||||
function activeRamp(): string[] {
|
||||
const grid = resolveHeatmapGrid({
|
||||
buckets: BUCKETS,
|
||||
step: STEP,
|
||||
series: SERIES,
|
||||
});
|
||||
return createHeatmapColorResolver({
|
||||
options: DEFAULT_HEATMAP_COLORS,
|
||||
domain: resolveCountDomain(DEFAULT_HEATMAP_COLORS, grid.counts),
|
||||
isDarkMode: true,
|
||||
seriesColor: DEFAULT_HEATMAP_COLORS.fill,
|
||||
}).ramp.map((color) => color.toLowerCase());
|
||||
}
|
||||
|
||||
it('places each marker where its group sits on the colour bar', () => {
|
||||
renderHeatmap();
|
||||
const ramp = activeRamp();
|
||||
|
||||
// cart peaks at 1200, checkout at 4, so cart sits further along the ramp.
|
||||
// The DOM lowercases hex; the ramp is built uppercase.
|
||||
const cart = ramp.indexOf(marker(CART).style.borderColor.toLowerCase());
|
||||
const checkout = ramp.indexOf(
|
||||
marker(CHECKOUT).style.borderColor.toLowerCase(),
|
||||
);
|
||||
|
||||
expect(cart).toBeGreaterThan(-1);
|
||||
expect(checkout).toBeGreaterThan(-1);
|
||||
expect(cart).toBeGreaterThan(checkout);
|
||||
});
|
||||
|
||||
it('gives every marker the solid fill in opacity mode', () => {
|
||||
renderHeatmap({
|
||||
colors: { mode: HeatmapColorMode.Opacity, fill: '#e5484d' },
|
||||
});
|
||||
|
||||
// A partially transparent marker is hard to read against the panel.
|
||||
expect(marker(CART).style.borderColor).toBe(
|
||||
marker(CHECKOUT).style.borderColor,
|
||||
);
|
||||
expect(marker(CART).style.borderColor).not.toBe('');
|
||||
});
|
||||
|
||||
it('hides the legend when there is only one group to choose from', () => {
|
||||
renderHeatmap({ series: [SERIES[0]] });
|
||||
|
||||
expect(screen.queryByText(CART)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the legend when asked', () => {
|
||||
renderHeatmap({ showLegend: false });
|
||||
|
||||
expect(screen.queryByText(CART)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
import { useHeatmapGroupLegend } from '../useHeatmapGroupLegend';
|
||||
|
||||
const GROUPS = ['cart', 'checkout', 'payments'];
|
||||
|
||||
/** Mimics a click on an item's label, as the shared Legend renders it. */
|
||||
function labelClick(seriesIndex: number): MouseEvent<HTMLDivElement> {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.setAttribute('data-legend-item-id', String(seriesIndex));
|
||||
const label = document.createElement('span');
|
||||
wrapper.appendChild(label);
|
||||
return { target: label } as unknown as MouseEvent<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/** Mimics a click on the item's marker circle. */
|
||||
function markerClick(seriesIndex: number): MouseEvent<HTMLDivElement> {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.setAttribute('data-legend-item-id', String(seriesIndex));
|
||||
const marker = document.createElement('div');
|
||||
marker.dataset.isLegendMarker = 'true';
|
||||
wrapper.appendChild(marker);
|
||||
return { target: marker } as unknown as MouseEvent<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function render(
|
||||
groups: string[] = GROUPS,
|
||||
): ReturnType<
|
||||
typeof renderHook<ReturnType<typeof useHeatmapGroupLegend>, unknown>
|
||||
> {
|
||||
return renderHook(() => useHeatmapGroupLegend({ groups }));
|
||||
}
|
||||
|
||||
describe('useHeatmapGroupLegend', () => {
|
||||
it('enables every group to begin with', () => {
|
||||
const { result } = render();
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('isolates a group when its label is clicked', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(labelClick(2)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
|
||||
});
|
||||
|
||||
it('restores every group when the isolated label is clicked again', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(labelClick(2)));
|
||||
act(() => result.current.onLegendClick(labelClick(2)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('moves the isolation when a different label is clicked', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(labelClick(1)));
|
||||
act(() => result.current.onLegendClick(labelClick(3)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['payments']);
|
||||
});
|
||||
|
||||
it('excludes just one group when its marker is clicked', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(markerClick(2)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['cart', 'payments']);
|
||||
});
|
||||
|
||||
it('re-includes a group when its marker is clicked again', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(markerClick(2)));
|
||||
act(() => result.current.onLegendClick(markerClick(2)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('excludes more than one group', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(markerClick(1)));
|
||||
act(() => result.current.onLegendClick(markerClick(3)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
|
||||
});
|
||||
|
||||
it('drops the isolation when a marker is clicked, so the label can isolate again', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendClick(labelClick(1)));
|
||||
// Re-including cart by marker leaves it enabled but no longer isolated.
|
||||
act(() => result.current.onLegendClick(markerClick(2)));
|
||||
act(() => result.current.onLegendClick(labelClick(1)));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['cart']);
|
||||
});
|
||||
|
||||
it('allows every group to be excluded, as the other legends do', () => {
|
||||
const { result } = render();
|
||||
|
||||
GROUPS.forEach((_, index) =>
|
||||
act(() => result.current.onLegendClick(markerClick(index + 1))),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('ignores clicks that miss an entry', () => {
|
||||
const { result } = render();
|
||||
const stray = {
|
||||
target: document.createElement('div'),
|
||||
} as unknown as MouseEvent<HTMLDivElement>;
|
||||
|
||||
act(() => result.current.onLegendClick(stray));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('forgets a hidden group that left the result', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ groups }) => useHeatmapGroupLegend({ groups }),
|
||||
{ initialProps: { groups: GROUPS } },
|
||||
);
|
||||
|
||||
act(() => result.current.onLegendClick(markerClick(3)));
|
||||
rerender({ groups: ['cart', 'checkout'] });
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['cart', 'checkout']);
|
||||
});
|
||||
|
||||
it('tracks the hovered entry for the legend"s focus highlight', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() => result.current.onLegendMouseMove(labelClick(2)));
|
||||
expect(result.current.focusedSeriesIndex).toBe(2);
|
||||
|
||||
act(() => result.current.onLegendMouseLeave());
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { DEFAULT_HEATMAP_COLORS } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapGrid,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { buildHeatmapConfig, prepareHeatmapChartData } from '../utils';
|
||||
|
||||
const GRID: HeatmapGrid = {
|
||||
bounds: [128, 256, 1024],
|
||||
timestamps: [1000, 1060, 1120],
|
||||
step: 60,
|
||||
counts: [
|
||||
[1, 2, 3],
|
||||
[4, null, 6],
|
||||
[7, 8, 9],
|
||||
[0, 0, 0],
|
||||
],
|
||||
};
|
||||
|
||||
const Y_AXIS = resolveHeatmapYAxis(GRID.bounds, HeatmapAxisScale.Log);
|
||||
|
||||
/** Tall enough that no tick needs thinning. */
|
||||
const TALL_PLOT = { bbox: { height: 1000 } } as uPlot;
|
||||
|
||||
function readSplits(
|
||||
config: ReturnType<typeof buildHeatmapConfig>,
|
||||
plot: uPlot,
|
||||
): number[] {
|
||||
const [, yAxisConfig] = config.getConfig().axes ?? [];
|
||||
return (yAxisConfig.splits as (self: uPlot) => number[])(plot);
|
||||
}
|
||||
|
||||
function readLabels(
|
||||
config: ReturnType<typeof buildHeatmapConfig>,
|
||||
splits: number[],
|
||||
): string[] {
|
||||
const [, yAxisConfig] = config.getConfig().axes ?? [];
|
||||
return (yAxisConfig.values as (u: uPlot, splits: number[]) => string[])(
|
||||
{} as uPlot,
|
||||
splits,
|
||||
);
|
||||
}
|
||||
|
||||
function readRange(scale?: uPlot.Scale): [number, number] {
|
||||
const range = scale?.range as (
|
||||
u: uPlot,
|
||||
min: number,
|
||||
max: number,
|
||||
) => [number, number];
|
||||
return range({} as uPlot, 0, 0);
|
||||
}
|
||||
|
||||
function buildConfig(
|
||||
overrides: Partial<Parameters<typeof buildHeatmapConfig>[0]> = {},
|
||||
): ReturnType<typeof buildHeatmapConfig> {
|
||||
return buildHeatmapConfig({
|
||||
id: 'panel-1',
|
||||
grid: GRID,
|
||||
yAxis: Y_AXIS,
|
||||
colors: DEFAULT_HEATMAP_COLORS,
|
||||
isDarkMode: true,
|
||||
seriesColor: '#4e74f8',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('prepareHeatmapChartData', () => {
|
||||
it('puts timestamps first and one series per bucket row', () => {
|
||||
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
|
||||
|
||||
expect(data).toHaveLength(Y_AXIS.rows.length + 1);
|
||||
expect(data[0]).toStrictEqual(GRID.timestamps);
|
||||
expect(data[1]).toStrictEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('preserves null cells rather than zeroing them', () => {
|
||||
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
|
||||
|
||||
expect(data[2]).toStrictEqual([4, null, 6]);
|
||||
});
|
||||
|
||||
it('pads short rows so every uPlot data array is the same length', () => {
|
||||
const data = prepareHeatmapChartData(
|
||||
{ ...GRID, counts: [[1]] },
|
||||
Y_AXIS.rows.length,
|
||||
);
|
||||
|
||||
expect(data[1]).toStrictEqual([1, null, null]);
|
||||
});
|
||||
|
||||
it('pads missing rows up to the resolved row count', () => {
|
||||
const data = prepareHeatmapChartData({ ...GRID, counts: [] }, 2);
|
||||
|
||||
expect(data).toHaveLength(3);
|
||||
expect(data[2]).toStrictEqual([null, null, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHeatmapConfig', () => {
|
||||
it('registers one series per bucket row, plus uPlot"s timestamp series', () => {
|
||||
const config = buildHeatmapConfig({
|
||||
id: 'panel-1',
|
||||
grid: GRID,
|
||||
yAxis: Y_AXIS,
|
||||
colors: DEFAULT_HEATMAP_COLORS,
|
||||
isDarkMode: true,
|
||||
seriesColor: '#4e74f8',
|
||||
}).getConfig();
|
||||
|
||||
expect(config.series).toHaveLength(Y_AXIS.rows.length + 1);
|
||||
});
|
||||
|
||||
it('draws no paths or points per series — the renderer paints the cells', () => {
|
||||
const [, firstRow] = buildConfig().getConfig().series ?? [];
|
||||
|
||||
expect((firstRow as uPlot.Series).paths?.({} as uPlot, 1, 0, 1)).toBeNull();
|
||||
expect((firstRow as uPlot.Series).points?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('labels series by bucket range, including the open-ended rows', () => {
|
||||
const labels = (buildConfig().getConfig().series ?? [])
|
||||
.slice(1)
|
||||
.map((series) => series.label);
|
||||
|
||||
expect(labels[0]).toContain('≤');
|
||||
expect(labels[labels.length - 1]).toContain('>');
|
||||
});
|
||||
|
||||
it('spans the x scale to the end of the last column, not its start', () => {
|
||||
const { x } = buildConfig().getConfig().scales ?? {};
|
||||
|
||||
expect(readRange(x)).toStrictEqual([1000, 1180]);
|
||||
});
|
||||
|
||||
it('prefers the query window over the grid extent', () => {
|
||||
const { x } =
|
||||
buildConfig({ minTimeScale: 900, maxTimeScale: 1500 }).getConfig().scales ??
|
||||
{};
|
||||
|
||||
expect(readRange(x)).toStrictEqual([900, 1500]);
|
||||
});
|
||||
|
||||
it('pins the y scale to the bucket axis instead of auto-ranging on counts', () => {
|
||||
const { y } = buildConfig().getConfig().scales ?? {};
|
||||
|
||||
expect(y?.auto).toBe(false);
|
||||
expect(readRange(y)).toStrictEqual([Y_AXIS.min, Y_AXIS.max]);
|
||||
});
|
||||
|
||||
it('puts a y tick on every bucket boundary plus the overflow row"s upper edge', () => {
|
||||
const splits = readSplits(buildConfig(), TALL_PLOT);
|
||||
|
||||
expect(splits).toStrictEqual([...Y_AXIS.splits, Y_AXIS.overflowSplit]);
|
||||
});
|
||||
|
||||
it('labels the overflow edge as infinite and the rest by bucket value', () => {
|
||||
const config = buildConfig();
|
||||
const labels = readLabels(config, readSplits(config, TALL_PLOT));
|
||||
|
||||
expect(labels[0]).toBe('128');
|
||||
expect(labels[labels.length - 1]).toBe('∞');
|
||||
});
|
||||
|
||||
it('thins the tick set when the panel is too short to label every boundary', () => {
|
||||
const config = buildConfig();
|
||||
const splits = readSplits(config, { bbox: { height: 40 } } as uPlot);
|
||||
|
||||
expect(splits.length).toBeLessThan(Y_AXIS.splits.length + 1);
|
||||
// The infinite edge is the one label that must never be dropped.
|
||||
expect(readLabels(config, splits).at(-1)).toBe('∞');
|
||||
});
|
||||
|
||||
it('disables uPlot cursor focus and points, which cannot read a colour axis', () => {
|
||||
const config = buildConfig().getConfig();
|
||||
|
||||
expect(config.cursor?.focus?.prox).toBe(-1);
|
||||
expect(config.cursor?.points?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps focus alpha at 1 so focusing a row does not force a full redraw', () => {
|
||||
expect(buildConfig().getConfig().focus?.alpha).toBe(1);
|
||||
});
|
||||
|
||||
it('registers the renderer hooks', () => {
|
||||
const { hooks } = buildConfig().getConfig();
|
||||
|
||||
expect(hooks?.init).toHaveLength(1);
|
||||
expect(hooks?.draw).toHaveLength(1);
|
||||
expect(hooks?.setCursor).toHaveLength(1);
|
||||
expect(hooks?.destroy).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { MouseEvent, useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export interface UseHeatmapGroupLegendResult {
|
||||
/** Groups currently enabled. The grid sums exactly these. */
|
||||
visibleGroups: string[];
|
||||
focusedSeriesIndex: number | null;
|
||||
onLegendClick: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseMove: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
onLegendMouseLeave: () => void;
|
||||
}
|
||||
|
||||
/** The shared Legend tags each item and delegates interaction to the container. */
|
||||
function getLegendIndex(event: MouseEvent<HTMLDivElement>): number | null {
|
||||
const element = (event.target as HTMLElement | null)?.closest<HTMLElement>(
|
||||
'[data-legend-item-id]',
|
||||
);
|
||||
const id = element?.dataset.legendItemId;
|
||||
return id === undefined ? null : Number(id);
|
||||
}
|
||||
|
||||
function isMarkerClick(event: MouseEvent<HTMLDivElement>): boolean {
|
||||
return Boolean((event.target as HTMLElement).dataset.isLegendMarker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group visibility for the heatmap legend, matching every other legend in the
|
||||
* product: the label isolates a group, the marker excludes one, and everything is
|
||||
* enabled to begin with. Counts are additive, so whatever is enabled is summed
|
||||
* client-side and needs no extra request.
|
||||
*
|
||||
* Visibility only. Marker colour is resolved by the caller, which owns the colour
|
||||
* ramp — and that ramp depends on which groups this hook has enabled.
|
||||
*/
|
||||
export function useHeatmapGroupLegend({
|
||||
groups,
|
||||
}: {
|
||||
groups: string[];
|
||||
}): UseHeatmapGroupLegendResult {
|
||||
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
|
||||
const [focusedSeriesIndex, setFocusedSeriesIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const isolatedRef = useRef<string | null>(null);
|
||||
|
||||
const visibleGroups = useMemo(
|
||||
() => groups.filter((group) => !hidden.has(group)),
|
||||
[groups, hidden],
|
||||
);
|
||||
|
||||
const onLegendClick = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(event);
|
||||
const group = index === null ? undefined : groups[index - 1];
|
||||
if (group === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMarkerClick(event)) {
|
||||
isolatedRef.current = null;
|
||||
setHidden((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(group)) {
|
||||
next.delete(group);
|
||||
} else {
|
||||
next.add(group);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Label click isolates; clicking the isolated group again restores all.
|
||||
const isReset = isolatedRef.current === group;
|
||||
isolatedRef.current = isReset ? null : group;
|
||||
setHidden(
|
||||
isReset ? new Set() : new Set(groups.filter((entry) => entry !== group)),
|
||||
);
|
||||
},
|
||||
[groups],
|
||||
);
|
||||
|
||||
const onLegendMouseMove = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>): void => {
|
||||
setFocusedSeriesIndex(getLegendIndex(event));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onLegendMouseLeave = useCallback((): void => {
|
||||
setFocusedSeriesIndex(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
visibleGroups,
|
||||
focusedSeriesIndex,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
};
|
||||
}
|
||||
193
frontend/src/lib/visualization/charts/Heatmap/utils.ts
Normal file
193
frontend/src/lib/visualization/charts/Heatmap/utils.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import {
|
||||
decimateAxisSplits,
|
||||
formatRowLabel,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
createHeatmapHooks,
|
||||
HeatmapRenderOptions,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin';
|
||||
import { HeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
/** Minimum gap between y tick labels, in CSS pixels. */
|
||||
const MIN_Y_TICK_GAP_PX = 18;
|
||||
|
||||
/** Label for the edge above the overflow row. */
|
||||
const OVERFLOW_AXIS_LABEL = '∞';
|
||||
|
||||
/**
|
||||
* Flattens the grid into `[timestamps, ...rows]`, one series per bucket row so
|
||||
* `setData` handles refetches. The series draw nothing; the renderer paints cells.
|
||||
*
|
||||
* Rows are padded to `rowCount` — which can differ from `bounds.length + 1` when
|
||||
* the response carried duplicate boundaries — since uPlot requires equal lengths.
|
||||
*/
|
||||
export function prepareHeatmapChartData(
|
||||
grid: HeatmapGrid,
|
||||
rowCount: number,
|
||||
): uPlot.AlignedData {
|
||||
const columnCount = grid.timestamps.length;
|
||||
const rows = Array.from({ length: rowCount }, (_, row) => {
|
||||
const counts = grid.counts[row] ?? [];
|
||||
return Array.from({ length: columnCount }, (_, column) =>
|
||||
counts[column] === undefined ? null : counts[column],
|
||||
);
|
||||
});
|
||||
|
||||
return [grid.timestamps, ...rows] as unknown as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
export interface BuildHeatmapConfigArgs extends Omit<
|
||||
HeatmapRenderOptions,
|
||||
'step'
|
||||
> {
|
||||
id: string;
|
||||
grid: HeatmapGrid;
|
||||
/** Unit of the bucket boundaries; counts are never formatted with it. */
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
timezone?: Timezone;
|
||||
/** Query window, in seconds. Falls back to the grid's own extent. */
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
}
|
||||
|
||||
export function buildHeatmapConfig({
|
||||
id,
|
||||
grid,
|
||||
yAxis,
|
||||
colors,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
dimOnHover,
|
||||
onHoverChange,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
}: BuildHeatmapConfigArgs): UPlotConfigBuilder {
|
||||
const tzDate = timezone
|
||||
? (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value)
|
||||
: undefined;
|
||||
|
||||
const builder = new UPlotConfigBuilder({ id, onDragSelect, tzDate });
|
||||
|
||||
// uPlot's focus picks the series closest in value space, meaningless when the
|
||||
// value is a colour; the renderer focuses the hovered row itself. alpha 1 keeps
|
||||
// that call off uPlot's full-redraw path.
|
||||
builder.setFocus({ alpha: 1 });
|
||||
builder.setCursor({ focus: { prox: -1 }, points: { show: false } });
|
||||
|
||||
const formatBucketValue = (value: number): string =>
|
||||
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
|
||||
|
||||
const lastTimestamp = grid.timestamps[grid.timestamps.length - 1] ?? 0;
|
||||
const xRange: [number, number] = [
|
||||
minTimeScale ?? grid.timestamps[0] ?? 0,
|
||||
maxTimeScale ?? lastTimestamp + grid.step,
|
||||
];
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
range: (): [number, number] => xRange,
|
||||
});
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
auto: false,
|
||||
range: (): [number, number] => [yAxis.min, yAxis.max],
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
values: uPlotXAxisValuesFormat as uPlot.Axis.Values,
|
||||
});
|
||||
|
||||
// Ticks sit on row edges, so the overflow row is the band between the last
|
||||
// boundary and `∞`. A centre label would sit half a row from the boundary tick
|
||||
// and collide with it.
|
||||
const overflowRow = yAxis.rows[yAxis.rows.length - 1];
|
||||
const hasOverflowTick =
|
||||
yAxis.overflowSplit !== null && overflowRow?.isOverflow === true;
|
||||
const axisSplits = hasOverflowTick
|
||||
? [...yAxis.splits, yAxis.overflowSplit as number]
|
||||
: yAxis.splits;
|
||||
|
||||
// From the boundaries themselves, not by inverting the transform:
|
||||
// 10 ** Math.log10(128) is 127.999…, which formats as "127.99".
|
||||
const splitLabels = new Map<number, string>();
|
||||
yAxis.splits.forEach((split, index) => {
|
||||
splitLabels.set(split, formatBucketValue(yAxis.rows[index].upper));
|
||||
});
|
||||
if (hasOverflowTick) {
|
||||
splitLabels.set(yAxis.overflowSplit as number, OVERFLOW_AXIS_LABEL);
|
||||
}
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
// Thinned to whatever fits: a histogram can carry more boundaries than the
|
||||
// panel has room to label.
|
||||
splits: (self): number[] =>
|
||||
decimateAxisSplits({
|
||||
splits: axisSplits,
|
||||
min: yAxis.min,
|
||||
max: yAxis.max,
|
||||
plotHeight: self.bbox.height / uPlot.pxRatio,
|
||||
minGapPx: MIN_Y_TICK_GAP_PX,
|
||||
}),
|
||||
values: (_, splits): string[] =>
|
||||
splits.map(
|
||||
(split) =>
|
||||
splitLabels.get(split) ?? formatBucketValue(yAxis.toBucketValue(split)),
|
||||
),
|
||||
});
|
||||
|
||||
yAxis.rows.forEach((row) => {
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
// Nothing is stroked per series; the draw hook paints the grid.
|
||||
drawStyle: DrawStyle.Line,
|
||||
pathBuilder: (): null => null,
|
||||
showPoints: false,
|
||||
spanGaps: false,
|
||||
label: formatRowLabel(row, formatBucketValue),
|
||||
colorMapping: {},
|
||||
isDarkMode,
|
||||
});
|
||||
});
|
||||
|
||||
const hooks = createHeatmapHooks({
|
||||
yAxis,
|
||||
step: grid.step,
|
||||
colors,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
dimOnHover,
|
||||
onHoverChange,
|
||||
});
|
||||
|
||||
// Order matters — see the HeatmapHooks doc comment.
|
||||
builder.addHook('init', hooks.init);
|
||||
builder.addHook('draw', hooks.draw);
|
||||
builder.addHook('setCursor', hooks.setCursor);
|
||||
builder.addHook('destroy', hooks.destroy);
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type {
|
||||
HeatmapAxisScale,
|
||||
HeatmapCell,
|
||||
HeatmapColorOptions,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
@@ -33,6 +39,13 @@ interface BaseChartProps {
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
tooltipPortalRoot?: HTMLElement | null;
|
||||
/** Replaces the config-driven legend, for charts whose legend lists something
|
||||
* other than uPlot series — heatmap groups, where the series are bucket rows. */
|
||||
customLegend?: (averageLegendWidth: number) => React.ReactNode;
|
||||
/** Measured against for the chart/legend split. Pair with `customLegend`. */
|
||||
legendLabels?: string[];
|
||||
/** Rendered under the plot but above the legend, inside the chart column. */
|
||||
contentFooter?: React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
interface UPlotBasedChartProps {
|
||||
@@ -74,6 +87,60 @@ export interface HistogramChartProps extends ChartWrapperProps {
|
||||
isQueriesMerged?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* or the combined view right. It builds its own `UPlotConfigBuilder` too, since the
|
||||
* y axis *is* the bucket axis and `buckets` fully determines it.
|
||||
*
|
||||
* `buckets`, `series` and `colors` must be referentially stable: a new identity
|
||||
* rebuilds the config, which recreates the plot.
|
||||
*/
|
||||
export interface HeatmapChartProps {
|
||||
id: string;
|
||||
/** Ascending. N boundaries describe N+1 rows. */
|
||||
buckets: number[];
|
||||
/** The *effective* step the server used (`meta.stepIntervals[queryName]`), not
|
||||
* the requested one. Cannot be inferred: the last column has no successor. */
|
||||
step: number;
|
||||
/** One entry per group; a query without grouping yields one series. */
|
||||
series: HeatmapSeries[];
|
||||
width: number;
|
||||
height: number;
|
||||
isDarkMode: boolean;
|
||||
/** Overrides on top of `DEFAULT_HEATMAP_COLORS`. */
|
||||
colors?: Partial<HeatmapColorOptions>;
|
||||
/** Default log. */
|
||||
axisScale?: HeatmapAxisScale;
|
||||
/** Unit of the bucket boundaries; counts are always plain numbers. */
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
timezone?: Timezone;
|
||||
/** Colour bar below the grid. Default true. */
|
||||
showVisualMap?: boolean;
|
||||
/** Default true; hidden anyway when there is only one group. Every group starts
|
||||
* enabled — the label isolates one, the marker excludes one. */
|
||||
showLegend?: boolean;
|
||||
legendPosition?: LegendPosition;
|
||||
/** Default true. */
|
||||
dimOnHover?: boolean;
|
||||
showTooltip?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
pinKey?: string;
|
||||
/** Overrides the opacity-mode fill, which otherwise follows the selected
|
||||
* group's legend colour so the grid matches the swatch that was clicked. */
|
||||
seriesColor?: string;
|
||||
/** Query window, in seconds. Falls back to the data's own extent. */
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
onCellClick?: (cell: HeatmapCell, clickData: ChartClickData) => void;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
tooltipPortalRoot?: HTMLElement | null;
|
||||
layoutChildren?: React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One resolved pie/donut slice: a display label, its (already parsed) positive
|
||||
* numeric value, and the colour used for the arc + legend swatch.
|
||||
|
||||
@@ -16,20 +16,31 @@ export interface ChartLayoutProps {
|
||||
averageLegendWidth: number;
|
||||
}) => React.ReactNode;
|
||||
layoutChildren?: React.ReactNode;
|
||||
/**
|
||||
* Rendered directly under the plot, inside the chart column — so it stays next to
|
||||
* the axis with the legend below it, and beside a RIGHT legend rather than under
|
||||
* it. `layoutChildren` sits below everything instead.
|
||||
*/
|
||||
contentFooter?: React.ReactNode;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
legendConfig: LegendConfig;
|
||||
config: UPlotConfigBuilder;
|
||||
/** Defaults to the chart's series labels. Pass them when the legend lists
|
||||
* something else, or the split is measured against the wrong text. */
|
||||
seriesLabels?: string[];
|
||||
}
|
||||
export default function ChartLayout({
|
||||
showLegend = true,
|
||||
legendComponent,
|
||||
children,
|
||||
layoutChildren,
|
||||
contentFooter,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
legendConfig,
|
||||
config,
|
||||
seriesLabels,
|
||||
}: ChartLayoutProps): JSX.Element {
|
||||
const chartDimensions = useMemo(
|
||||
() => {
|
||||
@@ -42,19 +53,20 @@ export default function ChartLayout({
|
||||
averageLegendWidth: MAX_LEGEND_WIDTH,
|
||||
};
|
||||
}
|
||||
const legendItemsMap = config.getLegendItems();
|
||||
const seriesLabels = Object.values(legendItemsMap)
|
||||
.map((item) => item.label)
|
||||
.filter((label): label is string => label !== undefined);
|
||||
const resolvedLabels =
|
||||
seriesLabels ??
|
||||
Object.values(config.getLegendItems())
|
||||
.map((item) => item.label)
|
||||
.filter((label): label is string => label !== undefined);
|
||||
return calculateChartDimensions({
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
legendConfig,
|
||||
seriesLabels,
|
||||
seriesLabels: resolvedLabels,
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[containerWidth, containerHeight, legendConfig, showLegend],
|
||||
[containerWidth, containerHeight, legendConfig, showLegend, seriesLabels],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -72,6 +84,7 @@ export default function ChartLayout({
|
||||
chartHeight: chartDimensions.height,
|
||||
averageLegendWidth: chartDimensions.averageLegendWidth,
|
||||
})}
|
||||
{contentFooter}
|
||||
</div>
|
||||
{showLegend && (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Tripled on purpose: the markdown reset (`.content.content *`) weighs (0,2,0),
|
||||
// and a doubled class only ties it — leaving the winner to stylesheet order,
|
||||
// which reverted `position: relative` and let the copy button anchor to the
|
||||
// panel instead of the block. (0,3,0) wins regardless of order.
|
||||
.codeBlock.codeBlock.codeBlock {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// The wrapper is a reset-exempt island (see MarkdownContent.module.scss), so
|
||||
// plain classes style it; the button inside keeps its design-system look.
|
||||
.copyButton {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
border-radius: 3px;
|
||||
background: var(--l3-background);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
// GitHub-style reveal: hover anywhere on the block, or keyboard focus.
|
||||
.codeBlock.codeBlock.codeBlock:hover .copyButton,
|
||||
.codeBlock.codeBlock.codeBlock:focus-within .copyButton {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { CodeProps } from 'react-markdown/lib/ast-to-react';
|
||||
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
|
||||
import SyntaxHighlighter, { resolveLanguage } from './syntaxLanguages';
|
||||
import { usePrismLanguage } from './usePrismLanguage';
|
||||
|
||||
import styles from './CodeBlock.module.scss';
|
||||
|
||||
const LANGUAGE_PATTERN = /language-(\w+)/;
|
||||
|
||||
/**
|
||||
* Fenced blocks are tokenised by Prism but coloured by the SCSS module —
|
||||
* `useInlineStyles` off swaps the library's own theme for `token …` class names,
|
||||
* which keeps the palette on design tokens and themed with the rest of the body.
|
||||
*/
|
||||
function CodeBlock({ inline, className, children }: CodeProps): JSX.Element {
|
||||
const fenced = LANGUAGE_PATTERN.exec(className ?? '')?.[1]?.toLowerCase();
|
||||
const language = fenced ? resolveLanguage(fenced) : null;
|
||||
const isReady = usePrismLanguage(language);
|
||||
|
||||
if (inline) {
|
||||
return <code className={className}>{children}</code>;
|
||||
}
|
||||
|
||||
// react-markdown hands the block's text through as string children; anything
|
||||
// else in there is not source and has no place in the highlighter's input —
|
||||
// and it is exactly what the copy button puts on the clipboard.
|
||||
const source = (Array.isArray(children) ? children : [children])
|
||||
.filter((child): child is string => typeof child === 'string')
|
||||
.join('')
|
||||
.replace(/\n$/, '');
|
||||
|
||||
// Verbatim while the language chunk is still loading, and permanently for one
|
||||
// Prism doesn't know. The `pre` is supplied here either way, since
|
||||
// `MarkdownContent` unwraps react-markdown's own.
|
||||
const block =
|
||||
!language || !isReady ? (
|
||||
<pre>
|
||||
<code className={className}>{children}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
useInlineStyles={false}
|
||||
PreTag="pre"
|
||||
CodeTag="code"
|
||||
>
|
||||
{source}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.codeBlock}>
|
||||
{block}
|
||||
{/* data-md-ui: exempts the design-system button from the body's style reset. */}
|
||||
<span data-md-ui className={styles.copyButton}>
|
||||
<CopyButton
|
||||
value={source}
|
||||
size={13}
|
||||
ariaLabel="Copy code"
|
||||
testId="text-panel-copy-code"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodeBlock;
|
||||
@@ -0,0 +1,328 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
// Style isolation: the subtree is rolled back to user-agent styling, so no global
|
||||
// rule reaches the rendered body and the rules below are the only author styles that
|
||||
// apply. `all` skips custom properties, so tokens still resolve, and inherited
|
||||
// properties the caller owns (`text-align`, set by the panel's presentation options)
|
||||
// still flow in. The class is doubled throughout so a global `.wrapper p` can't tie
|
||||
// on specificity and win on source order.
|
||||
// `[data-md-ui]` marks injected UI islands (the code-block copy button) that keep
|
||||
// their design-system styling: `:where(:not(…))` skips them and their subtrees at
|
||||
// zero added specificity, so the island escape doesn't out-rank the body rules.
|
||||
.content.content,
|
||||
.content.content *:where(:not([data-md-ui], [data-md-ui] *)) {
|
||||
all: revert;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.content.content {
|
||||
--md-foreground: var(--text-vanilla-100);
|
||||
--md-muted: var(--text-neutral-dark-100);
|
||||
--md-link: var(--text-robin-400);
|
||||
--md-border: var(--l1-border);
|
||||
--md-surface: color-mix(in srgb, var(--l1-foreground) 6%, transparent);
|
||||
--md-code-comment: var(--text-neutral-dark-200);
|
||||
--md-code-punctuation: var(--text-neutral-dark-100);
|
||||
--md-code-keyword: var(--text-sakura-400);
|
||||
--md-code-string: var(--text-forest-400);
|
||||
--md-code-number: var(--text-amber-400);
|
||||
--md-code-function: var(--text-robin-300);
|
||||
--md-code-property: var(--text-aqua-400);
|
||||
// Fits a two-digit ordered marker (`10.`). Shared by the task-list offset.
|
||||
--md-list-indent: 24px;
|
||||
|
||||
display: block;
|
||||
font-family: var(--font-family-inter);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--paragraph-base-400-line-height);
|
||||
color: var(--md-foreground);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
:global(body.lightMode) .content.content {
|
||||
--md-foreground: var(--text-ink-400);
|
||||
--md-muted: var(--text-neutral-light-100);
|
||||
--md-link: var(--text-robin-500);
|
||||
--md-code-comment: var(--text-neutral-light-100);
|
||||
--md-code-punctuation: var(--text-neutral-light-100);
|
||||
--md-code-keyword: var(--text-sakura-600);
|
||||
--md-code-string: var(--text-forest-700);
|
||||
--md-code-number: var(--text-amber-800);
|
||||
--md-code-function: var(--text-robin-600);
|
||||
--md-code-property: var(--text-aqua-700);
|
||||
}
|
||||
|
||||
.content.content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content.content > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.content.content p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.content.content h1,
|
||||
.content.content h2,
|
||||
.content.content h3,
|
||||
.content.content h4,
|
||||
.content.content h5,
|
||||
.content.content h6 {
|
||||
margin: 16px 0 8px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: var(--line-height-tight);
|
||||
color: var(--md-foreground);
|
||||
}
|
||||
|
||||
.content.content h1 {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.content.content h2 {
|
||||
font-size: var(--label-medium-600-font-size);
|
||||
}
|
||||
|
||||
.content.content h3 {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.content.content h4,
|
||||
.content.content h5,
|
||||
.content.content h6 {
|
||||
font-size: var(--paragraph-base-600-font-size);
|
||||
}
|
||||
|
||||
.content.content h5,
|
||||
.content.content h6 {
|
||||
color: var(--md-muted);
|
||||
}
|
||||
|
||||
.content.content ul,
|
||||
.content.content ol {
|
||||
margin: 0 0 8px;
|
||||
padding-left: var(--md-list-indent);
|
||||
}
|
||||
|
||||
// A nested list belongs to the item above it, so it opens tight.
|
||||
.content.content li > ul,
|
||||
.content.content li > ol {
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
// The reset flattens the user-agent's own disc/circle/square progression, so the
|
||||
// per-depth shapes are restated here.
|
||||
.content.content ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.content.content ul ul {
|
||||
list-style: circle;
|
||||
}
|
||||
|
||||
.content.content ul ul ul {
|
||||
list-style: square;
|
||||
}
|
||||
|
||||
.content.content ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.content.content ol ol {
|
||||
list-style: lower-alpha;
|
||||
}
|
||||
|
||||
.content.content ol ol ol {
|
||||
list-style: lower-roman;
|
||||
}
|
||||
|
||||
.content.content li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
// Markers are structure, not content. Safari below 17 ignores `::marker` colour and
|
||||
// leaves them in the body colour.
|
||||
.content.content li::marker {
|
||||
color: var(--md-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// Task lists carry their own checkbox, so drop the marker and reclaim the indent.
|
||||
.content.content li:has(> input[type='checkbox']) {
|
||||
list-style: none;
|
||||
margin-left: calc(var(--md-list-indent) * -1);
|
||||
}
|
||||
|
||||
.content.content input[type='checkbox'] {
|
||||
margin-right: 6px;
|
||||
accent-color: var(--md-link);
|
||||
}
|
||||
|
||||
.content.content input[type='checkbox']:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.content.content a {
|
||||
color: var(--md-link);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.content.content strong {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--md-foreground);
|
||||
}
|
||||
|
||||
.content.content em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.content.content del {
|
||||
text-decoration: line-through;
|
||||
color: var(--md-muted);
|
||||
}
|
||||
|
||||
.content.content code {
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
|
||||
background: var(--md-surface);
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: var(--code-small-400-font-size);
|
||||
color: var(--md-foreground);
|
||||
}
|
||||
|
||||
.content.content pre {
|
||||
margin: 0 0 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 3px;
|
||||
background: var(--md-surface);
|
||||
overflow-x: auto;
|
||||
@include custom-scrollbar;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
font-size: var(--code-small-400-font-size);
|
||||
line-height: var(--line-height-18);
|
||||
color: var(--md-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.content.content blockquote {
|
||||
margin: 0 0 8px;
|
||||
padding: 2px 0 2px 10px;
|
||||
border-left: 4px solid var(--md-border);
|
||||
color: var(--md-muted);
|
||||
}
|
||||
|
||||
.content.content hr {
|
||||
margin: 12px 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--md-border);
|
||||
}
|
||||
|
||||
.content.content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.content.content table {
|
||||
border-collapse: collapse;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.content.content th,
|
||||
.content.content td {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--md-border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.content.content th {
|
||||
background: var(--md-surface);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
// Rendered by the `table` component override.
|
||||
.content.content .tableScroll {
|
||||
margin: 0 0 8px;
|
||||
overflow-x: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
// Prism runs with `useInlineStyles` off, so it emits `token …` class names. They are
|
||||
// `:global` because CSS Modules would otherwise hash them and match nothing, and the
|
||||
// palette lives here on design tokens instead of in a theme object.
|
||||
.content.content :global(.token.comment),
|
||||
.content.content :global(.token.prolog),
|
||||
.content.content :global(.token.doctype),
|
||||
.content.content :global(.token.cdata) {
|
||||
color: var(--md-code-comment);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.content.content :global(.token.punctuation),
|
||||
.content.content :global(.token.operator),
|
||||
.content.content :global(.token.entity) {
|
||||
color: var(--md-code-punctuation);
|
||||
}
|
||||
|
||||
.content.content :global(.token.keyword),
|
||||
.content.content :global(.token.atrule),
|
||||
.content.content :global(.token.rule),
|
||||
.content.content :global(.token.important),
|
||||
.content.content :global(.token.selector) {
|
||||
color: var(--md-code-keyword);
|
||||
}
|
||||
|
||||
.content.content :global(.token.string),
|
||||
.content.content :global(.token.char),
|
||||
.content.content :global(.token.attr-value),
|
||||
.content.content :global(.token.regex),
|
||||
.content.content :global(.token.url) {
|
||||
color: var(--md-code-string);
|
||||
}
|
||||
|
||||
.content.content :global(.token.number),
|
||||
.content.content :global(.token.boolean),
|
||||
.content.content :global(.token.constant),
|
||||
.content.content :global(.token.symbol) {
|
||||
color: var(--md-code-number);
|
||||
}
|
||||
|
||||
.content.content :global(.token.function),
|
||||
.content.content :global(.token.class-name),
|
||||
.content.content :global(.token.builtin) {
|
||||
color: var(--md-code-function);
|
||||
}
|
||||
|
||||
.content.content :global(.token.property),
|
||||
.content.content :global(.token.attr-name),
|
||||
.content.content :global(.token.variable),
|
||||
.content.content :global(.token.tag) {
|
||||
color: var(--md-code-property);
|
||||
}
|
||||
|
||||
.content.content :global(.token.deleted) {
|
||||
color: var(--text-cherry-400);
|
||||
}
|
||||
|
||||
.content.content :global(.token.inserted) {
|
||||
color: var(--text-forest-400);
|
||||
}
|
||||
|
||||
.content.content :global(.token.bold) {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.content.content :global(.token.italic) {
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import {
|
||||
editRenderedOccurrence,
|
||||
type EditableConstruct,
|
||||
} from '../../utils/markdownSource';
|
||||
import { TASK_LIST } from '../../utils/taskList';
|
||||
import CodeBlock from './CodeBlock';
|
||||
import TaskCheckbox from './TaskCheckbox';
|
||||
import { TaskItemOffsetContext } from './taskItemOffset';
|
||||
|
||||
import styles from './MarkdownContent.module.scss';
|
||||
|
||||
/**
|
||||
* SECURITY — never add `rehype-raw` here. Without it react-markdown renders raw HTML
|
||||
* as plain text, so there is no `dangerouslySetInnerHTML` on the path and nothing to
|
||||
* sanitise. The body is user-authored and, on a public dashboard, read anonymously;
|
||||
* the shared `MarkdownRenderer` enables `rehype-raw` and is safe only for the trusted
|
||||
* content it was built for. `transformLinkUri` is likewise left at its default.
|
||||
*/
|
||||
const REMARK_PLUGINS = [remarkGfm];
|
||||
|
||||
// What the default transformer substitutes for a rejected scheme. Inert, but it
|
||||
// would still put `javascript:` in the DOM, so the anchor is dropped instead.
|
||||
const REJECTED_HREF = `javascript:${'void(0)'}`;
|
||||
|
||||
const READ_ONLY_COMPONENTS: Components = {
|
||||
a: ({ node: _node, children, href, ...props }): JSX.Element => {
|
||||
if (!href || href === REJECTED_HREF) {
|
||||
return <span {...props}>{children}</span>;
|
||||
}
|
||||
return (
|
||||
<a {...props} href={href} target="_blank" rel="noopener noreferrer nofollow">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// Wide tables scroll inside their own box rather than widening the panel.
|
||||
table: ({ node: _node, children, ...props }): JSX.Element => (
|
||||
<div className={styles.tableScroll}>
|
||||
<table {...props}>{children}</table>
|
||||
</div>
|
||||
),
|
||||
code: CodeBlock,
|
||||
// `CodeBlock` emits its own `pre`, so this one would nest a second one.
|
||||
pre: ({ children }): JSX.Element => <>{children}</>,
|
||||
};
|
||||
|
||||
/** Absent on a read-only surface, which is what keeps the public view inert. */
|
||||
export interface MarkdownInteractive {
|
||||
/** The body before interpolation — what an edit is applied to. */
|
||||
source: string;
|
||||
onChangeSource: (next: string) => void;
|
||||
}
|
||||
|
||||
export interface MarkdownContentProps {
|
||||
/** Variable interpolation happens upstream, before parsing. */
|
||||
children: string;
|
||||
interactive?: MarkdownInteractive;
|
||||
/** Rendered instead of the body when the source is blank. */
|
||||
emptyState?: ReactNode;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/** CommonMark + GFM, styled in isolation — see the reset in the SCSS module. */
|
||||
function MarkdownContent({
|
||||
children,
|
||||
interactive,
|
||||
emptyState = null,
|
||||
className,
|
||||
testId = 'markdown-content',
|
||||
}: MarkdownContentProps): JSX.Element | null {
|
||||
// Dashboards re-render on every variable tick; parsing is the expensive half.
|
||||
// Element overrides that write back to the source: one entry per interactive
|
||||
// construct, pairing an `EditableConstruct` with the element it renders as.
|
||||
const components = useMemo<Components>(() => {
|
||||
// A const, so the narrowing survives into the handler's closure.
|
||||
const capability = interactive;
|
||||
if (!capability) {
|
||||
return READ_ONLY_COMPONENTS;
|
||||
}
|
||||
|
||||
const edit = <T,>(
|
||||
construct: EditableConstruct<T>,
|
||||
renderedOffset: number,
|
||||
value: T,
|
||||
): void => {
|
||||
const next = editRenderedOccurrence(construct, {
|
||||
source: capability.source,
|
||||
rendered: children,
|
||||
renderedOffset,
|
||||
value,
|
||||
});
|
||||
if (next !== null) {
|
||||
capability.onChangeSource(next);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...READ_ONLY_COMPONENTS,
|
||||
li: ({ node, children: items, ...props }): JSX.Element => (
|
||||
<li {...props}>
|
||||
<TaskItemOffsetContext.Provider value={node.position?.start.offset}>
|
||||
{items}
|
||||
</TaskItemOffsetContext.Provider>
|
||||
</li>
|
||||
),
|
||||
input: ({ checked, type }): JSX.Element | null => {
|
||||
if (type !== 'checkbox') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<TaskCheckbox
|
||||
checked={checked === true}
|
||||
onChange={(next, offset): void => edit(TASK_LIST, offset, next)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [interactive, children]);
|
||||
|
||||
const body = useMemo(
|
||||
() =>
|
||||
children.trim() ? (
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
) : null,
|
||||
[children, components],
|
||||
);
|
||||
|
||||
if (!body) {
|
||||
return emptyState ? <>{emptyState}</> : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.content, className)} data-testid={testId}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownContent;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useTaskItemOffset } from './taskItemOffset';
|
||||
|
||||
interface TaskCheckboxProps {
|
||||
checked: boolean;
|
||||
/** `offset` locates the item in the rendered body. */
|
||||
onChange: (checked: boolean, offset: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A GFM task-list checkbox that writes its state back to the panel's markdown.
|
||||
* Disabled without an offset from its item: nothing would locate its marker.
|
||||
*/
|
||||
function TaskCheckbox({ checked, onChange }: TaskCheckboxProps): JSX.Element {
|
||||
const offset = useTaskItemOffset();
|
||||
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={offset === undefined}
|
||||
aria-label="Toggle task item"
|
||||
data-testid="markdown-task-checkbox"
|
||||
onChange={(event): void => {
|
||||
if (offset !== undefined) {
|
||||
onChange(event.target.checked, offset);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TaskCheckbox;
|
||||
@@ -0,0 +1,313 @@
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import MarkdownContent from '../MarkdownContent';
|
||||
import { loadLanguage } from '../syntaxLanguages';
|
||||
|
||||
describe('MarkdownContent', () => {
|
||||
describe('security', () => {
|
||||
it('renders a script tag as literal text, never as an element', () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent>{'<script>alert(1)</script>'}</MarkdownContent>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('script')).toBeNull();
|
||||
expect(screen.getByTestId('markdown-content')).toHaveTextContent(
|
||||
'<script>alert(1)</script>',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders raw HTML as text rather than markup', () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent>
|
||||
{'<b>bold</b> and <img src="x" onerror="alert(1)">'}
|
||||
</MarkdownContent>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('b')).toBeNull();
|
||||
expect(container.querySelector('img')).toBeNull();
|
||||
expect(screen.getByTestId('markdown-content')).toHaveTextContent(
|
||||
'<b>bold</b>',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the anchor for a javascript: href, keeping the label as text', () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent>{'[x](javascript:alert(1))'}</MarkdownContent>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
expect(container.innerHTML).not.toContain('javascript');
|
||||
expect(screen.getByTestId('markdown-content')).toHaveTextContent('x');
|
||||
});
|
||||
|
||||
it('opens links in a new tab without handing over the opener', () => {
|
||||
render(<MarkdownContent>{'[docs](https://signoz.io)'}</MarkdownContent>);
|
||||
|
||||
const link = screen.getByRole('link', { name: 'docs' });
|
||||
expect(link).toHaveAttribute('href', 'https://signoz.io');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CommonMark and GFM', () => {
|
||||
it('renders headings, lists and emphasis', () => {
|
||||
render(
|
||||
<MarkdownContent>
|
||||
{'# Runbook\n\n- **owner** payments\n- _rotation_ weekly'}
|
||||
</MarkdownContent>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { level: 1, name: 'Runbook' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(2);
|
||||
expect(screen.getByText('owner').tagName).toBe('STRONG');
|
||||
expect(screen.getByText('rotation').tagName).toBe('EM');
|
||||
});
|
||||
|
||||
it('renders GFM tables, task lists and strikethrough', () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent>
|
||||
{'| a | b |\n| --- | --- |\n| 1 | 2 |\n\n- [x] done\n\n~~gone~~'}
|
||||
</MarkdownContent>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
expect(screen.getByRole('checkbox')).toBeChecked();
|
||||
expect(container.querySelector('del')).toHaveTextContent('gone');
|
||||
});
|
||||
|
||||
it('renders fenced code as a preformatted block', () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent>{'```sh\nkubectl get pods\n```'}</MarkdownContent>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('pre code')).toHaveTextContent(
|
||||
'kubectl get pods',
|
||||
);
|
||||
expect(container.querySelectorAll('pre')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders malformed markdown as literal text instead of throwing', () => {
|
||||
render(<MarkdownContent>{'| broken | table\n**unclosed'}</MarkdownContent>);
|
||||
|
||||
expect(screen.getByTestId('markdown-content')).toHaveTextContent(
|
||||
'**unclosed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syntax highlighting', () => {
|
||||
it('tokenises a fenced block once its language has loaded', async () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent>{'```js\nconst x = 1; // note\n```'}</MarkdownContent>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.token.keyword')).toHaveTextContent(
|
||||
'const',
|
||||
);
|
||||
});
|
||||
expect(container.querySelector('.token.number')).toHaveTextContent('1');
|
||||
expect(container.querySelector('.token.comment')).toHaveTextContent(
|
||||
'// note',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the source verbatim while the language is still loading', () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent>{'```rust\nfn main() {}\n```'}</MarkdownContent>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('pre code')).toHaveTextContent(
|
||||
'fn main() {}',
|
||||
);
|
||||
expect(container.querySelector('.token')).toBeNull();
|
||||
});
|
||||
|
||||
it('highlights a language already loaded on the first render', async () => {
|
||||
await loadLanguage('sql');
|
||||
const { container } = render(
|
||||
<MarkdownContent>{'```sql\nSELECT 1\n```'}</MarkdownContent>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.token.keyword')).toHaveTextContent(
|
||||
'SELECT',
|
||||
);
|
||||
});
|
||||
|
||||
it('tags the code element with the language', () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent>{'```python\nx = 1\n```'}</MarkdownContent>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('code')).toHaveClass('language-python');
|
||||
});
|
||||
|
||||
it('renders an unknown language verbatim', () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent>{'```promql\nrate(foo[5m])\n```'}</MarkdownContent>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('pre code')).toHaveTextContent(
|
||||
'rate(foo[5m])',
|
||||
);
|
||||
expect(container.querySelector('.token')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a fence with no language verbatim', () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent>{'```\nplain text\n```'}</MarkdownContent>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('pre code')).toHaveTextContent('plain text');
|
||||
expect(container.querySelector('.token')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves inline code untokenised', () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent>{'use `const` here'}</MarkdownContent>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('pre')).toBeNull();
|
||||
expect(container.querySelector('.token')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty body', () => {
|
||||
it('renders nothing when the source is blank', () => {
|
||||
const { container } = render(<MarkdownContent>{' \n '}</MarkdownContent>);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders the empty state when one is supplied', () => {
|
||||
render(
|
||||
<MarkdownContent emptyState={<span>Nothing here yet</span>}>
|
||||
{''}
|
||||
</MarkdownContent>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Nothing here yet')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('markdown-content')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('code block copy button', () => {
|
||||
it('offers the block source, exactly as fenced, to the copy control', () => {
|
||||
render(<MarkdownContent>{'```sh\nkubectl get pods\n```'}</MarkdownContent>);
|
||||
|
||||
const button = screen.getByTestId('text-panel-copy-code');
|
||||
expect(button).toHaveAccessibleName('Copy code');
|
||||
});
|
||||
|
||||
it('renders no copy control on inline code', () => {
|
||||
render(<MarkdownContent>{'run `npm i` now'}</MarkdownContent>);
|
||||
|
||||
expect(screen.queryByTestId('text-panel-copy-code')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownContent — interactive task lists', () => {
|
||||
const source = ['- [ ] first', '- [x] second'].join('\n');
|
||||
|
||||
it('renders task checkboxes disabled without the capability', () => {
|
||||
render(<MarkdownContent>{source}</MarkdownContent>);
|
||||
|
||||
const boxes = screen.getAllByRole('checkbox');
|
||||
expect(boxes).toHaveLength(2);
|
||||
boxes.forEach((box) => expect(box).toBeDisabled());
|
||||
});
|
||||
|
||||
it('renders them enabled, and checked to match the source', () => {
|
||||
render(
|
||||
<MarkdownContent interactive={{ source, onChangeSource: jest.fn() }}>
|
||||
{source}
|
||||
</MarkdownContent>,
|
||||
);
|
||||
|
||||
const [first, second] = screen.getAllByRole('checkbox');
|
||||
expect(first).toBeEnabled();
|
||||
expect(first).not.toBeChecked();
|
||||
expect(second).toBeChecked();
|
||||
});
|
||||
|
||||
it('checking one rewrites its marker in the source', () => {
|
||||
const onChangeSource = jest.fn();
|
||||
render(
|
||||
<MarkdownContent interactive={{ source, onChangeSource }}>
|
||||
{source}
|
||||
</MarkdownContent>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('checkbox')[0]);
|
||||
|
||||
expect(onChangeSource).toHaveBeenCalledWith(
|
||||
['- [x] first', '- [x] second'].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('unchecking one rewrites only that marker', () => {
|
||||
const onChangeSource = jest.fn();
|
||||
render(
|
||||
<MarkdownContent interactive={{ source, onChangeSource }}>
|
||||
{source}
|
||||
</MarkdownContent>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('checkbox')[1]);
|
||||
|
||||
expect(onChangeSource).toHaveBeenCalledWith(
|
||||
['- [ ] first', '- [ ] second'].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps a click back through an expanded variable', () => {
|
||||
const onChangeSource = jest.fn();
|
||||
const withVariable = ['- [ ] $env first', '- [ ] second'].join('\n');
|
||||
render(
|
||||
<MarkdownContent interactive={{ source: withVariable, onChangeSource }}>
|
||||
{['- [ ] production first', '- [ ] second'].join('\n')}
|
||||
</MarkdownContent>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('checkbox')[1]);
|
||||
|
||||
expect(onChangeSource).toHaveBeenCalledWith(
|
||||
['- [ ] $env first', '- [x] second'].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('saves nothing when a variable injected a marker of its own', () => {
|
||||
const onChangeSource = jest.fn();
|
||||
render(
|
||||
<MarkdownContent interactive={{ source: '- [ ] $tasks', onChangeSource }}>
|
||||
{['- [ ] one', '- [ ] two'].join('\n')}
|
||||
</MarkdownContent>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('checkbox')[0]);
|
||||
|
||||
expect(onChangeSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a task marker inside a fence', () => {
|
||||
const onChangeSource = jest.fn();
|
||||
const fenced = ['```', '- [ ] fenced', '```', '', '- [ ] real'].join('\n');
|
||||
render(
|
||||
<MarkdownContent interactive={{ source: fenced, onChangeSource }}>
|
||||
{fenced}
|
||||
</MarkdownContent>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
expect(onChangeSource).toHaveBeenCalledWith(
|
||||
fenced.replace('- [ ] real', '- [x] real'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { PrismLight } from 'react-syntax-highlighter';
|
||||
|
||||
type PrismLanguage = Parameters<typeof PrismLight.registerLanguage>[1];
|
||||
type LanguageLoader = () => Promise<{ default: PrismLanguage }>;
|
||||
|
||||
// One dynamic import per language, so each becomes its own chunk and a panel pays
|
||||
// only for the languages its fences actually name.
|
||||
const LOADERS: Record<string, LanguageLoader> = {
|
||||
bash: () => import('react-syntax-highlighter/dist/esm/languages/prism/bash'),
|
||||
css: () => import('react-syntax-highlighter/dist/esm/languages/prism/css'),
|
||||
diff: () => import('react-syntax-highlighter/dist/esm/languages/prism/diff'),
|
||||
docker: () =>
|
||||
import('react-syntax-highlighter/dist/esm/languages/prism/docker'),
|
||||
go: () => import('react-syntax-highlighter/dist/esm/languages/prism/go'),
|
||||
java: () => import('react-syntax-highlighter/dist/esm/languages/prism/java'),
|
||||
javascript: () =>
|
||||
import('react-syntax-highlighter/dist/esm/languages/prism/javascript'),
|
||||
json: () => import('react-syntax-highlighter/dist/esm/languages/prism/json'),
|
||||
markup: () =>
|
||||
import('react-syntax-highlighter/dist/esm/languages/prism/markup'),
|
||||
python: () =>
|
||||
import('react-syntax-highlighter/dist/esm/languages/prism/python'),
|
||||
rust: () => import('react-syntax-highlighter/dist/esm/languages/prism/rust'),
|
||||
sql: () => import('react-syntax-highlighter/dist/esm/languages/prism/sql'),
|
||||
typescript: () =>
|
||||
import('react-syntax-highlighter/dist/esm/languages/prism/typescript'),
|
||||
yaml: () => import('react-syntax-highlighter/dist/esm/languages/prism/yaml'),
|
||||
};
|
||||
|
||||
const ALIASES: Record<string, string> = {
|
||||
dockerfile: 'docker',
|
||||
html: 'markup',
|
||||
js: 'javascript',
|
||||
py: 'python',
|
||||
sh: 'bash',
|
||||
shell: 'bash',
|
||||
ts: 'typescript',
|
||||
xml: 'markup',
|
||||
yml: 'yaml',
|
||||
};
|
||||
|
||||
const registered = new Set<string>();
|
||||
const inFlight = new Map<string, Promise<boolean>>();
|
||||
|
||||
/** The name Prism knows a fence's language by, or null if it knows none. */
|
||||
export function resolveLanguage(name: string): string | null {
|
||||
const canonical = ALIASES[name] ?? name;
|
||||
return canonical in LOADERS ? canonical : null;
|
||||
}
|
||||
|
||||
export function isLanguageRegistered(name: string): boolean {
|
||||
return registered.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves to whether `name` is registered and ready to highlight with. Concurrent
|
||||
* callers share one import, so a dashboard of same-language fences fetches once.
|
||||
*/
|
||||
export function loadLanguage(name: string): Promise<boolean> {
|
||||
if (registered.has(name)) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
const pending = inFlight.get(name);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
const loader = LOADERS[name];
|
||||
if (!loader) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const request = loader()
|
||||
.then((module) => {
|
||||
PrismLight.registerLanguage(name, module.default);
|
||||
registered.add(name);
|
||||
return true;
|
||||
})
|
||||
.catch(() => false)
|
||||
.finally(() => {
|
||||
inFlight.delete(name);
|
||||
});
|
||||
|
||||
inFlight.set(name, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export default PrismLight;
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
createContext,
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
useContext,
|
||||
} from 'react';
|
||||
|
||||
/**
|
||||
* Offset of the enclosing list item in the rendered body. The checkbox a task list
|
||||
* renders is synthesised by the AST transform with no position of its own, so its
|
||||
* item supplies one — after every earlier marker and before its own, which is all
|
||||
* the ordinal needs.
|
||||
*
|
||||
* Context, not a store: one render pass handing a node's position to its child.
|
||||
*/
|
||||
export const TaskItemOffsetContext = createContext<number | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export function useTaskItemOffset(): number | undefined {
|
||||
return useContext(TaskItemOffsetContext);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { isLanguageRegistered, loadLanguage } from './syntaxLanguages';
|
||||
|
||||
/**
|
||||
* Registers `language` with Prism on demand, reporting when it is ready to
|
||||
* highlight with. Already-loaded languages report ready on the first render, so a
|
||||
* second fence of the same language never flashes unhighlighted.
|
||||
*/
|
||||
export function usePrismLanguage(language: string | null): boolean {
|
||||
const [isReady, setIsReady] = useState(
|
||||
() => !!language && isLanguageRegistered(language),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!language) {
|
||||
setIsReady(false);
|
||||
return undefined;
|
||||
}
|
||||
if (isLanguageRegistered(language)) {
|
||||
setIsReady(true);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setIsReady(false);
|
||||
let isStale = false;
|
||||
// `loadLanguage` resolves false rather than rejecting, so there is no failure
|
||||
// path here beyond leaving the block unhighlighted.
|
||||
void loadLanguage(language).then((loaded): boolean => {
|
||||
if (!isStale && loaded) {
|
||||
setIsReady(true);
|
||||
}
|
||||
return loaded;
|
||||
});
|
||||
|
||||
return (): void => {
|
||||
isStale = true;
|
||||
};
|
||||
}, [language]);
|
||||
|
||||
return isReady;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
editRenderedOccurrence,
|
||||
maskFencedCode,
|
||||
matchOffsets,
|
||||
} from '../markdownSource';
|
||||
import { TASK_LIST } from '../taskList';
|
||||
|
||||
const CHECKLIST = ['- [ ] first', '- [x] second', '- [ ] third'].join('\n');
|
||||
|
||||
function toggle(
|
||||
source: string,
|
||||
renderedOffset: number,
|
||||
value: boolean,
|
||||
rendered = source,
|
||||
): string | null {
|
||||
return editRenderedOccurrence(TASK_LIST, {
|
||||
source,
|
||||
rendered,
|
||||
renderedOffset,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
describe('maskFencedCode', () => {
|
||||
it('blanks a fence without moving anything after it', () => {
|
||||
const markdown = ['a', '```', '- [ ] not a task', '```', '- [ ] task'].join(
|
||||
'\n',
|
||||
);
|
||||
const masked = maskFencedCode(markdown);
|
||||
|
||||
expect(masked).toHaveLength(markdown.length);
|
||||
expect(masked.indexOf('- [ ] task')).toBe(markdown.indexOf('- [ ] task'));
|
||||
expect(masked).not.toContain('not a task');
|
||||
});
|
||||
|
||||
it('handles tilde fences and an unterminated one', () => {
|
||||
expect(maskFencedCode('~~~\n- [ ] x\n~~~')).not.toContain('[ ]');
|
||||
expect(maskFencedCode('```\n- [ ] x')).not.toContain('[ ]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchOffsets', () => {
|
||||
it('finds every task marker, in document order', () => {
|
||||
expect(TASK_LIST.offsets(CHECKLIST)).toStrictEqual([
|
||||
CHECKLIST.indexOf('[ ]') + 1,
|
||||
CHECKLIST.indexOf('[x]') + 1,
|
||||
CHECKLIST.lastIndexOf('[ ]') + 1,
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores markers inside a fence', () => {
|
||||
const markdown = ['- [ ] real', '```', '- [ ] fenced', '```'].join('\n');
|
||||
|
||||
expect(TASK_LIST.offsets(markdown)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reads ordered lists and the alternate bullets', () => {
|
||||
const markdown = ['1. [ ] a', '2) [ ] b', '* [ ] c', '+ [ ] d'].join('\n');
|
||||
|
||||
expect(TASK_LIST.offsets(markdown)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('is repeatable', () => {
|
||||
expect(TASK_LIST.offsets(CHECKLIST)).toStrictEqual(
|
||||
TASK_LIST.offsets(CHECKLIST),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat a bare bracket as a marker', () => {
|
||||
expect(TASK_LIST.offsets('- [] no')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('scans with the pattern it is given', () => {
|
||||
const offsets = matchOffsets('a1 b2', /\d/g, () => 0);
|
||||
|
||||
expect(offsets).toStrictEqual([1, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editRenderedOccurrence', () => {
|
||||
it('checks the marker the clicked checkbox belongs to', () => {
|
||||
const next = toggle(CHECKLIST, CHECKLIST.lastIndexOf('[ ]'), true);
|
||||
|
||||
expect(next).toBe(['- [ ] first', '- [x] second', '- [x] third'].join('\n'));
|
||||
});
|
||||
|
||||
it('unchecks one without touching its neighbours', () => {
|
||||
const next = toggle(CHECKLIST, CHECKLIST.indexOf('[x]'), false);
|
||||
|
||||
expect(next).toBe(['- [ ] first', '- [ ] second', '- [ ] third'].join('\n'));
|
||||
});
|
||||
|
||||
it('leaves the rest of the body byte-identical', () => {
|
||||
const source = ['# Title', '', '- [ ] one', '', 'Prose after.'].join('\n');
|
||||
const next = toggle(source, source.indexOf('[ ]'), true);
|
||||
|
||||
expect(next).toBe(source.replace('[ ]', '[x]'));
|
||||
});
|
||||
|
||||
it('maps a rendered offset back through an expanded variable', () => {
|
||||
const source = ['- [ ] $env first', '- [ ] second'].join('\n');
|
||||
const rendered = ['- [ ] production first', '- [ ] second'].join('\n');
|
||||
|
||||
const next = toggle(source, rendered.lastIndexOf('[ ]'), true, rendered);
|
||||
|
||||
expect(next).toBe(['- [ ] $env first', '- [x] second'].join('\n'));
|
||||
});
|
||||
|
||||
it('refuses when the two bodies disagree on how many there are', () => {
|
||||
const source = ['- [ ] $tasks', '- [ ] last'].join('\n');
|
||||
const rendered = ['- [ ] one', '- [ ] injected', '- [ ] last'].join('\n');
|
||||
|
||||
expect(
|
||||
toggle(source, rendered.lastIndexOf('[ ]'), true, rendered),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses when the source has no such occurrence', () => {
|
||||
expect(toggle('no tasks here', 0, true)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/** Fenced regions, whose markup is source text rather than a rendered construct. */
|
||||
const FENCE = /^([ \t]*)(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:\n\1\2[^\n]*|$)/gm;
|
||||
|
||||
/**
|
||||
* Blanks fenced code, preserving length so offsets still line up. A construct
|
||||
* inside a fence renders as text, so counting it would misalign the rest.
|
||||
*/
|
||||
export function maskFencedCode(markdown: string): string {
|
||||
return markdown.replace(FENCE, (block) => block.replace(/[^\n]/g, ' '));
|
||||
}
|
||||
|
||||
/** Offsets of every match of `pattern` in `markdown`, ignoring fenced code. */
|
||||
export function matchOffsets(
|
||||
markdown: string,
|
||||
pattern: RegExp,
|
||||
/** Offset within the match of the span the rewrite targets. */
|
||||
spanOffset: (match: RegExpExecArray) => number,
|
||||
): number[] {
|
||||
const masked = maskFencedCode(markdown);
|
||||
const scan = new RegExp(pattern.source, pattern.flags);
|
||||
const offsets: number[] = [];
|
||||
let match = scan.exec(masked);
|
||||
while (match !== null) {
|
||||
offsets.push(match.index + spanOffset(match));
|
||||
match = scan.exec(masked);
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
/**
|
||||
* A markdown construct whose rendered element writes back to the source. Add one
|
||||
* of these plus an element override in `MarkdownContent` to make another element
|
||||
* interactive.
|
||||
*/
|
||||
export interface EditableConstruct<T> {
|
||||
/** Offset of each occurrence's editable span, in document order. */
|
||||
offsets: (markdown: string) => number[];
|
||||
/** Rewrites the span at `offset` to `value`. */
|
||||
rewrite: (markdown: string, offset: number, value: T) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a rendered element back to the same ordinal in the source and rewrites it.
|
||||
*
|
||||
* Variables are interpolated before parsing, so a rendered offset is not a source
|
||||
* offset. The ordinal only holds while both bodies carry the same number of the
|
||||
* construct — a variable value with one of its own shifts everything after it —
|
||||
* so a mismatch returns `null` rather than editing the wrong occurrence.
|
||||
*/
|
||||
export function editRenderedOccurrence<T>(
|
||||
construct: EditableConstruct<T>,
|
||||
{
|
||||
source,
|
||||
rendered,
|
||||
renderedOffset,
|
||||
value,
|
||||
}: { source: string; rendered: string; renderedOffset: number; value: T },
|
||||
): string | null {
|
||||
const sourceOffsets = construct.offsets(source);
|
||||
const renderedOffsets = construct.offsets(rendered);
|
||||
if (sourceOffsets.length !== renderedOffsets.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ordinal = renderedOffsets.filter(
|
||||
(start) => start < renderedOffset,
|
||||
).length;
|
||||
const target = sourceOffsets[ordinal];
|
||||
if (target === undefined) {
|
||||
return null;
|
||||
}
|
||||
return construct.rewrite(source, target, value);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type EditableConstruct, matchOffsets } from './markdownSource';
|
||||
|
||||
/** Group 1 runs up to the state character, which is the span a toggle rewrites. */
|
||||
const TASK_MARKER = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+\[)[ xX](\])/gm;
|
||||
|
||||
/** The checkbox a GFM task list renders. */
|
||||
export const TASK_LIST: EditableConstruct<boolean> = {
|
||||
offsets: (markdown) =>
|
||||
matchOffsets(markdown, TASK_MARKER, (match) => match[1].length),
|
||||
rewrite: (markdown, offset, checked) =>
|
||||
markdown.slice(0, offset) +
|
||||
(checked ? 'x' : ' ') +
|
||||
markdown.slice(offset + 1),
|
||||
};
|
||||
@@ -37,6 +37,9 @@ type Alertmanager interface {
|
||||
// ListAllChannels lists all channels for all organizations. It is used by the legacy alertmanager only.
|
||||
ListAllChannels(context.Context) ([]*alertmanagertypes.Channel, error)
|
||||
|
||||
// Nil params lists every channel, unordered and unpaged.
|
||||
ListNotificationChannels(context.Context, string, *alertmanagertypes.ListChannelsParams) (*alertmanagertypes.ListableNotificationChannel, error)
|
||||
|
||||
// GetChannelByID gets a channel for the organization.
|
||||
GetChannelByID(context.Context, string, valuer.UUID) (*alertmanagertypes.Channel, error)
|
||||
|
||||
@@ -48,7 +51,12 @@ type Alertmanager interface {
|
||||
|
||||
// CreateNotificationChannel takes the postable rather than a receiver, because
|
||||
// a receiver carries only the display name.
|
||||
CreateNotificationChannel(context.Context, string, *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)
|
||||
CreateNotificationChannel(context.Context, string, alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)
|
||||
|
||||
UpdateNotificationChannel(context.Context, string, valuer.UUID, alertmanagertypes.UpdatableNotificationChannel) (*alertmanagertypes.Channel, error)
|
||||
|
||||
// TestNotificationChannel sends a test notification for a channel that need not exist.
|
||||
TestNotificationChannel(context.Context, string, alertmanagertypes.TestableNotificationChannel) error
|
||||
|
||||
// DeleteChannelByID deletes a channel for the organization.
|
||||
DeleteChannelByID(context.Context, string, valuer.UUID) error
|
||||
|
||||
@@ -3,6 +3,7 @@ package sqlalertmanagerstore
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
@@ -138,21 +139,88 @@ func (store *config) DeleteChannelByID(ctx context.Context, orgID string, id val
|
||||
}, opts...)
|
||||
}
|
||||
|
||||
func (store *config) ListChannels(ctx context.Context, orgID string) ([]*alertmanagertypes.Channel, error) {
|
||||
var channels []*alertmanagertypes.Channel
|
||||
func (store *config) ListChannels(ctx context.Context, orgID string, params *alertmanagertypes.ListChannelsParams) ([]*alertmanagertypes.Channel, int64, error) {
|
||||
// Nil params means no pagination, so every match is on the only page.
|
||||
if params == nil {
|
||||
var channels []*alertmanagertypes.Channel
|
||||
|
||||
err := store.
|
||||
if err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&channels).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return channels, int64(len(channels)), nil
|
||||
}
|
||||
|
||||
type listedChannel struct {
|
||||
*alertmanagertypes.Channel `bun:",extend"`
|
||||
|
||||
Total int64 `bun:"total"`
|
||||
}
|
||||
|
||||
rows := make([]*listedChannel, 0)
|
||||
|
||||
q := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&channels).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
Model(&rows).
|
||||
ColumnExpr("*").
|
||||
ColumnExpr("COUNT(*) OVER () AS total").
|
||||
Where("org_id = ?", orgID)
|
||||
|
||||
formatter := store.sqlstore.Formatter()
|
||||
|
||||
if params.Query != "" {
|
||||
// LOWER(col) LIKE LOWER(?) behaves the same on SQLite (no ILIKE) and
|
||||
// Postgres. The value's % and _ are escaped to match literally, with
|
||||
// ESCAPE pinning backslash as the escape character.
|
||||
q = q.Where(
|
||||
string(formatter.LowerExpression("display_name"))+" LIKE LOWER(?) ESCAPE '\\'",
|
||||
"%"+formatter.EscapeLikePattern(params.Query)+"%",
|
||||
)
|
||||
}
|
||||
|
||||
return channels, nil
|
||||
if !params.Kind.IsZero() {
|
||||
q = q.Where("type = ?", params.Kind.ToStoredType())
|
||||
}
|
||||
|
||||
q = q.
|
||||
OrderExpr(params.Sort.ToColumn() + " " + strings.ToUpper(params.Order.StringValue())).
|
||||
Limit(params.Limit).
|
||||
Offset(params.Offset)
|
||||
|
||||
if err := q.Scan(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// COUNT(*) OVER () is computed pre-LIMIT, so any returned row carries the full
|
||||
// filter total.
|
||||
var total int64
|
||||
if len(rows) > 0 {
|
||||
total = rows[0].Total
|
||||
} else if params.Offset > 0 {
|
||||
// An empty page carries no total. At offset zero that means nothing
|
||||
// matched, but past the last match it hides the rows the offset skipped.
|
||||
matches, err := q.Count(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
total = int64(matches)
|
||||
}
|
||||
|
||||
channels := make([]*alertmanagertypes.Channel, len(rows))
|
||||
for index, row := range rows {
|
||||
channels[index] = row.Channel
|
||||
}
|
||||
|
||||
return channels, total, nil
|
||||
}
|
||||
|
||||
func (store *config) ListAllChannels(ctx context.Context) ([]*alertmanagertypes.Channel, error) {
|
||||
|
||||
@@ -292,7 +292,7 @@ func (_c *MockAlertmanager_CreateInhibitRules_Call) RunAndReturn(run func(ctx co
|
||||
}
|
||||
|
||||
// CreateNotificationChannel provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) CreateNotificationChannel(context1 context.Context, s string, postableNotificationChannel *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error) {
|
||||
func (_mock *MockAlertmanager) CreateNotificationChannel(context1 context.Context, s string, postableNotificationChannel alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error) {
|
||||
ret := _mock.Called(context1, s, postableNotificationChannel)
|
||||
|
||||
if len(ret) == 0 {
|
||||
@@ -301,17 +301,17 @@ func (_mock *MockAlertmanager) CreateNotificationChannel(context1 context.Contex
|
||||
|
||||
var r0 *alertmanagertypes.Channel
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)); ok {
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)); ok {
|
||||
return returnFunc(context1, s, postableNotificationChannel)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.PostableNotificationChannel) *alertmanagertypes.Channel); ok {
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.PostableNotificationChannel) *alertmanagertypes.Channel); ok {
|
||||
r0 = returnFunc(context1, s, postableNotificationChannel)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*alertmanagertypes.Channel)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, *alertmanagertypes.PostableNotificationChannel) error); ok {
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, alertmanagertypes.PostableNotificationChannel) error); ok {
|
||||
r1 = returnFunc(context1, s, postableNotificationChannel)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
@@ -327,12 +327,12 @@ type MockAlertmanager_CreateNotificationChannel_Call struct {
|
||||
// CreateNotificationChannel is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - postableNotificationChannel *alertmanagertypes.PostableNotificationChannel
|
||||
// - postableNotificationChannel alertmanagertypes.PostableNotificationChannel
|
||||
func (_e *MockAlertmanager_Expecter) CreateNotificationChannel(context1 interface{}, s interface{}, postableNotificationChannel interface{}) *MockAlertmanager_CreateNotificationChannel_Call {
|
||||
return &MockAlertmanager_CreateNotificationChannel_Call{Call: _e.mock.On("CreateNotificationChannel", context1, s, postableNotificationChannel)}
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_CreateNotificationChannel_Call) Run(run func(context1 context.Context, s string, postableNotificationChannel *alertmanagertypes.PostableNotificationChannel)) *MockAlertmanager_CreateNotificationChannel_Call {
|
||||
func (_c *MockAlertmanager_CreateNotificationChannel_Call) Run(run func(context1 context.Context, s string, postableNotificationChannel alertmanagertypes.PostableNotificationChannel)) *MockAlertmanager_CreateNotificationChannel_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
@@ -342,9 +342,9 @@ func (_c *MockAlertmanager_CreateNotificationChannel_Call) Run(run func(context1
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 *alertmanagertypes.PostableNotificationChannel
|
||||
var arg2 alertmanagertypes.PostableNotificationChannel
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(*alertmanagertypes.PostableNotificationChannel)
|
||||
arg2 = args[2].(alertmanagertypes.PostableNotificationChannel)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
@@ -360,7 +360,7 @@ func (_c *MockAlertmanager_CreateNotificationChannel_Call) Return(channel *alert
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_CreateNotificationChannel_Call) RunAndReturn(run func(context1 context.Context, s string, postableNotificationChannel *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateNotificationChannel_Call {
|
||||
func (_c *MockAlertmanager_CreateNotificationChannel_Call) RunAndReturn(run func(context1 context.Context, s string, postableNotificationChannel alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateNotificationChannel_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -1280,6 +1280,80 @@ func (_c *MockAlertmanager_ListChannels_Call) RunAndReturn(run func(context1 con
|
||||
return _c
|
||||
}
|
||||
|
||||
// ListNotificationChannels provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) ListNotificationChannels(context1 context.Context, s string, listChannelsParams *alertmanagertypes.ListChannelsParams) (*alertmanagertypes.ListableNotificationChannel, error) {
|
||||
ret := _mock.Called(context1, s, listChannelsParams)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ListNotificationChannels")
|
||||
}
|
||||
|
||||
var r0 *alertmanagertypes.ListableNotificationChannel
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.ListChannelsParams) (*alertmanagertypes.ListableNotificationChannel, error)); ok {
|
||||
return returnFunc(context1, s, listChannelsParams)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.ListChannelsParams) *alertmanagertypes.ListableNotificationChannel); ok {
|
||||
r0 = returnFunc(context1, s, listChannelsParams)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*alertmanagertypes.ListableNotificationChannel)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, *alertmanagertypes.ListChannelsParams) error); ok {
|
||||
r1 = returnFunc(context1, s, listChannelsParams)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockAlertmanager_ListNotificationChannels_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNotificationChannels'
|
||||
type MockAlertmanager_ListNotificationChannels_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// ListNotificationChannels is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - listChannelsParams *alertmanagertypes.ListChannelsParams
|
||||
func (_e *MockAlertmanager_Expecter) ListNotificationChannels(context1 interface{}, s interface{}, listChannelsParams interface{}) *MockAlertmanager_ListNotificationChannels_Call {
|
||||
return &MockAlertmanager_ListNotificationChannels_Call{Call: _e.mock.On("ListNotificationChannels", context1, s, listChannelsParams)}
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_ListNotificationChannels_Call) Run(run func(context1 context.Context, s string, listChannelsParams *alertmanagertypes.ListChannelsParams)) *MockAlertmanager_ListNotificationChannels_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 *alertmanagertypes.ListChannelsParams
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(*alertmanagertypes.ListChannelsParams)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_ListNotificationChannels_Call) Return(listableNotificationChannel *alertmanagertypes.ListableNotificationChannel, err error) *MockAlertmanager_ListNotificationChannels_Call {
|
||||
_c.Call.Return(listableNotificationChannel, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_ListNotificationChannels_Call) RunAndReturn(run func(context1 context.Context, s string, listChannelsParams *alertmanagertypes.ListChannelsParams) (*alertmanagertypes.ListableNotificationChannel, error)) *MockAlertmanager_ListNotificationChannels_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// PutAlerts provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) PutAlerts(context1 context.Context, s string, v alertmanagertypes.PostableAlerts) error {
|
||||
ret := _mock.Called(context1, s, v)
|
||||
@@ -1697,6 +1771,69 @@ func (_c *MockAlertmanager_TestAlert_Call) RunAndReturn(run func(ctx context.Con
|
||||
return _c
|
||||
}
|
||||
|
||||
// TestNotificationChannel provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) TestNotificationChannel(context1 context.Context, s string, testableNotificationChannel alertmanagertypes.TestableNotificationChannel) error {
|
||||
ret := _mock.Called(context1, s, testableNotificationChannel)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for TestNotificationChannel")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.TestableNotificationChannel) error); ok {
|
||||
r0 = returnFunc(context1, s, testableNotificationChannel)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockAlertmanager_TestNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TestNotificationChannel'
|
||||
type MockAlertmanager_TestNotificationChannel_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// TestNotificationChannel is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - testableNotificationChannel alertmanagertypes.TestableNotificationChannel
|
||||
func (_e *MockAlertmanager_Expecter) TestNotificationChannel(context1 interface{}, s interface{}, testableNotificationChannel interface{}) *MockAlertmanager_TestNotificationChannel_Call {
|
||||
return &MockAlertmanager_TestNotificationChannel_Call{Call: _e.mock.On("TestNotificationChannel", context1, s, testableNotificationChannel)}
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_TestNotificationChannel_Call) Run(run func(context1 context.Context, s string, testableNotificationChannel alertmanagertypes.TestableNotificationChannel)) *MockAlertmanager_TestNotificationChannel_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 alertmanagertypes.TestableNotificationChannel
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(alertmanagertypes.TestableNotificationChannel)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_TestNotificationChannel_Call) Return(err error) *MockAlertmanager_TestNotificationChannel_Call {
|
||||
_c.Call.Return(err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_TestNotificationChannel_Call) RunAndReturn(run func(context1 context.Context, s string, testableNotificationChannel alertmanagertypes.TestableNotificationChannel) error) *MockAlertmanager_TestNotificationChannel_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// TestReceiver provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string, receiver *alertmanagertypes.Receiver) error {
|
||||
ret := _mock.Called(context1, s, receiver)
|
||||
@@ -1892,6 +2029,86 @@ func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) RunAndReturn(run f
|
||||
return _c
|
||||
}
|
||||
|
||||
// UpdateNotificationChannel provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) UpdateNotificationChannel(context1 context.Context, s string, uUID valuer.UUID, updatableNotificationChannel alertmanagertypes.UpdatableNotificationChannel) (*alertmanagertypes.Channel, error) {
|
||||
ret := _mock.Called(context1, s, uUID, updatableNotificationChannel)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for UpdateNotificationChannel")
|
||||
}
|
||||
|
||||
var r0 *alertmanagertypes.Channel
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID, alertmanagertypes.UpdatableNotificationChannel) (*alertmanagertypes.Channel, error)); ok {
|
||||
return returnFunc(context1, s, uUID, updatableNotificationChannel)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID, alertmanagertypes.UpdatableNotificationChannel) *alertmanagertypes.Channel); ok {
|
||||
r0 = returnFunc(context1, s, uUID, updatableNotificationChannel)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*alertmanagertypes.Channel)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, valuer.UUID, alertmanagertypes.UpdatableNotificationChannel) error); ok {
|
||||
r1 = returnFunc(context1, s, uUID, updatableNotificationChannel)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockAlertmanager_UpdateNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateNotificationChannel'
|
||||
type MockAlertmanager_UpdateNotificationChannel_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// UpdateNotificationChannel is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - uUID valuer.UUID
|
||||
// - updatableNotificationChannel alertmanagertypes.UpdatableNotificationChannel
|
||||
func (_e *MockAlertmanager_Expecter) UpdateNotificationChannel(context1 interface{}, s interface{}, uUID interface{}, updatableNotificationChannel interface{}) *MockAlertmanager_UpdateNotificationChannel_Call {
|
||||
return &MockAlertmanager_UpdateNotificationChannel_Call{Call: _e.mock.On("UpdateNotificationChannel", context1, s, uUID, updatableNotificationChannel)}
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_UpdateNotificationChannel_Call) Run(run func(context1 context.Context, s string, uUID valuer.UUID, updatableNotificationChannel alertmanagertypes.UpdatableNotificationChannel)) *MockAlertmanager_UpdateNotificationChannel_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 valuer.UUID
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(valuer.UUID)
|
||||
}
|
||||
var arg3 alertmanagertypes.UpdatableNotificationChannel
|
||||
if args[3] != nil {
|
||||
arg3 = args[3].(alertmanagertypes.UpdatableNotificationChannel)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
arg3,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_UpdateNotificationChannel_Call) Return(channel *alertmanagertypes.Channel, err error) *MockAlertmanager_UpdateNotificationChannel_Call {
|
||||
_c.Call.Return(channel, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_UpdateNotificationChannel_Call) RunAndReturn(run func(context1 context.Context, s string, uUID valuer.UUID, updatableNotificationChannel alertmanagertypes.UpdatableNotificationChannel) (*alertmanagertypes.Channel, error)) *MockAlertmanager_UpdateNotificationChannel_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// UpdateRoutePolicyByID provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) UpdateRoutePolicyByID(ctx context.Context, routeID string, route *alertmanagertypes.PostableRoutePolicy) (*alertmanagertypes.GettableRoutePolicy, error) {
|
||||
ret := _mock.Called(ctx, routeID, route)
|
||||
@@ -2177,6 +2394,52 @@ func (_c *MockHandler_DeleteChannelByID_Call) RunAndReturn(run func(responseWrit
|
||||
return _c
|
||||
}
|
||||
|
||||
// DeleteNotificationChannel provides a mock function for the type MockHandler
|
||||
func (_mock *MockHandler) DeleteNotificationChannel(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
_mock.Called(responseWriter, request)
|
||||
return
|
||||
}
|
||||
|
||||
// MockHandler_DeleteNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteNotificationChannel'
|
||||
type MockHandler_DeleteNotificationChannel_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// DeleteNotificationChannel is a helper method to define mock.On call
|
||||
// - responseWriter http.ResponseWriter
|
||||
// - request *http.Request
|
||||
func (_e *MockHandler_Expecter) DeleteNotificationChannel(responseWriter interface{}, request interface{}) *MockHandler_DeleteNotificationChannel_Call {
|
||||
return &MockHandler_DeleteNotificationChannel_Call{Call: _e.mock.On("DeleteNotificationChannel", responseWriter, request)}
|
||||
}
|
||||
|
||||
func (_c *MockHandler_DeleteNotificationChannel_Call) Run(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_DeleteNotificationChannel_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 http.ResponseWriter
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(http.ResponseWriter)
|
||||
}
|
||||
var arg1 *http.Request
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*http.Request)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockHandler_DeleteNotificationChannel_Call) Return() *MockHandler_DeleteNotificationChannel_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockHandler_DeleteNotificationChannel_Call) RunAndReturn(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_DeleteNotificationChannel_Call {
|
||||
_c.Run(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// DeleteRoutePolicyByID provides a mock function for the type MockHandler
|
||||
func (_mock *MockHandler) DeleteRoutePolicyByID(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
_mock.Called(responseWriter, request)
|
||||
@@ -2361,6 +2624,52 @@ func (_c *MockHandler_GetChannelByID_Call) RunAndReturn(run func(responseWriter
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetNotificationChannel provides a mock function for the type MockHandler
|
||||
func (_mock *MockHandler) GetNotificationChannel(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
_mock.Called(responseWriter, request)
|
||||
return
|
||||
}
|
||||
|
||||
// MockHandler_GetNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetNotificationChannel'
|
||||
type MockHandler_GetNotificationChannel_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// GetNotificationChannel is a helper method to define mock.On call
|
||||
// - responseWriter http.ResponseWriter
|
||||
// - request *http.Request
|
||||
func (_e *MockHandler_Expecter) GetNotificationChannel(responseWriter interface{}, request interface{}) *MockHandler_GetNotificationChannel_Call {
|
||||
return &MockHandler_GetNotificationChannel_Call{Call: _e.mock.On("GetNotificationChannel", responseWriter, request)}
|
||||
}
|
||||
|
||||
func (_c *MockHandler_GetNotificationChannel_Call) Run(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_GetNotificationChannel_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 http.ResponseWriter
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(http.ResponseWriter)
|
||||
}
|
||||
var arg1 *http.Request
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*http.Request)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockHandler_GetNotificationChannel_Call) Return() *MockHandler_GetNotificationChannel_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockHandler_GetNotificationChannel_Call) RunAndReturn(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_GetNotificationChannel_Call {
|
||||
_c.Run(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// GetRoutePolicyByID provides a mock function for the type MockHandler
|
||||
func (_mock *MockHandler) GetRoutePolicyByID(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
_mock.Called(responseWriter, request)
|
||||
@@ -2499,6 +2808,98 @@ func (_c *MockHandler_ListChannels_Call) RunAndReturn(run func(responseWriter ht
|
||||
return _c
|
||||
}
|
||||
|
||||
// ListNotificationChannels provides a mock function for the type MockHandler
|
||||
func (_mock *MockHandler) ListNotificationChannels(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
_mock.Called(responseWriter, request)
|
||||
return
|
||||
}
|
||||
|
||||
// MockHandler_ListNotificationChannels_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNotificationChannels'
|
||||
type MockHandler_ListNotificationChannels_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// ListNotificationChannels is a helper method to define mock.On call
|
||||
// - responseWriter http.ResponseWriter
|
||||
// - request *http.Request
|
||||
func (_e *MockHandler_Expecter) ListNotificationChannels(responseWriter interface{}, request interface{}) *MockHandler_ListNotificationChannels_Call {
|
||||
return &MockHandler_ListNotificationChannels_Call{Call: _e.mock.On("ListNotificationChannels", responseWriter, request)}
|
||||
}
|
||||
|
||||
func (_c *MockHandler_ListNotificationChannels_Call) Run(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_ListNotificationChannels_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 http.ResponseWriter
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(http.ResponseWriter)
|
||||
}
|
||||
var arg1 *http.Request
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*http.Request)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockHandler_ListNotificationChannels_Call) Return() *MockHandler_ListNotificationChannels_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockHandler_ListNotificationChannels_Call) RunAndReturn(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_ListNotificationChannels_Call {
|
||||
_c.Run(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// TestNotificationChannel provides a mock function for the type MockHandler
|
||||
func (_mock *MockHandler) TestNotificationChannel(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
_mock.Called(responseWriter, request)
|
||||
return
|
||||
}
|
||||
|
||||
// MockHandler_TestNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TestNotificationChannel'
|
||||
type MockHandler_TestNotificationChannel_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// TestNotificationChannel is a helper method to define mock.On call
|
||||
// - responseWriter http.ResponseWriter
|
||||
// - request *http.Request
|
||||
func (_e *MockHandler_Expecter) TestNotificationChannel(responseWriter interface{}, request interface{}) *MockHandler_TestNotificationChannel_Call {
|
||||
return &MockHandler_TestNotificationChannel_Call{Call: _e.mock.On("TestNotificationChannel", responseWriter, request)}
|
||||
}
|
||||
|
||||
func (_c *MockHandler_TestNotificationChannel_Call) Run(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_TestNotificationChannel_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 http.ResponseWriter
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(http.ResponseWriter)
|
||||
}
|
||||
var arg1 *http.Request
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*http.Request)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockHandler_TestNotificationChannel_Call) Return() *MockHandler_TestNotificationChannel_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockHandler_TestNotificationChannel_Call) RunAndReturn(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_TestNotificationChannel_Call {
|
||||
_c.Run(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// TestReceiver provides a mock function for the type MockHandler
|
||||
func (_mock *MockHandler) TestReceiver(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
_mock.Called(responseWriter, request)
|
||||
@@ -2591,6 +2992,52 @@ func (_c *MockHandler_UpdateChannelByID_Call) RunAndReturn(run func(responseWrit
|
||||
return _c
|
||||
}
|
||||
|
||||
// UpdateNotificationChannel provides a mock function for the type MockHandler
|
||||
func (_mock *MockHandler) UpdateNotificationChannel(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
_mock.Called(responseWriter, request)
|
||||
return
|
||||
}
|
||||
|
||||
// MockHandler_UpdateNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateNotificationChannel'
|
||||
type MockHandler_UpdateNotificationChannel_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// UpdateNotificationChannel is a helper method to define mock.On call
|
||||
// - responseWriter http.ResponseWriter
|
||||
// - request *http.Request
|
||||
func (_e *MockHandler_Expecter) UpdateNotificationChannel(responseWriter interface{}, request interface{}) *MockHandler_UpdateNotificationChannel_Call {
|
||||
return &MockHandler_UpdateNotificationChannel_Call{Call: _e.mock.On("UpdateNotificationChannel", responseWriter, request)}
|
||||
}
|
||||
|
||||
func (_c *MockHandler_UpdateNotificationChannel_Call) Run(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_UpdateNotificationChannel_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 http.ResponseWriter
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(http.ResponseWriter)
|
||||
}
|
||||
var arg1 *http.Request
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*http.Request)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockHandler_UpdateNotificationChannel_Call) Return() *MockHandler_UpdateNotificationChannel_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockHandler_UpdateNotificationChannel_Call) RunAndReturn(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_UpdateNotificationChannel_Call {
|
||||
_c.Run(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// UpdateRoutePolicy provides a mock function for the type MockHandler
|
||||
func (_mock *MockHandler) UpdateRoutePolicy(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
_mock.Called(responseWriter, request)
|
||||
|
||||
@@ -19,8 +19,18 @@ type Handler interface {
|
||||
|
||||
DeleteChannelByID(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListNotificationChannels(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetNotificationChannel(http.ResponseWriter, *http.Request)
|
||||
|
||||
CreateNotificationChannel(http.ResponseWriter, *http.Request)
|
||||
|
||||
UpdateNotificationChannel(http.ResponseWriter, *http.Request)
|
||||
|
||||
DeleteNotificationChannel(http.ResponseWriter, *http.Request)
|
||||
|
||||
TestNotificationChannel(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetAllRoutePolicies(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetRoutePolicyByID(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -240,7 +240,7 @@ func (service *Service) getConfig(ctx context.Context, orgID string) (*alertmana
|
||||
}
|
||||
|
||||
func (service *Service) compareAndSelectConfig(ctx context.Context, incomingConfig *alertmanagertypes.Config) (*alertmanagertypes.Config, error) {
|
||||
channels, err := service.configStore.ListChannels(ctx, incomingConfig.StoreableConfig().OrgID)
|
||||
channels, _, err := service.configStore.ListChannels(ctx, incomingConfig.StoreableConfig().OrgID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (handler *handler) CreateNotificationChannel(rw http.ResponseWriter, req *http.Request) {
|
||||
@@ -21,8 +24,8 @@ func (handler *handler) CreateNotificationChannel(rw http.ResponseWriter, req *h
|
||||
return
|
||||
}
|
||||
|
||||
postable := new(alertmanagertypes.PostableNotificationChannel)
|
||||
if err := binding.JSON.BindBody(req.Body, postable); err != nil {
|
||||
postable := alertmanagertypes.PostableNotificationChannel{}
|
||||
if err := binding.JSON.BindBody(req.Body, &postable); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
@@ -41,3 +44,150 @@ func (handler *handler) CreateNotificationChannel(rw http.ResponseWriter, req *h
|
||||
|
||||
render.Success(rw, http.StatusCreated, gettable)
|
||||
}
|
||||
|
||||
func (handler *handler) ListNotificationChannels(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
params := new(alertmanagertypes.ListChannelsParams)
|
||||
if err := binding.Query.BindQuery(req.URL.Query(), params); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := params.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// todo(@namanv3): use valuer.MustNewUUID(claims.OrgID) from here till the store method.
|
||||
listed, err := handler.alertmanager.ListNotificationChannels(ctx, claims.OrgID, params)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, listed)
|
||||
}
|
||||
|
||||
func (handler *handler) GetNotificationChannel(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
return
|
||||
}
|
||||
|
||||
channel, err := handler.alertmanager.GetChannelByID(ctx, claims.OrgID, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
gettable, err := channel.ToGettableNotificationChannel()
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, gettable)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateNotificationChannel(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
return
|
||||
}
|
||||
|
||||
updatable := alertmanagertypes.UpdatableNotificationChannel{}
|
||||
if err := binding.JSON.BindBody(req.Body, &updatable); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
channel, err := handler.alertmanager.UpdateNotificationChannel(ctx, claims.OrgID, id, updatable)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
gettable, err := channel.ToGettableNotificationChannel()
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, gettable)
|
||||
}
|
||||
|
||||
func (handler *handler) DeleteNotificationChannel(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := handler.alertmanager.DeleteChannelByID(ctx, claims.OrgID, id); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) TestNotificationChannel(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
testable := alertmanagertypes.TestableNotificationChannel{}
|
||||
if err := binding.JSON.BindBody(req.Body, &testable); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := handler.alertmanager.TestNotificationChannel(ctx, claims.OrgID, testable); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
@@ -141,7 +141,23 @@ func (provider *provider) TestAlert(ctx context.Context, orgID string, ruleID st
|
||||
}
|
||||
|
||||
func (provider *provider) ListChannels(ctx context.Context, orgID string) ([]*alertmanagertypes.Channel, error) {
|
||||
return provider.configStore.ListChannels(ctx, orgID)
|
||||
channels, _, err := provider.configStore.ListChannels(ctx, orgID, nil)
|
||||
|
||||
return channels, err
|
||||
}
|
||||
|
||||
func (provider *provider) ListNotificationChannels(ctx context.Context, orgID string, params *alertmanagertypes.ListChannelsParams) (*alertmanagertypes.ListableNotificationChannel, error) {
|
||||
channels, total, err := provider.configStore.ListChannels(ctx, orgID, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listed := make([]*alertmanagertypes.ListedNotificationChannel, 0, len(channels))
|
||||
for _, channel := range channels {
|
||||
listed = append(listed, channel.ToListedNotificationChannel())
|
||||
}
|
||||
|
||||
return &alertmanagertypes.ListableNotificationChannel{Channels: listed, Total: total}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) ListAllChannels(ctx context.Context) ([]*alertmanagertypes.Channel, error) {
|
||||
@@ -244,7 +260,7 @@ func (provider *provider) CreateChannel(ctx context.Context, orgID string, recei
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (provider *provider) CreateNotificationChannel(ctx context.Context, orgID string, postable *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error) {
|
||||
func (provider *provider) CreateNotificationChannel(ctx context.Context, orgID string, postable alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error) {
|
||||
receiver, err := postable.ToReceiver()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -278,6 +294,55 @@ func (provider *provider) CreateNotificationChannel(ctx context.Context, orgID s
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
// UpdateNotificationChannel replaces the channel's configuration. The display
|
||||
// name is not in the updatable body because it cannot change, so it is read off
|
||||
// the stored channel and fed back into the receiver.
|
||||
func (provider *provider) UpdateNotificationChannel(ctx context.Context, orgID string, id valuer.UUID, updatable alertmanagertypes.UpdatableNotificationChannel) (*alertmanagertypes.Channel, error) {
|
||||
channel, err := provider.configStore.GetChannelByID(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
receiver, err := updatable.ToReceiver(channel.DisplayName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := channel.Update(receiver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := provider.configStore.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := config.SetGlobalConfig(provider.config.Signoz.Global); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := config.UpdateReceiver(receiver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := provider.configStore.UpdateChannel(ctx, orgID, channel, alertmanagertypes.WithCb(func(ctx context.Context) error {
|
||||
return provider.configStore.Set(ctx, config)
|
||||
})); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (provider *provider) TestNotificationChannel(ctx context.Context, orgID string, testable alertmanagertypes.TestableNotificationChannel) error {
|
||||
receiver, err := testable.ToReceiver()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return provider.service.TestReceiver(ctx, orgID, receiver)
|
||||
}
|
||||
|
||||
func (provider *provider) Config() alertmanagerserver.Config {
|
||||
return provider.config.Signoz.Config
|
||||
}
|
||||
@@ -300,7 +365,7 @@ func (provider *provider) SetDefaultConfig(ctx context.Context, orgID string) er
|
||||
}
|
||||
|
||||
func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
channels, err := provider.configStore.ListChannels(ctx, orgID.String())
|
||||
channels, _, err := provider.configStore.ListChannels(ctx, orgID.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -158,6 +158,140 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/notification_channels", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.ListNotificationChannels, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListNotificationChannels",
|
||||
Tags: []string{"channels"},
|
||||
Summary: "List notification channels",
|
||||
Description: "Returns a page of notification channels for the org. Each entry carries the channel's identity and kind but not its configuration; fetch a channel by ID for that. Supports a case-insensitive display name search (`query`), a kind filter (`kind`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).",
|
||||
Request: nil,
|
||||
RequestQuery: new(alertmanagertypes.ListChannelsParams),
|
||||
RequestContentType: "",
|
||||
Response: new(alertmanagertypes.ListableNotificationChannel),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/notification_channels/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.GetNotificationChannel, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetNotificationChannel",
|
||||
Tags: []string{"channels"},
|
||||
Summary: "Get notification channel by ID",
|
||||
Description: "This endpoint returns a notification channel by ID. A channel written by the v1 API can carry a configuration this API does not model.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(alertmanagertypes.GettableNotificationChannel),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/notification_channels/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.UpdateNotificationChannel, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateNotificationChannel",
|
||||
Tags: []string{"channels"},
|
||||
Summary: "Update notification channel",
|
||||
Description: "This endpoint replaces a notification channel's configuration in full. Neither name is part of the request body: both are immutable. The kind may change, which replaces the channel's notifier configuration.",
|
||||
Request: new(alertmanagertypes.UpdatableNotificationChannel),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.GettableNotificationChannel),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/notification_channels/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.DeleteNotificationChannel, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteNotificationChannel",
|
||||
Tags: []string{"channels"},
|
||||
Summary: "Delete notification channel",
|
||||
Description: "This endpoint deletes a notification channel by ID",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/notification_channels/test", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.TestNotificationChannel, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "TestNotificationChannel",
|
||||
Tags: []string{"channels"},
|
||||
Summary: "Test notification channel",
|
||||
Description: "This endpoint sends a test notification for the configuration in the request body. The channel need not exist and nothing is persisted, so the body carries a configuration only.",
|
||||
Request: new(alertmanagertypes.TestableNotificationChannel),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/route_policies", handler.New(provider.authzMiddleware.ViewAccess(provider.alertmanagerHandler.GetAllRoutePolicies), handler.OpenAPIDef{
|
||||
ID: "GetAllRoutePolicies",
|
||||
Tags: []string{"routepolicies"},
|
||||
|
||||
@@ -12,25 +12,32 @@ import (
|
||||
|
||||
// PrepareParamsForTracesV5 returns the traces explorer query params for the
|
||||
// given range and filter; the traces explorer writes its time params in
|
||||
// nanoseconds.
|
||||
func PrepareParamsForTracesV5(start, end time.Time, whereClause string) url.Values {
|
||||
return prepareExplorerParams("traces", start.UnixNano(), end.UnixNano(), whereClause)
|
||||
// nanoseconds. queryType is builder_ai_query for the AI observability explorer.
|
||||
func PrepareParamsForTracesV5(start, end time.Time, whereClause string, queryType qbtypes.QueryType) url.Values {
|
||||
return prepareExplorerParams("traces", queryType, start.UnixNano(), end.UnixNano(), whereClause)
|
||||
}
|
||||
|
||||
// PrepareParamsForLogsV5 returns the logs explorer query params for the given
|
||||
// range and filter; the logs explorer writes its time params in milliseconds.
|
||||
func PrepareParamsForLogsV5(start, end time.Time, whereClause string) url.Values {
|
||||
return prepareExplorerParams("logs", start.UnixMilli(), end.UnixMilli(), whereClause)
|
||||
return prepareExplorerParams("logs", qbtypes.QueryTypeBuilder, start.UnixMilli(), end.UnixMilli(), whereClause)
|
||||
}
|
||||
|
||||
// The end link is double encoded because otherwise a filter expression with `%` somewhere in it breaks.
|
||||
func prepareExplorerParams(dataSource string, start, end int64, whereClause string) url.Values {
|
||||
func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start, end int64, whereClause string) url.Values {
|
||||
// builder_query is the explorer default, so it is left out to keep existing links unchanged
|
||||
builderQueryType := ""
|
||||
if queryType != qbtypes.QueryTypeBuilder {
|
||||
builderQueryType = queryType.StringValue()
|
||||
}
|
||||
|
||||
urlData := URLShareableCompositeQuery{
|
||||
QueryType: "builder",
|
||||
Builder: URLShareableBuilderQuery{
|
||||
QueryData: []LinkQuery{{
|
||||
DataSource: dataSource,
|
||||
Filter: &FilterExpression{Expression: whereClause},
|
||||
DataSource: dataSource,
|
||||
BuilderQueryType: builderQueryType,
|
||||
Filter: &FilterExpression{Expression: whereClause},
|
||||
}},
|
||||
QueryFormulas: make([]string, 0),
|
||||
},
|
||||
@@ -47,7 +54,8 @@ func prepareExplorerParams(dataSource string, start, end int64, whereClause stri
|
||||
|
||||
// BuilderQueryForSignal returns the filter expression and group-by keys of the
|
||||
// builder query for the given signal, or found=false when the composite query
|
||||
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts).
|
||||
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts). AI trace
|
||||
// queries (builder_ai_query) count as trace builder queries.
|
||||
// TODO(srikanthccv): re-visit this and support multiple queries.
|
||||
func BuilderQueryForSignal(queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
|
||||
switch signal {
|
||||
@@ -63,7 +71,7 @@ func builderQueryForSignal[T any](queries []qbtypes.QueryEnvelope, signal teleme
|
||||
var q qbtypes.QueryBuilderQuery[T]
|
||||
found := false
|
||||
for _, query := range queries {
|
||||
if query.Type != qbtypes.QueryTypeBuilder {
|
||||
if query.Type != qbtypes.QueryTypeBuilder && query.Type != qbtypes.QueryTypeBuilderAI {
|
||||
continue
|
||||
}
|
||||
if spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T]); ok {
|
||||
|
||||
@@ -30,6 +30,15 @@ func TestBuilderQueryForSignal(t *testing.T) {
|
||||
Type: qbtypes.QueryTypePromQL,
|
||||
Spec: qbtypes.PromQuery{Name: "C"},
|
||||
}
|
||||
aiTraceQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeBuilderAI,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "D",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: "trace.input_tokens > 1000"},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "session.id"}}},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("logs query among mixed queries", func(t *testing.T) {
|
||||
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}, telemetrytypes.SignalLogs)
|
||||
@@ -46,6 +55,14 @@ func TestBuilderQueryForSignal(t *testing.T) {
|
||||
assert.Empty(t, groupBy)
|
||||
})
|
||||
|
||||
t.Run("ai trace query counts as traces", func(t *testing.T) {
|
||||
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, aiTraceQuery}, telemetrytypes.SignalTraces)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, "trace.input_tokens > 1000", filterExpr)
|
||||
require.Len(t, groupBy, 1)
|
||||
assert.Equal(t, "session.id", groupBy[0].Name)
|
||||
})
|
||||
|
||||
t.Run("no builder query for signal", func(t *testing.T) {
|
||||
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{traceQuery}, telemetrytypes.SignalLogs)
|
||||
assert.False(t, found)
|
||||
|
||||
@@ -13,8 +13,9 @@ type FilterExpression struct {
|
||||
// LinkQuery carries the only fields the explorer pages read from a shared
|
||||
// link; the frontend fills in the rest of the query shape with defaults.
|
||||
type LinkQuery struct {
|
||||
DataSource string `json:"dataSource"`
|
||||
Filter *FilterExpression `json:"filter,omitempty"`
|
||||
DataSource string `json:"dataSource"`
|
||||
BuilderQueryType string `json:"builderQueryType,omitempty"`
|
||||
Filter *FilterExpression `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
type URLShareableBuilderQuery struct {
|
||||
|
||||
@@ -114,6 +114,7 @@ func (h *handler) GetRuleHistoryTimeline(w http.ResponseWriter, r *http.Request)
|
||||
Fingerprint: item.Fingerprint,
|
||||
Value: item.Value,
|
||||
RelatedTracesLink: item.RelatedTracesLink,
|
||||
RelatedAITracesLink: item.RelatedAITracesLink,
|
||||
RelatedLogsLink: item.RelatedLogsLink,
|
||||
})
|
||||
}
|
||||
@@ -151,11 +152,12 @@ func (h *handler) GetRuleHistoryContributors(w http.ResponseWriter, r *http.Requ
|
||||
converted := make([]rulestatehistorytypes.GettableRuleStateHistoryContributor, 0, len(res))
|
||||
for _, item := range res {
|
||||
converted = append(converted, rulestatehistorytypes.GettableRuleStateHistoryContributor{
|
||||
Fingerprint: item.Fingerprint,
|
||||
Labels: item.Labels.ToQBLabels(),
|
||||
Count: item.Count,
|
||||
RelatedTracesLink: item.RelatedTracesLink,
|
||||
RelatedLogsLink: item.RelatedLogsLink,
|
||||
Fingerprint: item.Fingerprint,
|
||||
Labels: item.Labels.ToQBLabels(),
|
||||
Count: item.Count,
|
||||
RelatedTracesLink: item.RelatedTracesLink,
|
||||
RelatedAITracesLink: item.RelatedAITracesLink,
|
||||
RelatedLogsLink: item.RelatedLogsLink,
|
||||
})
|
||||
}
|
||||
render.Success(w, http.StatusOK, converted)
|
||||
|
||||
@@ -41,7 +41,8 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
|
||||
return nil
|
||||
}
|
||||
|
||||
if rule.AlertType != ruletypes.AlertTypeLogs && rule.AlertType != ruletypes.AlertTypeTraces {
|
||||
signal, ok := relatedLinkSignal(rule.AlertType)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if rule.RuleCondition == nil || rule.RuleCondition.CompositeQuery == nil {
|
||||
@@ -62,10 +63,6 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
|
||||
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
|
||||
}
|
||||
|
||||
signal := telemetrytypes.SignalLogs
|
||||
if rule.AlertType == ruletypes.AlertTypeTraces {
|
||||
signal = telemetrytypes.SignalTraces
|
||||
}
|
||||
// links are still built from the labels alone when the rule has no builder
|
||||
// query for the signal (e.g. ClickHouse SQL alerts)
|
||||
builder.filterExpr, builder.groupBy, _ = contextlinks.BuilderQueryForSignal(rule.RuleCondition.CompositeQuery.Queries, signal)
|
||||
@@ -84,21 +81,35 @@ func (b *relatedLinkBuilder) queryWindow(unixMilli int64) (time.Time, time.Time)
|
||||
return start.Add(-3 * time.Minute), end
|
||||
}
|
||||
|
||||
// links returns the encoded logs and traces explorer query params for the
|
||||
// given entry labels and time range; at most one of the two is non-empty.
|
||||
func (b *relatedLinkBuilder) links(labels rulestatehistorytypes.LabelsString, start, end time.Time) (string, string) {
|
||||
// links returns the explorer query params for the given entry labels and time
|
||||
// range.
|
||||
func (b *relatedLinkBuilder) links(labels rulestatehistorytypes.LabelsString, start, end time.Time) rulestatehistorytypes.RelatedLinks {
|
||||
lbls := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(labels), &lbls); err != nil {
|
||||
return "", ""
|
||||
return rulestatehistorytypes.RelatedLinks{}
|
||||
}
|
||||
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls, b.filterExpr, b.groupBy)
|
||||
|
||||
switch b.alertType {
|
||||
case ruletypes.AlertTypeLogs:
|
||||
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode(), ""
|
||||
return rulestatehistorytypes.RelatedLinks{RelatedLogsLink: contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode()}
|
||||
case ruletypes.AlertTypeTraces:
|
||||
return "", contextlinks.PrepareParamsForTracesV5(start, end, whereClause).Encode()
|
||||
return rulestatehistorytypes.RelatedLinks{RelatedTracesLink: contextlinks.PrepareParamsForTracesV5(start, end, whereClause, qbtypes.QueryTypeBuilder).Encode()}
|
||||
case ruletypes.AlertTypeAITraces:
|
||||
return rulestatehistorytypes.RelatedLinks{RelatedAITracesLink: contextlinks.PrepareParamsForTracesV5(start, end, whereClause, qbtypes.QueryTypeBuilderAI).Encode()}
|
||||
}
|
||||
return "", ""
|
||||
return rulestatehistorytypes.RelatedLinks{}
|
||||
}
|
||||
|
||||
// relatedLinkSignal returns the explorer signal that related links open for
|
||||
// the alert type, or ok=false when the alert type has none (e.g. metrics).
|
||||
func relatedLinkSignal(alertType ruletypes.AlertType) (telemetrytypes.Signal, bool) {
|
||||
switch alertType {
|
||||
case ruletypes.AlertTypeLogs:
|
||||
return telemetrytypes.SignalLogs, true
|
||||
case ruletypes.AlertTypeTraces, ruletypes.AlertTypeAITraces:
|
||||
return telemetrytypes.SignalTraces, true
|
||||
}
|
||||
return telemetrytypes.SignalUnspecified, false
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func (m *module) GetHistoryTimeline(ctx context.Context, orgID valuer.UUID, rule
|
||||
if builder := m.relatedLinkBuilderForRule(ctx, orgID, ruleID); builder != nil {
|
||||
for idx := range items {
|
||||
start, end := builder.queryWindow(items[idx].UnixMilli)
|
||||
items[idx].RelatedLogsLink, items[idx].RelatedTracesLink = builder.links(items[idx].Labels, start, end)
|
||||
items[idx].RelatedLinks = builder.links(items[idx].Labels, start, end)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func (m *module) GetHistoryContributors(ctx context.Context, orgID valuer.UUID,
|
||||
// span it too instead of a single evaluation window
|
||||
start, end := time.UnixMilli(query.Start), time.UnixMilli(query.End)
|
||||
for idx := range contributors {
|
||||
contributors[idx].RelatedLogsLink, contributors[idx].RelatedTracesLink = builder.links(contributors[idx].Labels, start, end)
|
||||
contributors[idx].RelatedLinks = builder.links(contributors[idx].Labels, start, end)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -451,7 +451,7 @@ func (bc *bucketCache) mergeBuckets(ctx context.Context, buckets []*qbtypes.Cach
|
||||
// Merge values based on type
|
||||
var mergedValue any
|
||||
switch resultType {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
mergedValue = bc.mergeTimeSeriesValues(ctx, buckets)
|
||||
// Raw and Scalar types are not cached, so no merge needed
|
||||
}
|
||||
@@ -476,14 +476,36 @@ func (bc *bucketCache) mergeTimeSeriesValues(ctx context.Context, buckets []*qbt
|
||||
}
|
||||
seriesMap := make(map[seriesKey]*qbtypes.TimeSeries, estimatedSeries)
|
||||
|
||||
decodedTimeSeriesData := make([]*qbtypes.TimeSeriesData, 0, len(buckets))
|
||||
|
||||
// Alias and Meta are taken from whichever cached bucket covers the latest
|
||||
// range, and the buckets do not arrive in StartMs order, so keep the winner
|
||||
// per AggregationBucket.Index alongside the StartMs that won it.
|
||||
aggregationIndexToLatest := map[int]*qbtypes.AggregationBucket{}
|
||||
aggregationIndexToLatestStartMs := map[int]uint64{}
|
||||
|
||||
for _, bucket := range buckets {
|
||||
var tsData *qbtypes.TimeSeriesData
|
||||
if err := json.Unmarshal(bucket.Value, &tsData); err != nil {
|
||||
bc.logger.ErrorContext(ctx, "failed to unmarshal time series data", errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
decodedTimeSeriesData = append(decodedTimeSeriesData, tsData)
|
||||
|
||||
for _, aggBucket := range tsData.Aggregations {
|
||||
if _, seen := aggregationIndexToLatest[aggBucket.Index]; !seen || bucket.StartMs >= aggregationIndexToLatestStartMs[aggBucket.Index] {
|
||||
aggregationIndexToLatest[aggBucket.Index] = aggBucket
|
||||
aggregationIndexToLatestStartMs[aggBucket.Index] = bucket.StartMs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mergedUpperBounds := qbtypes.MergeBucketUpperBounds(decodedTimeSeriesData...)
|
||||
|
||||
for _, tsData := range decodedTimeSeriesData {
|
||||
for _, aggBucket := range tsData.Aggregations {
|
||||
aggBucket.ReindexValuesToNewUpperBounds(mergedUpperBounds[aggBucket.Index])
|
||||
|
||||
for _, series := range aggBucket.Series {
|
||||
// Create series key from labels
|
||||
key := seriesKey{
|
||||
@@ -556,10 +578,15 @@ func (bc *bucketCache) mergeTimeSeriesValues(ctx context.Context, buckets []*qbt
|
||||
}
|
||||
}
|
||||
|
||||
result.Aggregations = append(result.Aggregations, &qbtypes.AggregationBucket{
|
||||
aggBucket := &qbtypes.AggregationBucket{
|
||||
Index: index,
|
||||
Series: seriesList,
|
||||
})
|
||||
}
|
||||
if latest, ok := aggregationIndexToLatest[index]; ok {
|
||||
aggBucket.Alias = latest.Alias
|
||||
aggBucket.Meta = latest.Meta
|
||||
}
|
||||
result.Aggregations = append(result.Aggregations, aggBucket)
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -572,7 +599,7 @@ func (bc *bucketCache) isEmptyResult(result *qbtypes.Result) (isEmpty bool, isFi
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
// No aggregations at all means truly empty
|
||||
if len(tsData.Aggregations) == 0 {
|
||||
@@ -699,14 +726,19 @@ func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, fluxBoun
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
// Trim time series data
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok && tsData != nil {
|
||||
trimmedData := &qbtypes.TimeSeriesData{}
|
||||
|
||||
for _, aggBucket := range tsData.Aggregations {
|
||||
// Meta has to survive the trim: a heatmap's counts are
|
||||
// positional against Meta.Buckets, so a cached bucket that
|
||||
// lost its axis cannot be read back against anything.
|
||||
trimmedBucket := &qbtypes.AggregationBucket{
|
||||
Index: aggBucket.Index,
|
||||
Alias: aggBucket.Alias,
|
||||
Meta: aggBucket.Meta,
|
||||
}
|
||||
|
||||
for _, series := range aggBucket.Series {
|
||||
@@ -766,7 +798,7 @@ func (bc *bucketCache) filterResultToTimeRange(result *qbtypes.Result, startMs,
|
||||
}
|
||||
|
||||
switch result.Type {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
filteredData := &qbtypes.TimeSeriesData{
|
||||
Aggregations: make([]*qbtypes.AggregationBucket, 0, len(tsData.Aggregations)),
|
||||
|
||||
@@ -92,6 +92,10 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
// This needs to include all fields that affect the query results
|
||||
parts := []string{q.queryType.StringValue()}
|
||||
|
||||
// A heatmap and a time series query can share every spec field and still
|
||||
// return different rows, so the request type has to separate their entries
|
||||
parts = append(parts, fmt.Sprintf("requestType=%s", q.kind.StringValue()))
|
||||
|
||||
// Add signal type
|
||||
parts = append(parts, fmt.Sprintf("signal=%s", q.spec.Signal.StringValue()))
|
||||
|
||||
@@ -130,6 +134,9 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
}
|
||||
part += ":" + route
|
||||
}
|
||||
if a.HeatmapBucketing != nil {
|
||||
part += ":" + fingerprintHeatmapBucketing(*a.HeatmapBucketing)
|
||||
}
|
||||
aggParts = append(aggParts, part)
|
||||
}
|
||||
}
|
||||
@@ -185,6 +192,16 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
// fingerprintHeatmapBucketing captures only what changes the rows ClickHouse
|
||||
// returns, which is why LogBucketsSpec.Scale is absent: coarsening it happens in
|
||||
// postprocessing, so every scale reads one cache entry.
|
||||
func fingerprintHeatmapBucketing(b qbtypes.HeatmapBucketing) string {
|
||||
if b.Kind == qbtypes.BucketsKindLinear {
|
||||
return fmt.Sprintf("%s:%v:%d", b.Kind.StringValue(), b.MaxValue, b.NumBuckets)
|
||||
}
|
||||
return b.Kind.StringValue()
|
||||
}
|
||||
|
||||
func fingerprintGroupByKey(gb qbtypes.GroupByKey) string {
|
||||
return fingerprintFieldKey(gb.TelemetryFieldKey)
|
||||
}
|
||||
@@ -412,7 +429,7 @@ func (q *builderQuery[T]) narrowWindowByTraceID(ctx context.Context, fromMS, toM
|
||||
func emptyResultFor(kind qbtypes.RequestType, queryName string) *qbtypes.Result {
|
||||
var value any
|
||||
switch kind {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
value = &qbtypes.TimeSeriesData{QueryName: queryName}
|
||||
case qbtypes.RequestTypeScalar:
|
||||
value = &qbtypes.ScalarData{QueryName: queryName}
|
||||
@@ -465,8 +482,9 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
|
||||
queryWindow := &qbtypes.TimeRange{From: q.fromMS, To: q.toMS}
|
||||
|
||||
kind := q.kind
|
||||
// all metric queries are time series then reduced if required
|
||||
if q.spec.Signal == telemetrytypes.SignalMetrics {
|
||||
// all metric queries are time series then reduced if required, except
|
||||
// heatmaps, whose statement returns a row per bucket rather than per point
|
||||
if q.spec.Signal == telemetrytypes.SignalMetrics && kind != qbtypes.RequestTypeHeatmap {
|
||||
kind = qbtypes.RequestTypeTimeSeries
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -120,6 +121,162 @@ func TestBuilderQueryFingerprintQueryType(t *testing.T) {
|
||||
assert.Empty(t, ai.Fingerprint())
|
||||
}
|
||||
|
||||
func TestBuilderQueryFingerprintHeatmapBucketing(t *testing.T) {
|
||||
coarseLogScale := 1
|
||||
|
||||
testCases := []struct {
|
||||
description string
|
||||
left *builderQuery[qbtypes.MetricAggregation]
|
||||
right *builderQuery[qbtypes.MetricAggregation]
|
||||
expectedEqual bool
|
||||
}{
|
||||
{
|
||||
// fingerprintHeatmapBucketing leaves LogScale out, so the two are
|
||||
// indistinguishable here by design
|
||||
description: "a coarser logScale reads the same cache entry",
|
||||
left: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLog, LogScale: qbtypes.MaxLogScale, NumBuckets: qbtypes.DefaultNumBuckets},
|
||||
}},
|
||||
},
|
||||
},
|
||||
right: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLog, LogScale: coarseLogScale, NumBuckets: qbtypes.DefaultNumBuckets},
|
||||
}},
|
||||
},
|
||||
},
|
||||
expectedEqual: true,
|
||||
},
|
||||
{
|
||||
description: "linear separates on maxValue",
|
||||
left: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLinear, MaxValue: 500, NumBuckets: 25},
|
||||
}},
|
||||
},
|
||||
},
|
||||
right: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLinear, MaxValue: 800, NumBuckets: 25},
|
||||
}},
|
||||
},
|
||||
},
|
||||
expectedEqual: false,
|
||||
},
|
||||
{
|
||||
description: "linear separates on numBuckets",
|
||||
left: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLinear, MaxValue: 500, NumBuckets: 25},
|
||||
}},
|
||||
},
|
||||
},
|
||||
right: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLinear, MaxValue: 500, NumBuckets: 40},
|
||||
}},
|
||||
},
|
||||
},
|
||||
expectedEqual: false,
|
||||
},
|
||||
{
|
||||
description: "linear and log are separate entries",
|
||||
left: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLinear, MaxValue: 500, NumBuckets: 25},
|
||||
}},
|
||||
},
|
||||
},
|
||||
right: &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "system.memory.usage",
|
||||
Type: metrictypes.GaugeType,
|
||||
HeatmapBucketing: &qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLog, LogScale: qbtypes.MaxLogScale, NumBuckets: qbtypes.DefaultNumBuckets},
|
||||
}},
|
||||
},
|
||||
},
|
||||
expectedEqual: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
if testCase.expectedEqual {
|
||||
assert.Equal(t, testCase.left.Fingerprint(), testCase.right.Fingerprint())
|
||||
return
|
||||
}
|
||||
assert.NotEqual(t, testCase.left.Fingerprint(), testCase.right.Fingerprint())
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a histogram folds in no bucket options at all", func(t *testing.T) {
|
||||
// resolveHeatmapBucketing leaves histograms nil, so bucketOptions sent
|
||||
// alongside one must not fragment its cache
|
||||
histogram := &builderQuery[qbtypes.MetricAggregation]{
|
||||
queryType: qbtypes.QueryTypeBuilder,
|
||||
kind: qbtypes.RequestTypeHeatmap,
|
||||
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
Aggregations: []qbtypes.MetricAggregation{{
|
||||
MetricName: "signoz_latency",
|
||||
Type: metrictypes.HistogramType,
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
fingerprint := histogram.Fingerprint()
|
||||
assert.NotContains(t, fingerprint, qbtypes.BucketsKindLog.StringValue())
|
||||
assert.NotContains(t, fingerprint, qbtypes.BucketsKindLinear.StringValue())
|
||||
})
|
||||
}
|
||||
|
||||
func TestMakeBucketsOrder(t *testing.T) {
|
||||
// Test that makeBuckets returns buckets in reverse chronological order by default
|
||||
// Using milliseconds as input - need > 1 hour range to get multiple buckets
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
@@ -83,6 +84,8 @@ func consume(rows driver.Rows, kind qbtypes.RequestType, queryWindow *qbtypes.Ti
|
||||
payload, err = readAsTimeSeries(rows, queryWindow, step, queryName)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
payload, err = readAsScalar(rows, queryName)
|
||||
case qbtypes.RequestTypeHeatmap:
|
||||
payload, err = readAsHeatmap(rows, queryWindow, step, queryName)
|
||||
case qbtypes.RequestTypeRaw, qbtypes.RequestTypeTrace, qbtypes.RequestTypeRawStream:
|
||||
payload, err = readAsRaw(rows, queryName)
|
||||
// TODO: add support for other request types
|
||||
@@ -112,35 +115,6 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
|
||||
|
||||
stepMs := uint64(step.Milliseconds())
|
||||
|
||||
// Helper function to check if a timestamp represents a partial value
|
||||
isPartialValue := func(timestamp int64) bool {
|
||||
if stepMs == 0 || queryWindow == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
timestampMs := uint64(timestamp)
|
||||
|
||||
// For the first interval, check if query start is misaligned
|
||||
// The first complete interval starts at the first timestamp >= queryWindow.From that is aligned to step
|
||||
firstCompleteInterval := queryWindow.From
|
||||
if queryWindow.From%stepMs != 0 {
|
||||
// Round up to next step boundary
|
||||
firstCompleteInterval = ((queryWindow.From / stepMs) + 1) * stepMs
|
||||
}
|
||||
|
||||
// If timestamp is before the first complete interval, it's partial
|
||||
if timestampMs < firstCompleteInterval {
|
||||
return true
|
||||
}
|
||||
|
||||
// For the last interval, check if it would extend beyond query end
|
||||
if timestampMs+stepMs > queryWindow.To {
|
||||
return queryWindow.To%stepMs != 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Pre-allocate for labels based on column count
|
||||
lblValsCapacity := len(colNames) - 1 // -1 for timestamp
|
||||
if lblValsCapacity < 0 {
|
||||
@@ -271,7 +245,7 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
|
||||
series.Values = append(series.Values, &qbtypes.TimeSeriesValue{
|
||||
Timestamp: ts,
|
||||
Value: val,
|
||||
Partial: isPartialValue(ts),
|
||||
Partial: isPartialValue(ts, queryWindow, stepMs),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -315,6 +289,132 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
|
||||
}, nil
|
||||
}
|
||||
|
||||
func hasHeatmapBucketBounds(colNames []string) bool {
|
||||
var hasMin, hasMax bool
|
||||
for _, colName := range colNames {
|
||||
switch stripKeyAlias(colName) {
|
||||
case qbtypes.HeatmapBucketMinColumn:
|
||||
hasMin = true
|
||||
case qbtypes.HeatmapBucketMaxColumn:
|
||||
hasMax = true
|
||||
}
|
||||
}
|
||||
return hasMin && hasMax
|
||||
}
|
||||
|
||||
// readAsHeatmap folds one row per cell — (timestamp, group labels, bucket bounds, count) — into one series per group.
|
||||
func readAsHeatmap(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbtypes.Step, queryName string) (*qbtypes.TimeSeriesData, error) {
|
||||
colTypes := rows.ColumnTypes()
|
||||
colNames := rows.Columns()
|
||||
|
||||
if !hasHeatmapBucketBounds(colNames) {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"a heatmap needs a %q and a %q column to know the extent of each bucket",
|
||||
qbtypes.HeatmapBucketMinColumn, qbtypes.HeatmapBucketMaxColumn)
|
||||
}
|
||||
|
||||
slots := make([]any, len(colTypes))
|
||||
for i, ct := range colTypes {
|
||||
slots[i] = reflect.New(ct.ScanType()).Interface()
|
||||
}
|
||||
|
||||
stepMs := uint64(step.Milliseconds())
|
||||
|
||||
accumulator := newHeatmapAccumulator()
|
||||
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(slots...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
ts int64
|
||||
bounds bucketBounds
|
||||
count float64
|
||||
lblVals []string
|
||||
lblObjs []*qbtypes.Label
|
||||
)
|
||||
|
||||
for idx, ptr := range slots {
|
||||
name := stripKeyAlias(colNames[idx])
|
||||
value := derefValue(ptr)
|
||||
|
||||
if t, ok := value.(time.Time); ok {
|
||||
ts = t.UnixMilli()
|
||||
continue
|
||||
}
|
||||
|
||||
switch name {
|
||||
case qbtypes.HeatmapBucketMinColumn:
|
||||
bounds.Lower = numericAsFloat(value)
|
||||
case qbtypes.HeatmapBucketMaxColumn:
|
||||
bounds.Upper = numericAsFloat(value)
|
||||
default:
|
||||
if aggRe.MatchString(name) || slices.Contains(legacyReservedColumnTargetAliases, name) {
|
||||
count = numericAsFloat(value)
|
||||
continue
|
||||
}
|
||||
// a nullable label column comes back as a nil any, which would
|
||||
// otherwise key the series on the literal "<nil>"
|
||||
if value == nil {
|
||||
value = ""
|
||||
}
|
||||
lblVals = append(lblVals, fmt.Sprint(value))
|
||||
lblObjs = append(lblObjs, &qbtypes.Label{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: name},
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if ts == 0 || !isValidBucketBounds(bounds) || math.IsNaN(count) || math.IsInf(count, 0) {
|
||||
continue
|
||||
}
|
||||
sort.Strings(lblVals)
|
||||
labelsKey := strings.Join(lblVals, ",")
|
||||
|
||||
if err := accumulator.addCell(labelsKey, lblObjs, ts, bounds, count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return accumulator.foldSeries(queryWindow, stepMs, queryName)
|
||||
}
|
||||
|
||||
// isPartialValue reports whether the step interval starting at timestamp is only
|
||||
// partly covered by the query window, which happens when the window boundaries
|
||||
// are not step-aligned.
|
||||
func isPartialValue(timestamp int64, queryWindow *qbtypes.TimeRange, stepMs uint64) bool {
|
||||
if stepMs == 0 || queryWindow == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
timestampMs := uint64(timestamp)
|
||||
|
||||
// For the first interval, check if query start is misaligned
|
||||
// The first complete interval starts at the first timestamp >= queryWindow.From that is aligned to step
|
||||
firstCompleteInterval := queryWindow.From
|
||||
if queryWindow.From%stepMs != 0 {
|
||||
// Round up to next step boundary
|
||||
firstCompleteInterval = ((queryWindow.From / stepMs) + 1) * stepMs
|
||||
}
|
||||
|
||||
// If timestamp is before the first complete interval, it's partial
|
||||
if timestampMs < firstCompleteInterval {
|
||||
return true
|
||||
}
|
||||
|
||||
// For the last interval, check if it would extend beyond query end
|
||||
if timestampMs+stepMs > queryWindow.To {
|
||||
return queryWindow.To%stepMs != 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isNumericKind(t reflect.Type) bool {
|
||||
if t == nil {
|
||||
return false
|
||||
|
||||
191
pkg/querier/heatmap_accumulator.go
Normal file
191
pkg/querier/heatmap_accumulator.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
// bucketBounds holds the values in (Lower, Upper]. Only Upper reaches the
|
||||
// response; Lower is what says whether two rows bucketed the same way.
|
||||
type bucketBounds struct {
|
||||
Lower float64
|
||||
Upper float64
|
||||
}
|
||||
|
||||
// heatmapColumn maps a bucket to the count in it, holding one timestamp's cells.
|
||||
// Keyed rather than indexed by bucket because the axis is only known once every
|
||||
// cell has been seen.
|
||||
type heatmapColumn map[bucketBounds]float64
|
||||
|
||||
func isValidBucketBounds(bounds bucketBounds) bool {
|
||||
return !math.IsNaN(bounds.Lower) && !math.IsNaN(bounds.Upper) &&
|
||||
!math.IsInf(bounds.Upper, -1) && bounds.Lower < bounds.Upper
|
||||
}
|
||||
|
||||
type heatmapAxisBucketDetails struct {
|
||||
bounds bucketBounds
|
||||
labels []*qbtypes.Label
|
||||
ts int64
|
||||
}
|
||||
|
||||
func (b heatmapAxisBucketDetails) describe() string {
|
||||
at := time.UnixMilli(b.ts).UTC().Format(time.RFC3339)
|
||||
if len(b.labels) == 0 {
|
||||
return at
|
||||
}
|
||||
|
||||
pairs := make([]string, 0, len(b.labels))
|
||||
for _, label := range b.labels {
|
||||
pairs = append(pairs, fmt.Sprintf("%s=%v", label.Key.Name, label.Value))
|
||||
}
|
||||
return fmt.Sprintf("%s at %s", strings.Join(pairs, ", "), at)
|
||||
}
|
||||
|
||||
// heatmapSeries accumulates one group's columns while the rows are read.
|
||||
type heatmapSeries struct {
|
||||
labels []*qbtypes.Label
|
||||
timestampToColumn map[int64]heatmapColumn
|
||||
}
|
||||
|
||||
// heatmapAccumulator collects cells from either reader and folds them into one
|
||||
// series per group.
|
||||
type heatmapAccumulator struct {
|
||||
keyToSeries map[string]*heatmapSeries
|
||||
seriesOrder []string
|
||||
upperBoundToBucket map[float64]heatmapAxisBucketDetails
|
||||
}
|
||||
|
||||
func newHeatmapAccumulator() *heatmapAccumulator {
|
||||
return &heatmapAccumulator{
|
||||
keyToSeries: map[string]*heatmapSeries{},
|
||||
upperBoundToBucket: map[float64]heatmapAxisBucketDetails{},
|
||||
}
|
||||
}
|
||||
|
||||
// addCell files one cell under the group labelsKey identifies, keeping the
|
||||
// labels from the first cell seen for it. A response carrying upper bounds
|
||||
// alone cannot express two rows cutting the same bucket differently, so the
|
||||
// second of them is rejected here.
|
||||
func (a *heatmapAccumulator) addCell(labelsKey string, lbls []*qbtypes.Label, ts int64, bounds bucketBounds, count float64) error {
|
||||
bucketToAdd := heatmapAxisBucketDetails{bounds: bounds, labels: lbls, ts: ts}
|
||||
|
||||
if seenBucket, isUpperBoundSeenBefore := a.upperBoundToBucket[bounds.Upper]; isUpperBoundSeenBefore && seenBucket.bounds.Lower != bounds.Lower {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"the bucket ending at %v starts at %v for %s and at %v for %s",
|
||||
bounds.Upper, seenBucket.bounds.Lower, seenBucket.describe(), bounds.Lower, bucketToAdd.describe()).
|
||||
WithAdditional("A heatmap draws one set of buckets, so every row has to report the same ones")
|
||||
}
|
||||
a.upperBoundToBucket[bounds.Upper] = bucketToAdd
|
||||
|
||||
series, found := a.keyToSeries[labelsKey]
|
||||
if !found {
|
||||
series = &heatmapSeries{labels: lbls, timestampToColumn: map[int64]heatmapColumn{}}
|
||||
a.keyToSeries[labelsKey] = series
|
||||
a.seriesOrder = append(a.seriesOrder, labelsKey)
|
||||
}
|
||||
if series.timestampToColumn[ts] == nil {
|
||||
series.timestampToColumn[ts] = heatmapColumn{}
|
||||
}
|
||||
series.timestampToColumn[ts][bounds] += count
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveBucketAxis lists the upper bounds the counts are indexed against. A
|
||||
// gap goes on the axis as its own empty bucket, or the bucket above it would
|
||||
// widen to cover a range nothing bucketed.
|
||||
func (a *heatmapAccumulator) resolveBucketAxis() ([]float64, error) {
|
||||
upperBounds := make([]float64, 0, len(a.upperBoundToBucket))
|
||||
for upperBound := range a.upperBoundToBucket {
|
||||
if !math.IsInf(upperBound, 1) {
|
||||
upperBounds = append(upperBounds, upperBound)
|
||||
}
|
||||
}
|
||||
slices.Sort(upperBounds)
|
||||
|
||||
axis := make([]float64, 0, 2*len(upperBounds))
|
||||
for index, upperBound := range upperBounds {
|
||||
currentBucket := a.upperBoundToBucket[upperBound]
|
||||
if index > 0 {
|
||||
previousBucket := a.upperBoundToBucket[upperBounds[index-1]]
|
||||
switch {
|
||||
case currentBucket.bounds.Lower < previousBucket.bounds.Upper:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"the bucket %v to %v for %s covers values already in the bucket %v to %v for %s",
|
||||
currentBucket.bounds.Lower, currentBucket.bounds.Upper, currentBucket.describe(),
|
||||
previousBucket.bounds.Lower, previousBucket.bounds.Upper, previousBucket.describe()).
|
||||
WithAdditional("A heatmap draws one set of buckets, so every row has to report the same ones")
|
||||
case currentBucket.bounds.Lower > previousBucket.bounds.Upper:
|
||||
axis = append(axis, currentBucket.bounds.Lower)
|
||||
}
|
||||
}
|
||||
axis = append(axis, upperBound)
|
||||
}
|
||||
|
||||
return axis, nil
|
||||
}
|
||||
|
||||
// foldSeries turns the collected cells into one series per group, in the order
|
||||
// the groups first appeared.
|
||||
func (a *heatmapAccumulator) foldSeries(queryWindow *qbtypes.TimeRange, stepMs uint64, queryName string) (*qbtypes.TimeSeriesData, error) {
|
||||
if len(a.seriesOrder) == 0 {
|
||||
return &qbtypes.TimeSeriesData{QueryName: queryName}, nil
|
||||
}
|
||||
|
||||
upperBounds, err := a.resolveBucketAxis()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
upperBoundToIndex := make(map[float64]int, len(upperBounds)+1)
|
||||
for index, upperBound := range upperBounds {
|
||||
upperBoundToIndex[upperBound] = index
|
||||
}
|
||||
// the index past the last upper bound is where the +Inf overflow lands
|
||||
upperBoundToIndex[math.Inf(1)] = len(upperBounds)
|
||||
|
||||
bucket := &qbtypes.AggregationBucket{
|
||||
Index: 0,
|
||||
Alias: "__result_0",
|
||||
Meta: qbtypes.AggregationMeta{Buckets: upperBounds},
|
||||
Series: make([]*qbtypes.TimeSeries, 0, len(a.seriesOrder)),
|
||||
}
|
||||
|
||||
for _, labelsKey := range a.seriesOrder {
|
||||
accumulated := a.keyToSeries[labelsKey]
|
||||
|
||||
timestamps := make([]int64, 0, len(accumulated.timestampToColumn))
|
||||
for ts := range accumulated.timestampToColumn {
|
||||
timestamps = append(timestamps, ts)
|
||||
}
|
||||
slices.Sort(timestamps)
|
||||
|
||||
series := &qbtypes.TimeSeries{
|
||||
Labels: accumulated.labels,
|
||||
Values: make([]*qbtypes.TimeSeriesValue, 0, len(timestamps)),
|
||||
}
|
||||
for _, ts := range timestamps {
|
||||
values := make([]float64, len(upperBounds)+1)
|
||||
for bounds, count := range accumulated.timestampToColumn[ts] {
|
||||
values[upperBoundToIndex[bounds.Upper]] += count
|
||||
}
|
||||
series.Values = append(series.Values, &qbtypes.TimeSeriesValue{
|
||||
Timestamp: ts,
|
||||
Values: values,
|
||||
Partial: isPartialValue(ts, queryWindow, stepMs),
|
||||
})
|
||||
}
|
||||
bucket.Series = append(bucket.Series, series)
|
||||
}
|
||||
|
||||
return &qbtypes.TimeSeriesData{
|
||||
QueryName: queryName,
|
||||
Aggregations: []*qbtypes.AggregationBucket{bucket},
|
||||
}, nil
|
||||
}
|
||||
101
pkg/querier/heatmap_test.go
Normal file
101
pkg/querier/heatmap_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMergeTimeSeriesResultsUnionsHeatmapAxes(t *testing.T) {
|
||||
// a log axis holds whichever bands the data reached, so a wide cached range
|
||||
// and a narrow fresh one routinely disagree on which bands exist
|
||||
cached := &qbtypes.TimeSeriesData{
|
||||
QueryName: "A",
|
||||
Aggregations: []*qbtypes.AggregationBucket{{
|
||||
Index: 0,
|
||||
Meta: qbtypes.AggregationMeta{Buckets: []float64{1, 4, 16}},
|
||||
Series: []*qbtypes.TimeSeries{{
|
||||
Labels: []*qbtypes.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host.name"}, Value: "node-1"}},
|
||||
Values: []*qbtypes.TimeSeriesValue{{Timestamp: 1710000000000, Values: []float64{1, 2, 3, 4}}},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
fresh := []*qbtypes.Result{{
|
||||
Value: &qbtypes.TimeSeriesData{
|
||||
QueryName: "A",
|
||||
Aggregations: []*qbtypes.AggregationBucket{{
|
||||
Index: 0,
|
||||
Meta: qbtypes.AggregationMeta{Buckets: []float64{2, 4}},
|
||||
Series: []*qbtypes.TimeSeries{{
|
||||
Labels: []*qbtypes.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host.name"}, Value: "node-1"}},
|
||||
Values: []*qbtypes.TimeSeriesValue{{Timestamp: 1710000060000, Values: []float64{5, 6, 7}}},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}}
|
||||
|
||||
merged := (&querier{}).mergeTimeSeriesResults(cached, fresh)
|
||||
|
||||
require.Len(t, merged.Aggregations, 1)
|
||||
aggBucket := merged.Aggregations[0]
|
||||
assert.Equal(t, []float64{1, 2, 4, 16}, aggBucket.Meta.Buckets)
|
||||
|
||||
require.Len(t, aggBucket.Series, 1)
|
||||
require.Len(t, aggBucket.Series[0].Values, 2)
|
||||
// the cached 16 band survives even though the fresh range never reached it
|
||||
assert.Equal(t, []float64{1, 0, 2, 3, 4}, aggBucket.Series[0].Values[0].Values)
|
||||
// and the fresh 2 band survives even though the cached range never had it
|
||||
assert.Equal(t, []float64{0, 5, 6, 0, 7}, aggBucket.Series[0].Values[1].Values)
|
||||
}
|
||||
|
||||
func TestTrimResultToFluxBoundaryKeepsTheHeatmapAxis(t *testing.T) {
|
||||
cache := &bucketCache{logger: instrumentationtest.New().Logger()}
|
||||
|
||||
result := &qbtypes.Result{
|
||||
Type: qbtypes.RequestTypeHeatmap,
|
||||
Value: &qbtypes.TimeSeriesData{
|
||||
Aggregations: []*qbtypes.AggregationBucket{{
|
||||
Index: 0,
|
||||
Alias: "__result_0",
|
||||
Meta: qbtypes.AggregationMeta{Unit: "By", Buckets: []float64{1, 2, 4}},
|
||||
Series: []*qbtypes.TimeSeries{{
|
||||
Values: []*qbtypes.TimeSeriesValue{
|
||||
{Timestamp: 1710000000000, Values: []float64{1, 2, 3, 4}},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
trimmed := cache.trimResultToFluxBoundary(result, 1710000060000)
|
||||
|
||||
tsData, ok := trimmed.Value.(*qbtypes.TimeSeriesData)
|
||||
require.True(t, ok)
|
||||
require.Len(t, tsData.Aggregations, 1)
|
||||
|
||||
// the counts are positional against the axis, so a cached bucket that lost
|
||||
// Meta.Buckets would be realigned from an empty axis and collapse into the
|
||||
// overflow slot on the way back out
|
||||
aggBucket := tsData.Aggregations[0]
|
||||
assert.Equal(t, []float64{1, 2, 4}, aggBucket.Meta.Buckets)
|
||||
assert.Equal(t, "By", aggBucket.Meta.Unit)
|
||||
assert.Equal(t, "__result_0", aggBucket.Alias)
|
||||
}
|
||||
|
||||
func TestRealignFromAnEmptyAxisCollapsesIntoTheOverflow(t *testing.T) {
|
||||
// pins the behaviour the trim bug exposed: with no axis to read the counts
|
||||
// against, everything lands in the overflow slot
|
||||
aggBucket := &qbtypes.AggregationBucket{
|
||||
Series: []*qbtypes.TimeSeries{{
|
||||
Values: []*qbtypes.TimeSeriesValue{{Timestamp: 1710000000000, Values: []float64{7, 8, 9, 10}}},
|
||||
}},
|
||||
}
|
||||
|
||||
aggBucket.ReindexValuesToNewUpperBounds([]float64{1, 2, 4})
|
||||
|
||||
assert.Equal(t, []float64{0, 0, 0, 7}, aggBucket.Series[0].Values[0].Values)
|
||||
}
|
||||
@@ -195,6 +195,16 @@ func postProcessBuilderQuery[T any](
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveHeatmapBucketAxis brings the bucket axis to the resolution the caller
|
||||
// asked for. Downscaling runs first so AddHeatmapBucketsWithNoCounts adds them
|
||||
// at that resolution rather than the finer one ClickHouse bucketed at.
|
||||
func resolveHeatmapBucketAxis(tsData *qbtypes.TimeSeriesData, bucketing qbtypes.HeatmapBucketing) {
|
||||
if bucketing.Kind == qbtypes.BucketsKindLog {
|
||||
qbtypes.DownscaleHeatmapResolution(tsData, bucketing.LogScale)
|
||||
}
|
||||
qbtypes.AddHeatmapBucketsWithNoCounts(tsData, bucketing)
|
||||
}
|
||||
|
||||
// postProcessMetricQuery applies postprocessing to a metric query result.
|
||||
func postProcessMetricQuery(
|
||||
q *querier,
|
||||
@@ -216,6 +226,12 @@ func postProcessMetricQuery(
|
||||
}
|
||||
}
|
||||
|
||||
if req.RequestType == qbtypes.RequestTypeHeatmap && config.HeatmapBucketing != nil {
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
resolveHeatmapBucketAxis(tsData, *config.HeatmapBucketing)
|
||||
}
|
||||
}
|
||||
|
||||
result = q.applySeriesLimit(result, query.Limit, query.Order)
|
||||
|
||||
if len(query.Functions) > 0 {
|
||||
@@ -342,6 +358,19 @@ func (q *querier) applyFormulas(ctx context.Context, results map[string]*qbtypes
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
|
||||
results[name] = result
|
||||
}
|
||||
case qbtypes.RequestTypeHeatmap:
|
||||
// The queries a formula reads were run as time series, so what
|
||||
// arrives here is one value per group per timestamp.
|
||||
result := q.processTimeSeriesFormula(ctx, results, formula, req)
|
||||
if result != nil {
|
||||
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
|
||||
bucketing := formula.BucketOptions.ToHeatmapBucketing()
|
||||
bucketFormulaOutputAsHeatmap(tsData, bucketing)
|
||||
resolveHeatmapBucketAxis(tsData, bucketing)
|
||||
}
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
|
||||
results[name] = result
|
||||
}
|
||||
case qbtypes.RequestTypeScalar:
|
||||
result := q.processScalarFormula(ctx, results, formula, req)
|
||||
// For scalar results, apply limit by processScalarFormula itself since it needs to be applied before converting back to scalar format
|
||||
@@ -410,6 +439,89 @@ func (q *querier) processTimeSeriesFormula(
|
||||
return result
|
||||
}
|
||||
|
||||
func bucketFormulaOutputAsHeatmap(tsData *qbtypes.TimeSeriesData, bucketing qbtypes.HeatmapBucketing) {
|
||||
// A formula is one expression, so processTimeSeriesFormula gives it one
|
||||
// aggregation.
|
||||
if tsData == nil || len(tsData.Aggregations) == 0 || tsData.Aggregations[0] == nil {
|
||||
return
|
||||
}
|
||||
aggBucket := tsData.Aggregations[0]
|
||||
|
||||
calculateUpperBound := calculateLogValueUpperBound
|
||||
if bucketing.Kind == qbtypes.BucketsKindLinear {
|
||||
calculateUpperBound = func(value float64) float64 {
|
||||
return calculateLinearValueUpperBound(bucketing, value)
|
||||
}
|
||||
}
|
||||
|
||||
// +Inf is the open-above overflow rather than an upper bound of its own, and
|
||||
// a NaN value has no bucket at all, so neither goes on the axis.
|
||||
upperBounds := []float64{}
|
||||
for _, series := range aggBucket.Series {
|
||||
for _, point := range series.Values {
|
||||
upperBound := calculateUpperBound(point.Value)
|
||||
if !math.IsNaN(upperBound) && !math.IsInf(upperBound, 0) {
|
||||
upperBounds = append(upperBounds, upperBound)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(upperBounds)
|
||||
upperBounds = slices.Compact(upperBounds)
|
||||
|
||||
upperBoundToIndex := make(map[float64]int, len(upperBounds))
|
||||
for index, upperBound := range upperBounds {
|
||||
upperBoundToIndex[upperBound] = index
|
||||
}
|
||||
|
||||
overflowIndex := len(upperBounds)
|
||||
for _, series := range aggBucket.Series {
|
||||
for _, point := range series.Values {
|
||||
upperBound := calculateUpperBound(point.Value)
|
||||
point.Values = make([]float64, overflowIndex+1)
|
||||
point.Value = 0
|
||||
switch {
|
||||
case math.IsNaN(upperBound):
|
||||
case math.IsInf(upperBound, 1):
|
||||
point.Values[overflowIndex] = 1
|
||||
default:
|
||||
point.Values[upperBoundToIndex[upperBound]] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
aggBucket.Meta.Buckets = upperBounds
|
||||
}
|
||||
|
||||
// calculateLinearValueUpperBound and calculateLogValueUpperBound are the Go side
|
||||
// of what renderLinearUpperBoundExpr and renderLogUpperBoundExpr emit, and have
|
||||
// to stay identical to them: a formula heatmap and a metric heatmap that
|
||||
// disagreed here would put their counts in different buckets.
|
||||
func calculateLinearValueUpperBound(bucketing qbtypes.HeatmapBucketing, value float64) float64 {
|
||||
if value > bucketing.MaxValue {
|
||||
return math.Inf(1)
|
||||
}
|
||||
numBuckets := float64(bucketing.NumBuckets)
|
||||
index := math.Min(math.Max(math.Ceil(value*numBuckets/bucketing.MaxValue), 1), numBuckets)
|
||||
return index * bucketing.MaxValue / numBuckets
|
||||
}
|
||||
|
||||
// Like renderLogUpperBoundExpr, this reads MaxLogScale rather than the requested
|
||||
// scale: ClickHouse buckets at the finest resolution and resolveHeatmapBucketAxis
|
||||
// folds the axis down afterwards.
|
||||
func calculateLogValueUpperBound(value float64) float64 {
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
if value <= qbtypes.MinLogUpperBound {
|
||||
return qbtypes.MinLogUpperBound
|
||||
}
|
||||
if value > qbtypes.MaxLogUpperBound {
|
||||
return math.Inf(1)
|
||||
}
|
||||
bucketsPerDoubling := math.Exp2(qbtypes.MaxLogScale)
|
||||
return math.Exp2(math.Ceil(math.Log2(value)*bucketsPerDoubling) / bucketsPerDoubling)
|
||||
}
|
||||
|
||||
func (q *querier) processScalarFormula(
|
||||
ctx context.Context,
|
||||
results map[string]*qbtypes.Result,
|
||||
@@ -494,7 +606,7 @@ func (q *querier) processScalarFormula(
|
||||
bucket := &qbtypes.AggregationBucket{
|
||||
Index: aggIdx,
|
||||
Alias: scalarData.Columns[colIdx].Name,
|
||||
Meta: scalarData.Columns[colIdx].Meta,
|
||||
Meta: qbtypes.AggregationMeta{Unit: scalarData.Columns[colIdx].Meta.Unit},
|
||||
Series: make([]*qbtypes.TimeSeries, 0),
|
||||
}
|
||||
|
||||
@@ -667,13 +779,14 @@ func convertTimeSeriesDataToScalar(tsData *qbtypes.TimeSeriesData, queryName str
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("__result_%d", agg.Index)
|
||||
}
|
||||
columns = append(columns, &qbtypes.ColumnDescriptor{
|
||||
column := &qbtypes.ColumnDescriptor{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: name},
|
||||
QueryName: queryName,
|
||||
AggregationIndex: int64(agg.Index),
|
||||
Meta: agg.Meta,
|
||||
Type: qbtypes.ColumnTypeAggregation,
|
||||
})
|
||||
}
|
||||
column.Meta.Unit = agg.Meta.Unit
|
||||
columns = append(columns, column)
|
||||
}
|
||||
|
||||
// Build rows.
|
||||
|
||||
@@ -50,7 +50,7 @@ func (q *querier) QueryRangePreview(
|
||||
env := []qbtypes.QueryEnvelope{req.CompositeQuery.Queries[idx]}
|
||||
ps.Warnings = append(ps.Warnings, q.adjustStepInterval(env, req.Start, req.End)...)
|
||||
|
||||
missingMetricQueries, metricWarnings, mErr := q.resolveMetricMetadata(ctx, orgID, env, req.Start, req.End)
|
||||
missingMetricQueries, metricWarnings, mErr := q.resolveMetricMetadata(ctx, orgID, env, req.Start, req.End, req.RequestType)
|
||||
if mErr != nil {
|
||||
// Report this query's error but keep previewing the rest.
|
||||
ps.Error = mErr
|
||||
|
||||
149
pkg/querier/promql_heatmap.go
Normal file
149
pkg/querier/promql_heatmap.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// promHistogramBucketLabel is the label a classic histogram carries its
|
||||
// cumulative upper bound on.
|
||||
const promHistogramBucketLabel = "le"
|
||||
|
||||
// cumulativeColumn maps a bucket's upper bound to the cumulative count at it.
|
||||
// Differencing turns it into the per-band counts a heatmapColumn holds.
|
||||
type cumulativeColumn map[float64]float64
|
||||
|
||||
// promHeatmapGroup assembles one group across the several matrix series its `le`
|
||||
// values arrive as, since differencing needs all of them.
|
||||
type promHeatmapGroup struct {
|
||||
labels []*qbv5.Label
|
||||
labelsKey string
|
||||
cumulative map[int64]cumulativeColumn
|
||||
}
|
||||
|
||||
// foldMatrixAsHeatmap folds a matrix of one cumulative series per (group, `le`)
|
||||
// into one series per group whose points hold a count per band.
|
||||
func foldMatrixAsHeatmap(matrix promql.Matrix, queryWindow *qbv5.TimeRange, stepMs uint64, queryName string) (*qbv5.TimeSeriesData, error) {
|
||||
groups, groupOrder := collectCumulativeGroups(matrix)
|
||||
|
||||
// An empty matrix is only ever the window having no data, but series that
|
||||
// all lack `le` say the expression itself cannot draw a heatmap.
|
||||
if len(matrix) > 0 && len(groups) == 0 {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"the query returned no `le` labels to build a bucket axis from").
|
||||
WithAdditional("Keep `le` through the aggregation, e.g. sum by (le) (rate(metric_bucket[5m]))")
|
||||
}
|
||||
|
||||
accumulator := newHeatmapAccumulator()
|
||||
for _, labelsKey := range groupOrder {
|
||||
if err := groups[labelsKey].addDifferencedCells(accumulator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return accumulator.foldSeries(queryWindow, stepMs, queryName)
|
||||
}
|
||||
|
||||
// collectCumulativeGroups reads the matrix into one group per label set. A series
|
||||
// without `le` has no band to sit in, so an expression that dropped the label
|
||||
// draws nothing.
|
||||
func collectCumulativeGroups(matrix promql.Matrix) (groups map[string]*promHeatmapGroup, groupOrder []string) {
|
||||
groups = map[string]*promHeatmapGroup{}
|
||||
|
||||
for _, promSeries := range matrix {
|
||||
upperBound, ok := extractBucketUpperBound(promSeries.Metric)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
lbls, labelsKey := extractHeatmapGroup(promSeries.Metric)
|
||||
group, ok := groups[labelsKey]
|
||||
if !ok {
|
||||
group = &promHeatmapGroup{labels: lbls, labelsKey: labelsKey, cumulative: map[int64]cumulativeColumn{}}
|
||||
groups[labelsKey] = group
|
||||
groupOrder = append(groupOrder, labelsKey)
|
||||
}
|
||||
|
||||
for _, point := range promSeries.Floats {
|
||||
if math.IsNaN(point.F) || math.IsInf(point.F, 0) {
|
||||
continue
|
||||
}
|
||||
if group.cumulative[point.T] == nil {
|
||||
group.cumulative[point.T] = cumulativeColumn{}
|
||||
}
|
||||
group.cumulative[point.T][upperBound] = point.F
|
||||
}
|
||||
}
|
||||
|
||||
return groups, groupOrder
|
||||
}
|
||||
|
||||
func extractBucketUpperBound(metric labels.Labels) (float64, bool) {
|
||||
raw := metric.Get(promHistogramBucketLabel)
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
upperBound, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil || math.IsNaN(upperBound) || math.IsInf(upperBound, -1) {
|
||||
return 0, false
|
||||
}
|
||||
return upperBound, true
|
||||
}
|
||||
|
||||
// extractHeatmapGroup returns a series' group labels — everything but `le`.
|
||||
func extractHeatmapGroup(metric labels.Labels) ([]*qbv5.Label, string) {
|
||||
lbls := make([]*qbv5.Label, 0, metric.Len())
|
||||
pairs := make([]string, 0, metric.Len())
|
||||
|
||||
metric.Range(func(l labels.Label) {
|
||||
if l.Name == promHistogramBucketLabel || excludePromLabel(l.Name) {
|
||||
return
|
||||
}
|
||||
lbls = append(lbls, &qbv5.Label{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: l.Name},
|
||||
Value: l.Value,
|
||||
})
|
||||
pairs = append(pairs, fmt.Sprintf("%s=%s", l.Name, l.Value))
|
||||
})
|
||||
|
||||
sort.Strings(pairs)
|
||||
return lbls, strings.Join(pairs, ",")
|
||||
}
|
||||
|
||||
// each cell is its upper bound's cumulative count minus the one below it, and
|
||||
// runs from that lower `le` up to its own. Nothing bounds the lowest one below:
|
||||
// a classic histogram counts negative observations in it too.
|
||||
func (g *promHeatmapGroup) addDifferencedCells(accumulator *heatmapAccumulator) error {
|
||||
for ts, cumulative := range g.cumulative {
|
||||
upperBounds := make([]float64, 0, len(cumulative))
|
||||
for upperBound := range cumulative {
|
||||
upperBounds = append(upperBounds, upperBound)
|
||||
}
|
||||
slices.Sort(upperBounds)
|
||||
|
||||
previousCount := float64(0)
|
||||
previousBound := math.Inf(-1)
|
||||
for _, upperBound := range upperBounds {
|
||||
bounds := bucketBounds{Lower: previousBound, Upper: upperBound}
|
||||
if err := accumulator.addCell(g.labelsKey, g.labels, ts, bounds, math.Max(cumulative[upperBound]-previousCount, 0)); err != nil {
|
||||
return err
|
||||
}
|
||||
previousCount = cumulative[upperBound]
|
||||
previousBound = upperBound
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
88
pkg/querier/promql_heatmap_test.go
Normal file
88
pkg/querier/promql_heatmap_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
|
||||
qbv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The cache key is the fingerprint alone, so two request types over one
|
||||
// expression must not produce the same one — a time series payload served to a
|
||||
// heatmap request has no axis and reads back as a single collapsed band.
|
||||
func TestFingerprintSeparatesHeatmapFromTimeSeries(t *testing.T) {
|
||||
fingerprintFor := func(requestType qbv5.RequestType) string {
|
||||
q := &promqlQuery{
|
||||
logger: slog.New(slog.DiscardHandler),
|
||||
query: qbv5.PromQuery{Name: "A", Query: "sum by (le) (increase(signoz_latency_bucket[5m]))", Step: qbv5.Step{Duration: time.Minute}},
|
||||
tr: qbv5.TimeRange{From: 1710000000000, To: 1710003600000},
|
||||
requestType: requestType,
|
||||
}
|
||||
return q.Fingerprint()
|
||||
}
|
||||
|
||||
heatmap := fingerprintFor(qbv5.RequestTypeHeatmap)
|
||||
timeSeries := fingerprintFor(qbv5.RequestTypeTimeSeries)
|
||||
|
||||
assert.NotEmpty(t, heatmap, "a heatmap decomposes into time buckets like a time series")
|
||||
assert.NotEqual(t, timeSeries, heatmap)
|
||||
assert.Empty(t, fingerprintFor(qbv5.RequestTypeScalar), "a scalar result is its window's last point")
|
||||
}
|
||||
|
||||
func TestFoldMatrixAsHeatmapClampsADecreasingCumulativeCount(t *testing.T) {
|
||||
at := int64(1710000000000)
|
||||
|
||||
matrix := promql.Matrix{
|
||||
{
|
||||
Metric: labels.FromStrings("le", "5"),
|
||||
Floats: []promql.FPoint{{T: at, F: 10}},
|
||||
},
|
||||
{
|
||||
Metric: labels.FromStrings("le", "10"),
|
||||
Floats: []promql.FPoint{{T: at, F: 4}},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := foldMatrixAsHeatmap(matrix, &qbv5.TimeRange{From: 1710000000000, To: 1710000060000}, uint64(time.Minute.Milliseconds()), "A")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, data.Aggregations, 1)
|
||||
|
||||
// a cumulative count that went backwards would difference to -6
|
||||
assert.Equal(t, []float64{10, 0, 0}, data.Aggregations[0].Series[0].Values[0].Values)
|
||||
}
|
||||
|
||||
func TestFoldMatrixAsHeatmapWidensTheBandOverAMissingUpperBound(t *testing.T) {
|
||||
at := int64(1710000000000)
|
||||
|
||||
matrix := promql.Matrix{
|
||||
{
|
||||
Metric: labels.FromStrings("le", "5"),
|
||||
Floats: []promql.FPoint{{T: at, F: 3}},
|
||||
},
|
||||
{
|
||||
Metric: labels.FromStrings("le", "10"),
|
||||
Floats: []promql.FPoint{{T: at, F: math.NaN()}},
|
||||
},
|
||||
{
|
||||
Metric: labels.FromStrings("le", "20"),
|
||||
Floats: []promql.FPoint{{T: at, F: 30}},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := foldMatrixAsHeatmap(matrix, &qbv5.TimeRange{From: 1710000000000, To: 1710000060000}, uint64(time.Minute.Milliseconds()), "A")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, data.Aggregations, 1)
|
||||
|
||||
aggregation := data.Aggregations[0]
|
||||
// 10 carried nothing to difference against, so it is not on the axis at all
|
||||
// and 20 differences against 5, holding what (5,10] and (10,20] would split
|
||||
assert.Equal(t, []float64{5, 20}, aggregation.Meta.Buckets)
|
||||
assert.Equal(t, []float64{3, 27, 0}, aggregation.Series[0].Values[0].Values)
|
||||
}
|
||||
@@ -155,7 +155,10 @@ func (q *promqlQuery) Fingerprint() string {
|
||||
if q.opts.serve != nil {
|
||||
return ""
|
||||
}
|
||||
if q.requestType != qbv5.RequestTypeTimeSeries {
|
||||
|
||||
switch q.requestType {
|
||||
case qbv5.RequestTypeTimeSeries, qbv5.RequestTypeHeatmap:
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -166,6 +169,8 @@ func (q *promqlQuery) Fingerprint() string {
|
||||
}
|
||||
parts := []string{
|
||||
"promql",
|
||||
// one expression returns a different shape per request type
|
||||
fmt.Sprintf("requestType=%s", q.requestType.StringValue()),
|
||||
query,
|
||||
q.query.Step.String(),
|
||||
}
|
||||
@@ -369,7 +374,7 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return q.toResult(matrix, nil, began, &statsMu, &rowsScanned, &bytesScanned), nil
|
||||
return q.toResult(matrix, nil, began, &statsMu, &rowsScanned, &bytesScanned)
|
||||
}
|
||||
|
||||
// When the serving provider has the RangeExecutor capability
|
||||
@@ -385,7 +390,7 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
return nil, err
|
||||
}
|
||||
if served {
|
||||
return q.toResult(matrix, nil, began, &statsMu, &rowsScanned, &bytesScanned), nil
|
||||
return q.toResult(matrix, nil, began, &statsMu, &rowsScanned, &bytesScanned)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,28 +451,60 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
}
|
||||
|
||||
warnings, _ := res.Warnings.AsStrings(query, 10, 0)
|
||||
return q.toResult(matrix, warnings, began, &statsMu, &rowsScanned, &bytesScanned), nil
|
||||
return q.toResult(matrix, warnings, began, &statsMu, &rowsScanned, &bytesScanned)
|
||||
}
|
||||
|
||||
// toResult converts an evaluated matrix into the v5 result shape, attaching
|
||||
// the ClickHouse scan stats accumulated during evaluation.
|
||||
func (q *promqlQuery) toResult(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) *qbv5.Result {
|
||||
// Hide only known SigNoz storage keys: label names are user data and may
|
||||
// legitimately start with "__" (e.g. __address__), so a blanket dunder
|
||||
// strip mangles user labelsets. The __scope./__resource. prefixes cover
|
||||
// every exporter version's keys.
|
||||
excludeLabel := func(labelName string) bool {
|
||||
return labelName == "__temporality__" ||
|
||||
strings.HasPrefix(labelName, "__scope.") ||
|
||||
strings.HasPrefix(labelName, "__resource.")
|
||||
// excludePromLabel hides only known SigNoz storage keys: label names are user
|
||||
// data and may legitimately start with "__" (e.g. __address__), so a blanket
|
||||
// dunder strip mangles user labelsets. The __scope./__resource. prefixes cover
|
||||
// every exporter version's keys.
|
||||
func excludePromLabel(labelName string) bool {
|
||||
return labelName == "__temporality__" ||
|
||||
strings.HasPrefix(labelName, "__scope.") ||
|
||||
strings.HasPrefix(labelName, "__resource.")
|
||||
}
|
||||
|
||||
// collectExecStats snapshots the scan counters a query accumulated. Callers take
|
||||
// it at the point they are done with the matrix, so the duration covers the
|
||||
// shaping they did.
|
||||
func collectExecStats(began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) qbv5.ExecStats {
|
||||
statsMu.Lock()
|
||||
defer statsMu.Unlock()
|
||||
return qbv5.ExecStats{
|
||||
RowsScanned: *rowsScanned,
|
||||
BytesScanned: *bytesScanned,
|
||||
DurationMS: uint64(time.Since(began).Milliseconds()),
|
||||
}
|
||||
}
|
||||
|
||||
func (q *promqlQuery) toResult(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) (*qbv5.Result, error) {
|
||||
if q.requestType == qbv5.RequestTypeHeatmap {
|
||||
return q.toResultForHeatmap(matrix, warnings, began, statsMu, rowsScanned, bytesScanned)
|
||||
}
|
||||
return q.toResultForTimeSeriesAndScalar(matrix, warnings, began, statsMu, rowsScanned, bytesScanned), nil
|
||||
}
|
||||
|
||||
func (q *promqlQuery) toResultForHeatmap(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) (*qbv5.Result, error) {
|
||||
tsData, err := foldMatrixAsHeatmap(matrix, &q.tr, uint64(q.query.Step.Milliseconds()), q.query.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &qbv5.Result{
|
||||
Type: q.requestType,
|
||||
Value: tsData,
|
||||
Warnings: warnings,
|
||||
Stats: collectExecStats(began, statsMu, rowsScanned, bytesScanned),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *promqlQuery) toResultForTimeSeriesAndScalar(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) *qbv5.Result {
|
||||
var series []*qbv5.TimeSeries
|
||||
for _, v := range matrix {
|
||||
var s qbv5.TimeSeries
|
||||
lbls := make([]*qbv5.Label, 0, v.Metric.Len())
|
||||
v.Metric.Range(func(l labels.Label) {
|
||||
if excludeLabel(l.Name) {
|
||||
if excludePromLabel(l.Name) {
|
||||
return
|
||||
}
|
||||
lbls = append(lbls, &qbv5.Label{
|
||||
@@ -495,13 +532,7 @@ func (q *promqlQuery) toResult(matrix promql.Matrix, warnings []string, began ti
|
||||
series = append(series, &s)
|
||||
}
|
||||
|
||||
statsMu.Lock()
|
||||
stats := qbv5.ExecStats{
|
||||
RowsScanned: *rowsScanned,
|
||||
BytesScanned: *bytesScanned,
|
||||
DurationMS: uint64(time.Since(began).Milliseconds()),
|
||||
}
|
||||
statsMu.Unlock()
|
||||
stats := collectExecStats(began, statsMu, rowsScanned, bytesScanned)
|
||||
|
||||
tsData := &qbv5.TimeSeriesData{QueryName: q.query.Name}
|
||||
// No bucket at all when nothing survived: a bucket holding no series reads
|
||||
|
||||
@@ -495,7 +495,8 @@ func TestToResultDropsNonFiniteValues(t *testing.T) {
|
||||
|
||||
var mu sync.Mutex
|
||||
var rows, bytes uint64
|
||||
result := q.toResult(matrix, nil, time.Now(), &mu, &rows, &bytes)
|
||||
result, err := q.toResult(matrix, nil, time.Now(), &mu, &rows, &bytes)
|
||||
require.NoError(t, err)
|
||||
|
||||
tsData, ok := result.Value.(*qbv5.TimeSeriesData)
|
||||
require.True(t, ok)
|
||||
@@ -526,7 +527,9 @@ func TestToResultDropsSeriesAndBucketLeftEmpty(t *testing.T) {
|
||||
|
||||
var mu sync.Mutex
|
||||
var rows, bytes uint64
|
||||
tsData, ok := q.toResult(matrix, nil, time.Now(), &mu, &rows, &bytes).Value.(*qbv5.TimeSeriesData)
|
||||
result, err := q.toResult(matrix, nil, time.Now(), &mu, &rows, &bytes)
|
||||
require.NoError(t, err)
|
||||
tsData, ok := result.Value.(*qbv5.TimeSeriesData)
|
||||
require.True(t, ok)
|
||||
require.Len(t, tsData.Aggregations, 1)
|
||||
require.Len(t, tsData.Aggregations[0].Series, 1, "the all-NaN series is gone")
|
||||
@@ -535,7 +538,9 @@ func TestToResultDropsSeriesAndBucketLeftEmpty(t *testing.T) {
|
||||
allNaN := promql.Matrix{
|
||||
{Metric: labels.FromStrings("job_name", "idleJob"), Floats: []promql.FPoint{{T: 1000, F: math.NaN()}}},
|
||||
}
|
||||
tsData, ok = q.toResult(allNaN, nil, time.Now(), &mu, &rows, &bytes).Value.(*qbv5.TimeSeriesData)
|
||||
result, err = q.toResult(allNaN, nil, time.Now(), &mu, &rows, &bytes)
|
||||
require.NoError(t, err)
|
||||
tsData, ok = result.Value.(*qbv5.TimeSeriesData)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, tsData.Aggregations)
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
// We need to set if it is unspecified or adjust it if value is not within recommended range
|
||||
intervalWarnings := q.adjustStepInterval(req.CompositeQuery.Queries, req.Start, req.End)
|
||||
|
||||
missingMetricQueries, metricWarnings, err := q.resolveMetricMetadata(ctx, orgID, req.CompositeQuery.Queries, req.Start, req.End)
|
||||
missingMetricQueries, metricWarnings, err := q.resolveMetricMetadata(ctx, orgID, req.CompositeQuery.Queries, req.Start, req.End, req.RequestType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -177,7 +177,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
preseededResults := make(map[string]any)
|
||||
for _, name := range missingMetricQueries {
|
||||
switch req.RequestType {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
preseededResults[name] = &qbtypes.TimeSeriesData{QueryName: name}
|
||||
case qbtypes.RequestTypeScalar:
|
||||
preseededResults[name] = &qbtypes.ScalarData{QueryName: name}
|
||||
@@ -334,15 +334,22 @@ func (q *querier) buildQueries(
|
||||
if missingMetricQuerySet[spec.Name] {
|
||||
continue
|
||||
}
|
||||
requestType := req.RequestType
|
||||
if requestType == qbtypes.RequestTypeHeatmap && spec.Disabled {
|
||||
// A disabled query in a heatmap request feeds a formula, and the
|
||||
// formula converts time series into heatmap data, so its inputs
|
||||
// run as time series queries.
|
||||
requestType = qbtypes.RequestTypeTimeSeries
|
||||
}
|
||||
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
|
||||
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
|
||||
var bq *builderQuery[qbtypes.MetricAggregation]
|
||||
|
||||
if spec.Source == telemetrytypes.SourceMeter {
|
||||
event.Source = telemetrytypes.SourceMeter.StringValue()
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.meterStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.meterStmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, builderConfig{})
|
||||
} else {
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.metricStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.metricStmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, builderConfig{})
|
||||
}
|
||||
|
||||
queries[spec.Name] = bq
|
||||
@@ -415,7 +422,7 @@ func (q *querier) populateQBEvent(event *qbtypes.QBEvent, queries []qbtypes.Quer
|
||||
// resolved: never-seen metrics and dormant metrics (seen but no data in
|
||||
// the query window).
|
||||
// - err: Internal when a metadata fetch fails.
|
||||
func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID, queries []qbtypes.QueryEnvelope, start, end uint64) (missingMetricQueries []string, metricWarnings []string, err error) {
|
||||
func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID, queries []qbtypes.QueryEnvelope, start, end uint64, requestType qbtypes.RequestType) (missingMetricQueries []string, metricWarnings []string, err error) {
|
||||
metricNames := make([]string, 0)
|
||||
for idx := range queries {
|
||||
if queries[idx].Type != qbtypes.QueryTypeBuilder {
|
||||
@@ -473,6 +480,13 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
|
||||
if err := spec.Aggregations[i].ValidateForTypeAndTemporality(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Only the enabled query is used to render the heatmap, so bucket
|
||||
// options are only applied to the enabled query.
|
||||
if requestType == qbtypes.RequestTypeHeatmap && !spec.Disabled {
|
||||
if err := spec.Aggregations[i].VerifyAndApplyBucketOptions(spec.BucketOptions); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if reducedMetricsSet[spec.Aggregations[i].MetricName] {
|
||||
spec.Aggregations[i].Reduced = true
|
||||
}
|
||||
@@ -679,7 +693,7 @@ func (q *querier) run(
|
||||
if val, ok := result.Value.(*qbtypes.RawData); ok && val != nil {
|
||||
return len(val.Rows) != 0
|
||||
}
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
if val, ok := result.Value.(*qbtypes.TimeSeriesData); ok && val != nil {
|
||||
if len(val.Aggregations) != 0 {
|
||||
anyNonEmpty := false
|
||||
@@ -1000,7 +1014,7 @@ func (q *querier) mergeResults(cached *qbtypes.Result, fresh []*qbtypes.Result)
|
||||
|
||||
// Merge all fresh results including the first one
|
||||
switch merged.Type {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
// Pass nil as cached value to ensure proper merging of all fresh results
|
||||
merged.Value = q.mergeTimeSeriesResults(nil, fresh)
|
||||
}
|
||||
@@ -1023,7 +1037,7 @@ func (q *querier) mergeResults(cached *qbtypes.Result, fresh []*qbtypes.Result)
|
||||
}
|
||||
|
||||
switch merged.Type {
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
|
||||
merged.Value = q.mergeTimeSeriesResults(cached.Value.(*qbtypes.TimeSeriesData), fresh)
|
||||
}
|
||||
|
||||
@@ -1044,6 +1058,16 @@ func (q *querier) mergeResults(cached *qbtypes.Result, fresh []*qbtypes.Result)
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeBucketUpperBounds(cachedValue *qbtypes.TimeSeriesData, freshResults []*qbtypes.Result) map[int][]float64 {
|
||||
upperBoundSources := make([]*qbtypes.TimeSeriesData, 0, len(freshResults)+1)
|
||||
upperBoundSources = append(upperBoundSources, cachedValue)
|
||||
for _, result := range freshResults {
|
||||
freshTS, _ := result.Value.(*qbtypes.TimeSeriesData)
|
||||
upperBoundSources = append(upperBoundSources, freshTS)
|
||||
}
|
||||
return qbtypes.MergeBucketUpperBounds(upperBoundSources...)
|
||||
}
|
||||
|
||||
// mergeTimeSeriesResults merges time series data.
|
||||
func (q *querier) mergeTimeSeriesResults(cachedValue *qbtypes.TimeSeriesData, freshResults []*qbtypes.Result) *qbtypes.TimeSeriesData {
|
||||
|
||||
@@ -1052,12 +1076,15 @@ func (q *querier) mergeTimeSeriesResults(cachedValue *qbtypes.TimeSeriesData, fr
|
||||
// Map to store aggregation bucket metadata
|
||||
bucketMetadata := make(map[int]*qbtypes.AggregationBucket)
|
||||
|
||||
mergedUpperBounds := mergeBucketUpperBounds(cachedValue, freshResults)
|
||||
|
||||
// Process cached data if available
|
||||
if cachedValue != nil && cachedValue.Aggregations != nil {
|
||||
for _, aggBucket := range cachedValue.Aggregations {
|
||||
if seriesMap[aggBucket.Index] == nil {
|
||||
seriesMap[aggBucket.Index] = make(map[string]*qbtypes.TimeSeries)
|
||||
}
|
||||
aggBucket.ReindexValuesToNewUpperBounds(mergedUpperBounds[aggBucket.Index])
|
||||
if bucketMetadata[aggBucket.Index] == nil {
|
||||
bucketMetadata[aggBucket.Index] = aggBucket
|
||||
}
|
||||
@@ -1109,6 +1136,7 @@ func (q *querier) mergeTimeSeriesResults(cachedValue *qbtypes.TimeSeriesData, fr
|
||||
}
|
||||
|
||||
for _, aggBucket := range freshTS.Aggregations {
|
||||
aggBucket.ReindexValuesToNewUpperBounds(mergedUpperBounds[aggBucket.Index])
|
||||
for _, series := range aggBucket.Series {
|
||||
key := qbtypes.GetUniqueSeriesKey(series.Labels)
|
||||
|
||||
|
||||
@@ -830,12 +830,12 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
|
||||
|
||||
res.Items[idx].RelatedLogsLink = contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode()
|
||||
} else if rule.AlertType == ruletypes.AlertTypeTraces {
|
||||
} else if rule.AlertType == ruletypes.AlertTypeTraces || rule.AlertType == ruletypes.AlertTypeAITraces {
|
||||
// TODO(srikanthccv): re-visit this and support multiple queries
|
||||
var q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
|
||||
|
||||
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
|
||||
if query.Type == qbtypes.QueryTypeBuilder {
|
||||
if query.Type == qbtypes.QueryTypeBuilder || query.Type == qbtypes.QueryTypeBuilderAI {
|
||||
switch spec := query.Spec.(type) {
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
|
||||
q = spec
|
||||
@@ -849,7 +849,7 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
|
||||
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, whereClause).Encode()
|
||||
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, whereClause, rule.AlertType.BuilderQueryType()).Encode()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,7 +420,7 @@ func (r *BaseRule) ShouldSkipNewGroups() bool {
|
||||
func (r *BaseRule) isFilterNewSeriesSupported() bool {
|
||||
if r.ruleCondition.CompositeQuery.QueryType == ruletypes.QueryTypeBuilder {
|
||||
for _, query := range r.ruleCondition.CompositeQuery.Queries {
|
||||
if query.Type != qbtypes.QueryTypeBuilder {
|
||||
if query.Type != qbtypes.QueryTypeBuilder && query.Type != qbtypes.QueryTypeBuilderAI {
|
||||
continue
|
||||
}
|
||||
switch query.Spec.(type) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
@@ -164,6 +165,24 @@ type filterNewSeriesTestCase struct {
|
||||
expectError bool
|
||||
}
|
||||
|
||||
func TestBaseRule_IsFilterNewSeriesSupported(t *testing.T) {
|
||||
postableRule := createPostableRule(&ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
Type: qbtypes.QueryTypeBuilderAI,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "max(trace.total_tokens)"}},
|
||||
},
|
||||
}},
|
||||
})
|
||||
|
||||
rule, err := NewBaseRule("test-rule", valuer.GenerateUUID(), &postableRule, mustParseURL(t, "http://localhost:8080"), WithLogger(instrumentationtest.New().Logger()))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, rule.isFilterNewSeriesSupported())
|
||||
}
|
||||
|
||||
func TestBaseRule_FilterNewSeries(t *testing.T) {
|
||||
defaultEvalTime := time.Unix(1700000000, 0)
|
||||
defaultNewGroupEvalDelay := valuer.MustParseTextDuration("2m")
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/aistatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/logsstatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/metricsstatementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder/tracesstatementbuilder"
|
||||
@@ -127,3 +128,40 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
|
||||
0, // maxConcurrentQueries (0 means default)
|
||||
)
|
||||
}
|
||||
|
||||
func prepareQuerierForAITraces(t *testing.T, telemetryStore telemetrystore.TelemetryStore, keysMap map[string][]*telemetrytypes.TelemetryFieldKey) querier.Querier {
|
||||
t.Helper()
|
||||
|
||||
providerSettings := instrumentationtest.New().ToProviderSettings()
|
||||
metadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
|
||||
for _, keys := range keysMap {
|
||||
for _, key := range keys {
|
||||
key.Signal = telemetrytypes.SignalTraces
|
||||
}
|
||||
}
|
||||
metadataStore.KeysMap = keysMap
|
||||
|
||||
fl := flaggertest.New(t)
|
||||
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(context.Background(), providerSettings, statementbuilder.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
return querier.New(
|
||||
providerSettings,
|
||||
telemetryStore,
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // promV2
|
||||
nil, // traceStmtBuilder
|
||||
aiTraceStmtBuilder,
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
nil, // metricStmtBuilder
|
||||
nil, // meterStmtBuilder
|
||||
nil, // traceOperatorStmtBuilder
|
||||
nil, // bucketCache
|
||||
fl,
|
||||
0,
|
||||
0, // maxConcurrentQueries (0 means default)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time
|
||||
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
|
||||
|
||||
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause)
|
||||
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause, r.typ.BuilderQueryType())
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
|
||||
@@ -308,10 +308,14 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
// is used alert grouping, and we want to group alerts with the same
|
||||
// label set, but different timestamps, together.
|
||||
switch r.typ {
|
||||
case ruletypes.AlertTypeTraces:
|
||||
case ruletypes.AlertTypeTraces, ruletypes.AlertTypeAITraces:
|
||||
params := r.prepareParamsForTraces(ctx, ts, smpl.Metric)
|
||||
if len(params) > 0 {
|
||||
link := r.ExternalURL("traces-explorer", params)
|
||||
explorerPath := "traces-explorer"
|
||||
if r.typ == ruletypes.AlertTypeAITraces {
|
||||
explorerPath = "ai-observability/explorer"
|
||||
}
|
||||
link := r.ExternalURL(explorerPath, params)
|
||||
r.logger.InfoContext(ctx, "adding traces link to annotations", slog.String("annotation.link", link))
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedTraces, Value: link})
|
||||
}
|
||||
|
||||
@@ -916,6 +916,104 @@ func TestThresholdRuleTracesLink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestThresholdRuleAITracesLink(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "AI traces link test",
|
||||
AlertType: ruletypes.AlertTypeAITraces,
|
||||
RuleType: ruletypes.RuleTypeThreshold,
|
||||
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
|
||||
EvalWindow: valuer.MustParseTextDuration("5m"),
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
Type: qbtypes.QueryTypeBuilderAI,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
StepInterval: qbtypes.Step{Duration: time.Minute},
|
||||
Aggregations: []qbtypes.TraceAggregation{{
|
||||
Expression: "count()",
|
||||
}},
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "service.name = 'llm-gateway'",
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cols := make([]cmock.ColumnType, 0)
|
||||
cols = append(cols, cmock.ColumnType{Name: "value", Type: "Float64"})
|
||||
cols = append(cols, cmock.ColumnType{Name: "attr", Type: "String"})
|
||||
cols = append(cols, cmock.ColumnType{Name: "timestamp", Type: "DateTime"})
|
||||
|
||||
keysMap := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"service.name": {
|
||||
{
|
||||
Name: "service.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
logger := instrumentationtest.New().Logger()
|
||||
|
||||
for idx, c := range testCases {
|
||||
|
||||
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
|
||||
|
||||
rows := cmock.NewRows(cols, c.values)
|
||||
telemetryStore.Mock().
|
||||
ExpectQuery("SELECT any").
|
||||
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil).
|
||||
WillReturnRows(rows)
|
||||
|
||||
querier := prepareQuerierForAITraces(t, telemetryStore, keysMap)
|
||||
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = c.matchType
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
||||
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: postableRule.AlertName,
|
||||
TargetValue: &c.target,
|
||||
TargetUnit: c.targetUnit,
|
||||
MatchType: c.matchType,
|
||||
CompareOperator: c.compareOperator,
|
||||
},
|
||||
},
|
||||
}
|
||||
postableRule.Annotations = map[string]string{
|
||||
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
|
||||
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
|
||||
}
|
||||
|
||||
externalURL := mustParseURL(t, "http://localhost:8080")
|
||||
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger, externalURL)
|
||||
require.NoError(t, err, "case %d", idx)
|
||||
|
||||
alertsFound, err := rule.Eval(context.Background(), time.Now())
|
||||
require.NoError(t, err, "case %d", idx)
|
||||
|
||||
assert.Equal(t, c.expectAlerts, alertsFound, "case %d", idx)
|
||||
for _, item := range rule.Active {
|
||||
link := item.Annotations.Map()[ruletypes.AnnotationRelatedTraces]
|
||||
assert.True(t, strings.HasPrefix(link, "http://localhost:8080/ai-observability/explorer?"), "case %d: %s", idx, link)
|
||||
assert.Contains(t, link, "builder_ai_query", "case %d", idx)
|
||||
assert.Contains(t, link, "llm-gateway", "case %d", idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThresholdRuleLogsLink(t *testing.T) {
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: "Logs link test",
|
||||
|
||||
@@ -129,7 +129,7 @@ func (b *meterQueryStatementBuilder) buildPipelineStatement(
|
||||
}
|
||||
|
||||
// final SELECT
|
||||
return b.metricsStatementBuilder.BuildFinalSelect(cteFragments, cteArgs, query)
|
||||
return b.metricsStatementBuilder.BuildFinalSelect(cteFragments, cteArgs, qbtypes.RequestTypeTimeSeries, query)
|
||||
}
|
||||
|
||||
func (b *meterQueryStatementBuilder) buildTemporalAggDeltaFastPath(
|
||||
|
||||
@@ -4,10 +4,14 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/clickhousesql"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
@@ -114,7 +118,7 @@ func (b *StatementBuilder) Build(
|
||||
orgID valuer.UUID,
|
||||
start uint64,
|
||||
end uint64,
|
||||
_ qbtypes.RequestType,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
@@ -126,13 +130,14 @@ func (b *StatementBuilder) Build(
|
||||
|
||||
start, end = querybuilder.AdjustedMetricTimeRange(start, end, uint64(query.StepInterval.Seconds()), query)
|
||||
|
||||
return b.buildPipelineStatement(ctx, orgID, start, end, query, keys, variables)
|
||||
return b.buildPipelineStatement(ctx, orgID, start, end, requestType, query, keys, variables)
|
||||
}
|
||||
|
||||
func (b *StatementBuilder) buildPipelineStatement(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
@@ -145,7 +150,7 @@ func (b *StatementBuilder) buildPipelineStatement(
|
||||
cteQuery := query
|
||||
if query.Aggregations[0].Type == metrictypes.HistogramType {
|
||||
query.GroupBy = slices.DeleteFunc(slices.Clone(query.GroupBy), isHistogramBucket)
|
||||
cteQuery = histogramCTEQuery(query)
|
||||
cteQuery = rewriteQueryForHistogramCTE(requestType, query)
|
||||
}
|
||||
|
||||
agg := cteQuery.Aggregations[0]
|
||||
@@ -217,7 +222,7 @@ func (b *StatementBuilder) buildPipelineStatement(
|
||||
}
|
||||
}
|
||||
|
||||
mainStmt, err := b.BuildFinalSelect(cteFragments, cteArgs, query)
|
||||
mainStmt, err := b.BuildFinalSelect(cteFragments, cteArgs, requestType, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -225,13 +230,29 @@ func (b *StatementBuilder) buildPipelineStatement(
|
||||
if reducedFragments == nil {
|
||||
return mainStmt, nil
|
||||
}
|
||||
reducedStmt, err := b.BuildFinalSelect(reducedFragments, reducedArgs, query)
|
||||
reducedStmt, err := b.BuildFinalSelect(reducedFragments, reducedArgs, requestType, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unionStatements(mainStmt, reducedStmt, query)
|
||||
}
|
||||
|
||||
func rewriteQueryForHistogramCTE(requestType qbtypes.RequestType, query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]) qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation] {
|
||||
query.GroupBy = append(slices.Clone(query.GroupBy), qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: histogramBucketKey},
|
||||
})
|
||||
|
||||
query.Aggregations = slices.Clone(query.Aggregations)
|
||||
if query.Aggregations[0].SpaceAggregation.IsPercentile() && requestType != qbtypes.RequestTypeHeatmap {
|
||||
query.Aggregations[0].TimeAggregation = metrictypes.TimeAggregationRate
|
||||
} else {
|
||||
query.Aggregations[0].TimeAggregation = metrictypes.TimeAggregationIncrease
|
||||
}
|
||||
query.Aggregations[0].SpaceAggregation = metrictypes.SpaceAggregationSum
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
func unionStatements(main, reduced *qbtypes.Statement, query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]) (*qbtypes.Statement, error) {
|
||||
orderBy := "ts"
|
||||
for i, g := range query.GroupBy {
|
||||
@@ -761,11 +782,9 @@ func (b *StatementBuilder) buildSpatialAggregationCTE(
|
||||
func (b *StatementBuilder) BuildFinalSelect(
|
||||
cteFragments []string,
|
||||
cteArgs [][]any,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
metricType := query.Aggregations[0].Type
|
||||
spaceAgg := query.Aggregations[0].SpaceAggregation
|
||||
|
||||
combined := querybuilder.CombineCTEs(cteFragments)
|
||||
|
||||
var args []any
|
||||
@@ -773,6 +792,22 @@ func (b *StatementBuilder) BuildFinalSelect(
|
||||
args = append(args, a...)
|
||||
}
|
||||
|
||||
if requestType == qbtypes.RequestTypeHeatmap {
|
||||
return buildHeatmapFinalSelect(combined, args, query)
|
||||
}
|
||||
return buildAggregationFinalSelect(combined, args, query)
|
||||
}
|
||||
|
||||
// buildAggregationFinalSelect reads __spatial_aggregation_cte as one value per
|
||||
// (group, timestamp), which is what every request type but heatmap wants.
|
||||
func buildAggregationFinalSelect(
|
||||
combined string,
|
||||
args []any,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
metricType := query.Aggregations[0].Type
|
||||
spaceAgg := query.Aggregations[0].SpaceAggregation
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
if metricType == metrictypes.HistogramType && spaceAgg.IsPercentile() {
|
||||
@@ -845,24 +880,150 @@ func (b *StatementBuilder) BuildFinalSelect(
|
||||
return &qbtypes.Statement{Query: combined + q, Args: append(args, a...)}, nil
|
||||
}
|
||||
|
||||
const histogramBucketKey = "le"
|
||||
const (
|
||||
histogramBucketKey = "le"
|
||||
|
||||
heatmapValueAlias = "__result_0"
|
||||
heatmapWindow = "__heatmap_window"
|
||||
)
|
||||
|
||||
func isHistogramBucket(k qbtypes.GroupByKey) bool { return k.Name == histogramBucketKey }
|
||||
|
||||
func histogramCTEQuery(query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]) qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation] {
|
||||
query.GroupBy = append(slices.Clone(query.GroupBy), qbtypes.GroupByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: histogramBucketKey},
|
||||
})
|
||||
|
||||
query.Aggregations = slices.Clone(query.Aggregations)
|
||||
if query.Aggregations[0].SpaceAggregation.IsPercentile() {
|
||||
query.Aggregations[0].TimeAggregation = metrictypes.TimeAggregationRate
|
||||
} else {
|
||||
query.Aggregations[0].TimeAggregation = metrictypes.TimeAggregationIncrease
|
||||
// buildHeatmapFinalSelect turns __spatial_aggregation_cte into one row per
|
||||
// heatmap cell: (ts, group labels..., bucket upper bound, count).
|
||||
func buildHeatmapFinalSelect(
|
||||
combined string,
|
||||
args []any,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
if query.Aggregations[0].Type == metrictypes.HistogramType {
|
||||
return buildHistogramHeatmapFinalSelect(combined, args, query)
|
||||
}
|
||||
query.Aggregations[0].SpaceAggregation = metrictypes.SpaceAggregationSum
|
||||
return buildValueHeatmapFinalSelect(combined, args, query)
|
||||
}
|
||||
|
||||
return query
|
||||
// buildHistogramHeatmapFinalSelect differences the cumulative per-`le` counts in
|
||||
// __spatial_aggregation_cte into a count per bucket. A bucket runs from the `le`
|
||||
// below it up to its own, so the `le=+Inf` row reaches the reader as the
|
||||
// overflow and the lowest `le` as a bucket open below, which is where a
|
||||
// negative observation would have been counted.
|
||||
func buildHistogramHeatmapFinalSelect(
|
||||
combined string,
|
||||
args []any,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
groupAliases := GroupByAliases(query.GroupBy)
|
||||
partitionBy := append(append([]string{}, groupAliases...), "ts")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("ts")
|
||||
sb.SelectMore(groupAliases...)
|
||||
sb.SelectMore(fmt.Sprintf(
|
||||
"lagInFrame(toFloat64(%s), 1, toFloat64('-Inf')) OVER %s AS %s",
|
||||
histogramBucketKey, heatmapWindow, qbtypes.HeatmapBucketMinColumn,
|
||||
))
|
||||
sb.SelectMore(fmt.Sprintf("toFloat64(%s) AS %s", histogramBucketKey, qbtypes.HeatmapBucketMaxColumn))
|
||||
// a partial scrape can break monotonicity across `le`, and a negative cell
|
||||
// count has no meaning
|
||||
sb.SelectMore(fmt.Sprintf(
|
||||
"greatest(value - lagInFrame(value, 1, 0) OVER %s, 0) AS %s",
|
||||
heatmapWindow, heatmapValueAlias,
|
||||
))
|
||||
// sqlbuilder has no WINDOW clause; appending it to FROM lands it between FROM
|
||||
// and ORDER BY, since these statements carry no WHERE or GROUP BY
|
||||
sb.From(fmt.Sprintf(
|
||||
"__spatial_aggregation_cte WINDOW %s AS (PARTITION BY %s ORDER BY toFloat64(%s))",
|
||||
heatmapWindow, strings.Join(partitionBy, ", "), histogramBucketKey,
|
||||
))
|
||||
sb.OrderBy(groupAliases...)
|
||||
sb.OrderBy("ts", fmt.Sprintf("toFloat64(%s)", histogramBucketKey))
|
||||
|
||||
q, a := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return &qbtypes.Statement{Query: combined + q, Args: append(args, a...)}, nil
|
||||
}
|
||||
|
||||
// buildValueHeatmapFinalSelect places each spatially aggregated value in a
|
||||
// bucket of the requested axis. __spatial_aggregation_cte holds one row per
|
||||
// (group, timestamp), so every cell counts exactly one.
|
||||
func buildValueHeatmapFinalSelect(
|
||||
combined string,
|
||||
args []any,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
|
||||
) (*qbtypes.Statement, error) {
|
||||
bucketMin, bucketMax, err := renderHeatmapBucketExprs(*query.Aggregations[0].HeatmapBucketing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groupAliases := GroupByAliases(query.GroupBy)
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("ts")
|
||||
sb.SelectMore(groupAliases...)
|
||||
sb.SelectMore(fmt.Sprintf("%s AS %s", bucketMin, qbtypes.HeatmapBucketMinColumn))
|
||||
sb.SelectMore(fmt.Sprintf("%s AS %s", bucketMax, qbtypes.HeatmapBucketMaxColumn))
|
||||
sb.SelectMore(fmt.Sprintf("toFloat64(1) AS %s", heatmapValueAlias))
|
||||
sb.From("__spatial_aggregation_cte")
|
||||
sb.OrderBy(groupAliases...)
|
||||
sb.OrderBy("ts", qbtypes.HeatmapBucketMaxColumn)
|
||||
|
||||
q, a := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return &qbtypes.Statement{Query: combined + q, Args: append(args, a...)}, nil
|
||||
}
|
||||
|
||||
// renderHeatmapBucketExprs renders the bucket (min, max] that `value` falls in.
|
||||
// Only the bucket under everything the axis covers is open below, and only the
|
||||
// one over it is open above.
|
||||
func renderHeatmapBucketExprs(bucketing qbtypes.HeatmapBucketing) (minExpr, maxExpr string, err error) {
|
||||
switch bucketing.Kind {
|
||||
case qbtypes.BucketsKindLinear:
|
||||
return renderLinearBucketExprs(bucketing)
|
||||
case qbtypes.BucketsKindLog:
|
||||
return renderLogBucketExprs()
|
||||
default:
|
||||
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"unsupported bucketsScaling %q for heatmap requests", bucketing.Kind.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
func renderLinearBucketExprs(bucketing qbtypes.HeatmapBucketing) (string, string, error) {
|
||||
maxValue := formatFloat(bucketing.MaxValue)
|
||||
numBuckets := strconv.Itoa(bucketing.NumBuckets)
|
||||
index := fmt.Sprintf("least(greatest(ceil(value * %s / %s), 1), %s)", numBuckets, maxValue, numBuckets)
|
||||
|
||||
minExpr := fmt.Sprintf(
|
||||
"multiIf(value <= 0, toFloat64('-Inf'), value > %s, toFloat64(%s), (%s - 1) * %s / %s)",
|
||||
maxValue, maxValue, index, maxValue, numBuckets,
|
||||
)
|
||||
maxExpr := fmt.Sprintf(
|
||||
"multiIf(value <= 0, toFloat64(0), value > %s, toFloat64('+Inf'), %s * %s / %s)",
|
||||
maxValue, index, maxValue, numBuckets,
|
||||
)
|
||||
return minExpr, maxExpr, nil
|
||||
}
|
||||
|
||||
// ClickHouse buckets at MaxLogScale whatever HeatmapBucketing.LogScale asks for;
|
||||
// postprocessing folds the axis down afterwards.
|
||||
func renderLogBucketExprs() (string, string, error) {
|
||||
bucketsPerDoubling := formatFloat(math.Exp2(qbtypes.MaxLogScale))
|
||||
lowest := formatFloat(qbtypes.MinLogUpperBound)
|
||||
highest := formatFloat(qbtypes.MaxLogUpperBound)
|
||||
|
||||
minExpr := fmt.Sprintf(
|
||||
"multiIf(value <= 0, toFloat64('-Inf'), value <= %s, toFloat64(0), value > %s, toFloat64(%s), pow(2, (ceil(log2(value) * %s) - 1) / %s))",
|
||||
lowest, highest, highest, bucketsPerDoubling, bucketsPerDoubling,
|
||||
)
|
||||
maxExpr := fmt.Sprintf(
|
||||
"multiIf(value <= 0, toFloat64(0), value <= %s, %s, value > %s, toFloat64('+Inf'), pow(2, ceil(log2(value) * %s) / %s))",
|
||||
lowest, lowest, highest, bucketsPerDoubling, bucketsPerDoubling,
|
||||
)
|
||||
return minExpr, maxExpr, nil
|
||||
}
|
||||
|
||||
// formatFloat renders a float64 as the shortest literal that reads back as the
|
||||
// same value, so an upper bound computed from it is identical on every row.
|
||||
func formatFloat(v float64) string {
|
||||
return strconv.FormatFloat(v, 'g', -1, 64)
|
||||
}
|
||||
|
||||
func GroupByColumnAlias(i int, name string) string {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user