mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-14 22:20:49 +01:00
Compare commits
11 Commits
test/explo
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82a2cca032 | ||
|
|
3514264657 | ||
|
|
970681b834 | ||
|
|
cc8440248b | ||
|
|
e84ddfb61e | ||
|
|
dc9fda2916 | ||
|
|
670677bae2 | ||
|
|
e4d66c5413 | ||
|
|
1371cb0beb | ||
|
|
140498cb11 | ||
|
|
ba01bc0418 |
@@ -17,3 +17,4 @@ For the Python integration suite under `tests/`. Setup, running, and suite layou
|
||||
- **snake_case parametrize ids.** `ids=["fill_gaps", "fill_zero"]`, not camelCase.
|
||||
- **Name suite files with the two-digit prefix (`NN_*.py`).** `pyproject.toml` restricts collection to `[0-9][0-9]_*.py` (plus the bootstrap `setup.py` / `run.py`) — a file that doesn't match is silently never collected.
|
||||
- **Always run pytest from `tests/`.** `--import-mode=importlib` is what allows same-basename files across suites (`querier/01_logs.py` vs `rawexportdata/01_logs.py`), but it disables pytest's implicit `sys.path` injection — `import fixtures` only resolves via `pythonpath = ["."]` from that rootdir.
|
||||
- **After changing any `.py` file, run `make py-fmt` and `make py-lint`** from the repo root and fix what they report.
|
||||
|
||||
@@ -1,5 +1,219 @@
|
||||
components:
|
||||
schemas:
|
||||
AiobservabilitytypesBlobPart:
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
mime_type:
|
||||
nullable: true
|
||||
type: string
|
||||
modality:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- modality
|
||||
- content
|
||||
type: object
|
||||
AiobservabilitytypesChatMessage:
|
||||
properties:
|
||||
name:
|
||||
nullable: true
|
||||
type: string
|
||||
parts:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesParts'
|
||||
role:
|
||||
type: string
|
||||
required:
|
||||
- role
|
||||
- parts
|
||||
type: object
|
||||
AiobservabilitytypesCompactionPart:
|
||||
properties:
|
||||
content:
|
||||
nullable: true
|
||||
type: string
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
AiobservabilitytypesFilePart:
|
||||
properties:
|
||||
file_id:
|
||||
type: string
|
||||
mime_type:
|
||||
nullable: true
|
||||
type: string
|
||||
modality:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- modality
|
||||
- file_id
|
||||
type: object
|
||||
AiobservabilitytypesGenericPart:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
AiobservabilitytypesGenericServerToolCall:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
AiobservabilitytypesGenericServerToolCallResponse:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
AiobservabilitytypesInputMessages:
|
||||
items:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesChatMessage'
|
||||
type: array
|
||||
AiobservabilitytypesOutputMessage:
|
||||
properties:
|
||||
finish_reason:
|
||||
nullable: true
|
||||
type: string
|
||||
name:
|
||||
nullable: true
|
||||
type: string
|
||||
parts:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesParts'
|
||||
role:
|
||||
type: string
|
||||
required:
|
||||
- role
|
||||
- parts
|
||||
type: object
|
||||
AiobservabilitytypesOutputMessages:
|
||||
items:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesOutputMessage'
|
||||
type: array
|
||||
AiobservabilitytypesPart:
|
||||
discriminator:
|
||||
mapping:
|
||||
blob: '#/components/schemas/AiobservabilitytypesBlobPart'
|
||||
compaction: '#/components/schemas/AiobservabilitytypesCompactionPart'
|
||||
file: '#/components/schemas/AiobservabilitytypesFilePart'
|
||||
reasoning: '#/components/schemas/AiobservabilitytypesReasoningPart'
|
||||
server_tool_call: '#/components/schemas/AiobservabilitytypesServerToolCallPart'
|
||||
server_tool_call_response: '#/components/schemas/AiobservabilitytypesServerToolCallResponsePart'
|
||||
text: '#/components/schemas/AiobservabilitytypesTextPart'
|
||||
tool_call: '#/components/schemas/AiobservabilitytypesToolCallRequestPart'
|
||||
tool_call_response: '#/components/schemas/AiobservabilitytypesToolCallResponsePart'
|
||||
uri: '#/components/schemas/AiobservabilitytypesUriPart'
|
||||
propertyName: type
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesTextPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesToolCallRequestPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesToolCallResponsePart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesServerToolCallPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesServerToolCallResponsePart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesBlobPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesFilePart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesUriPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesReasoningPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesCompactionPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesGenericPart'
|
||||
type: object
|
||||
AiobservabilitytypesParts:
|
||||
items:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesPart'
|
||||
type: array
|
||||
AiobservabilitytypesReasoningPart:
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- content
|
||||
type: object
|
||||
AiobservabilitytypesServerToolCallPart:
|
||||
properties:
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
server_tool_call:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesGenericServerToolCall'
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
- server_tool_call
|
||||
type: object
|
||||
AiobservabilitytypesServerToolCallResponsePart:
|
||||
properties:
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
server_tool_call_response:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesGenericServerToolCallResponse'
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- server_tool_call_response
|
||||
type: object
|
||||
AiobservabilitytypesTextPart:
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- content
|
||||
type: object
|
||||
AiobservabilitytypesToolCallRequestPart:
|
||||
properties:
|
||||
arguments: {}
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
type: object
|
||||
AiobservabilitytypesToolCallResponsePart:
|
||||
properties:
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
response:
|
||||
nullable: true
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- response
|
||||
type: object
|
||||
AiobservabilitytypesUriPart:
|
||||
properties:
|
||||
mime_type:
|
||||
nullable: true
|
||||
type: string
|
||||
modality:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- modality
|
||||
- uri
|
||||
type: object
|
||||
AlertmanagertypesChannel:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -3367,6 +3581,69 @@ components:
|
||||
repeatVariable:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesAIBuilderQuerySpec:
|
||||
properties:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
type: boolean
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
functions:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Function'
|
||||
nullable: true
|
||||
type: array
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
having:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Having'
|
||||
legend:
|
||||
type: string
|
||||
limit:
|
||||
type: integer
|
||||
limitBy:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LimitBy'
|
||||
name:
|
||||
type: string
|
||||
offset:
|
||||
type: integer
|
||||
order:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
nullable: true
|
||||
type: array
|
||||
secondaryAggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5SecondaryAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
selectFields:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
signal:
|
||||
enum:
|
||||
- traces
|
||||
type: string
|
||||
source:
|
||||
$ref: '#/components/schemas/TelemetrytypesSource'
|
||||
stepInterval:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Step'
|
||||
required:
|
||||
- signal
|
||||
type: object
|
||||
DashboardtypesAreaChartAppearance:
|
||||
properties:
|
||||
fillMode:
|
||||
@@ -4420,6 +4697,7 @@ components:
|
||||
DashboardtypesQueryPlugin:
|
||||
discriminator:
|
||||
mapping:
|
||||
signoz/AIBuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
|
||||
signoz/BuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
|
||||
signoz/ClickHouseSQL: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
|
||||
signoz/CompositeQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
|
||||
@@ -4429,6 +4707,7 @@ components:
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
|
||||
@@ -4438,12 +4717,25 @@ components:
|
||||
DashboardtypesQueryPluginKind:
|
||||
enum:
|
||||
- signoz/BuilderQuery
|
||||
- signoz/AIBuilderQuery
|
||||
- signoz/CompositeQuery
|
||||
- signoz/Formula
|
||||
- signoz/PromQLQuery
|
||||
- signoz/ClickHouseSQL
|
||||
- signoz/TraceOperator
|
||||
type: string
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/AIBuilderQuery
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesAIBuilderQuerySpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -10225,6 +10517,16 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesEvent'
|
||||
type: array
|
||||
formatted_input:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesInputMessages'
|
||||
formatted_output:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesOutputMessages'
|
||||
formatter:
|
||||
type: string
|
||||
formatter_warnings:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
has_error:
|
||||
type: boolean
|
||||
kind_string:
|
||||
@@ -10267,6 +10569,10 @@ components:
|
||||
- attributes
|
||||
- events
|
||||
- references
|
||||
- formatted_input
|
||||
- formatted_output
|
||||
- formatter
|
||||
- formatter_warnings
|
||||
type: object
|
||||
SpantypesTraceAISummary:
|
||||
properties:
|
||||
@@ -16134,9 +16440,12 @@ paths:
|
||||
/api/v1/traces/{traceID}/thread:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the spans carrying gen_ai input or output messages in timestamp
|
||||
order. Pass nextCursor as after or prevCursor as before to page, or spanId
|
||||
to open the page around a span.
|
||||
description: Returns the spans carrying gen_ai input or output messages, or
|
||||
a tool execution, in timestamp order. Messages already in the OTel GenAI shape
|
||||
are decoded into formatted_input and formatted_output, with formatter naming
|
||||
the converter and formatter_warnings what it could not resolve. Pass nextCursor
|
||||
as after or prevCursor as before to page, or spanId to open the page around
|
||||
a span.
|
||||
operationId: GetTraceThread
|
||||
parameters:
|
||||
- description: Page size, at most 100. 0 means 20.
|
||||
|
||||
@@ -4,6 +4,264 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
export enum AiobservabilitytypesBlobPartDTOType {
|
||||
blob = 'blob',
|
||||
}
|
||||
export interface AiobservabilitytypesBlobPartDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
mime_type?: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
modality: string;
|
||||
/**
|
||||
* @type string
|
||||
* @enum blob
|
||||
*/
|
||||
type: AiobservabilitytypesBlobPartDTOType;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesTextPartDTOType {
|
||||
text = 'text',
|
||||
}
|
||||
export interface AiobservabilitytypesTextPartDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* @type string
|
||||
* @enum text
|
||||
*/
|
||||
type: AiobservabilitytypesTextPartDTOType;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesToolCallRequestPartDTOType {
|
||||
tool_call = 'tool_call',
|
||||
}
|
||||
export interface AiobservabilitytypesToolCallRequestPartDTO {
|
||||
arguments?: unknown;
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
id?: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @enum tool_call
|
||||
*/
|
||||
type: AiobservabilitytypesToolCallRequestPartDTOType;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesToolCallResponsePartDTOType {
|
||||
tool_call_response = 'tool_call_response',
|
||||
}
|
||||
export type AiobservabilitytypesToolCallResponsePartDTOResponse =
|
||||
| unknown
|
||||
| null;
|
||||
|
||||
export interface AiobservabilitytypesToolCallResponsePartDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
id?: string | null;
|
||||
/**
|
||||
* @nullable true
|
||||
*/
|
||||
response: AiobservabilitytypesToolCallResponsePartDTOResponse;
|
||||
/**
|
||||
* @type string
|
||||
* @enum tool_call_response
|
||||
*/
|
||||
type: AiobservabilitytypesToolCallResponsePartDTOType;
|
||||
}
|
||||
|
||||
export interface AiobservabilitytypesGenericServerToolCallDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesServerToolCallPartDTOType {
|
||||
server_tool_call = 'server_tool_call',
|
||||
}
|
||||
export interface AiobservabilitytypesServerToolCallPartDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
id?: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
server_tool_call: AiobservabilitytypesGenericServerToolCallDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @enum server_tool_call
|
||||
*/
|
||||
type: AiobservabilitytypesServerToolCallPartDTOType;
|
||||
}
|
||||
|
||||
export interface AiobservabilitytypesGenericServerToolCallResponseDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesServerToolCallResponsePartDTOType {
|
||||
server_tool_call_response = 'server_tool_call_response',
|
||||
}
|
||||
export interface AiobservabilitytypesServerToolCallResponsePartDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
id?: string | null;
|
||||
server_tool_call_response: AiobservabilitytypesGenericServerToolCallResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @enum server_tool_call_response
|
||||
*/
|
||||
type: AiobservabilitytypesServerToolCallResponsePartDTOType;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesFilePartDTOType {
|
||||
file = 'file',
|
||||
}
|
||||
export interface AiobservabilitytypesFilePartDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
file_id: string;
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
mime_type?: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
modality: string;
|
||||
/**
|
||||
* @type string
|
||||
* @enum file
|
||||
*/
|
||||
type: AiobservabilitytypesFilePartDTOType;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesUriPartDTOType {
|
||||
uri = 'uri',
|
||||
}
|
||||
export interface AiobservabilitytypesUriPartDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
mime_type?: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
modality: string;
|
||||
/**
|
||||
* @type string
|
||||
* @enum uri
|
||||
*/
|
||||
type: AiobservabilitytypesUriPartDTOType;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesReasoningPartDTOType {
|
||||
reasoning = 'reasoning',
|
||||
}
|
||||
export interface AiobservabilitytypesReasoningPartDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* @type string
|
||||
* @enum reasoning
|
||||
*/
|
||||
type: AiobservabilitytypesReasoningPartDTOType;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesCompactionPartDTOType {
|
||||
compaction = 'compaction',
|
||||
}
|
||||
export interface AiobservabilitytypesCompactionPartDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
content?: string | null;
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
id?: string | null;
|
||||
/**
|
||||
* @type string
|
||||
* @enum compaction
|
||||
*/
|
||||
type: AiobservabilitytypesCompactionPartDTOType;
|
||||
}
|
||||
|
||||
export interface AiobservabilitytypesGenericPartDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type AiobservabilitytypesPartDTO =
|
||||
| AiobservabilitytypesTextPartDTO
|
||||
| AiobservabilitytypesToolCallRequestPartDTO
|
||||
| AiobservabilitytypesToolCallResponsePartDTO
|
||||
| AiobservabilitytypesServerToolCallPartDTO
|
||||
| AiobservabilitytypesServerToolCallResponsePartDTO
|
||||
| AiobservabilitytypesBlobPartDTO
|
||||
| AiobservabilitytypesFilePartDTO
|
||||
| AiobservabilitytypesUriPartDTO
|
||||
| AiobservabilitytypesReasoningPartDTO
|
||||
| AiobservabilitytypesCompactionPartDTO
|
||||
| AiobservabilitytypesGenericPartDTO;
|
||||
|
||||
export type AiobservabilitytypesPartsDTO = AiobservabilitytypesPartDTO[];
|
||||
|
||||
export interface AiobservabilitytypesChatMessageDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
name?: string | null;
|
||||
parts: AiobservabilitytypesPartsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
role: string;
|
||||
}
|
||||
|
||||
export type AiobservabilitytypesInputMessagesDTO =
|
||||
AiobservabilitytypesChatMessageDTO[];
|
||||
|
||||
export interface AiobservabilitytypesOutputMessageDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
finish_reason?: string | null;
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
name?: string | null;
|
||||
parts: AiobservabilitytypesPartsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
role: string;
|
||||
}
|
||||
|
||||
export type AiobservabilitytypesOutputMessagesDTO =
|
||||
AiobservabilitytypesOutputMessageDTO[];
|
||||
|
||||
export interface AlertmanagertypesChannelDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -4211,195 +4469,10 @@ export interface DashboardGridLayoutSpecDTO {
|
||||
repeatVariable?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesAreaFillModeDTO {
|
||||
solid = 'solid',
|
||||
gradient = 'gradient',
|
||||
export enum DashboardtypesAIBuilderQuerySpecDTOSignal {
|
||||
traces = 'traces',
|
||||
}
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 1
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesFillOpacityDTO = number | null;
|
||||
|
||||
export enum DashboardtypesLineInterpolationDTO {
|
||||
linear = 'linear',
|
||||
spline = 'spline',
|
||||
step_after = 'step_after',
|
||||
step_before = 'step_before',
|
||||
}
|
||||
export enum DashboardtypesLineStyleDTO {
|
||||
solid = 'solid',
|
||||
dashed = 'dashed',
|
||||
}
|
||||
export interface DashboardtypesSpanGapsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
|
||||
*/
|
||||
fillLessThan?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
|
||||
*/
|
||||
fillOnlyBelow?: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAreaChartAppearanceDTO {
|
||||
fillMode?: DashboardtypesAreaFillModeDTO;
|
||||
fillOpacity?: DashboardtypesFillOpacityDTO | null;
|
||||
lineInterpolation?: DashboardtypesLineInterpolationDTO;
|
||||
lineStyle?: DashboardtypesLineStyleDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
showPoints?: boolean;
|
||||
spanGaps?: DashboardtypesSpanGapsDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAxesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
isLogScale?: boolean;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
softMax?: number | null;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
softMin?: number | null;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPrecisionOptionDTO {
|
||||
NUMBER_0 = '0',
|
||||
NUMBER_1 = '1',
|
||||
NUMBER_2 = '2',
|
||||
NUMBER_3 = '3',
|
||||
NUMBER_4 = '4',
|
||||
full = 'full',
|
||||
}
|
||||
export interface DashboardtypesPanelFormattingDTO {
|
||||
decimalPrecision?: DashboardtypesPrecisionOptionDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesLegendModeDTO {
|
||||
list = 'list',
|
||||
}
|
||||
export enum DashboardtypesLegendPositionDTO {
|
||||
bottom = 'bottom',
|
||||
right = 'right',
|
||||
}
|
||||
export type DashboardtypesLegendDTOCustomColorsAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesLegendDTOCustomColors =
|
||||
DashboardtypesLegendDTOCustomColorsAnyOf | null;
|
||||
|
||||
export interface DashboardtypesLegendDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
customColors?: DashboardtypesLegendDTOCustomColors;
|
||||
mode?: DashboardtypesLegendModeDTO;
|
||||
position?: DashboardtypesLegendPositionDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesThresholdWithLabelDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
color: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
|
||||
export enum DashboardtypesStackModeDTO {
|
||||
none = 'none',
|
||||
normal = 'normal',
|
||||
percent = 'percent',
|
||||
}
|
||||
export enum DashboardtypesTimePreferenceDTO {
|
||||
global_time = 'global_time',
|
||||
last_5_min = 'last_5_min',
|
||||
last_15_min = 'last_15_min',
|
||||
last_30_min = 'last_30_min',
|
||||
last_1_hr = 'last_1_hr',
|
||||
last_6_hr = 'last_6_hr',
|
||||
last_1_day = 'last_1_day',
|
||||
last_3_days = 'last_3_days',
|
||||
last_1_week = 'last_1_week',
|
||||
last_1_month = 'last_1_month',
|
||||
}
|
||||
export interface DashboardtypesAreaChartVisualizationDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
fillSpans?: boolean;
|
||||
stack?: DashboardtypesStackModeDTO;
|
||||
timePreference?: DashboardtypesTimePreferenceDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAreaChartPanelSpecDTO {
|
||||
axes?: DashboardtypesAxesDTO;
|
||||
chartAppearance?: DashboardtypesAreaChartAppearanceDTO;
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
|
||||
visualization?: DashboardtypesAreaChartVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesBarChartVisualizationDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
fillSpans?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
stackedBarChart?: boolean;
|
||||
timePreference?: DashboardtypesTimePreferenceDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesBarChartPanelSpecDTO {
|
||||
axes?: DashboardtypesAxesDTO;
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
|
||||
visualization?: DashboardtypesBarChartVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesBasicVisualizationDTO {
|
||||
timePreference?: DashboardtypesTimePreferenceDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5LogAggregationDTO {
|
||||
export interface Querybuildertypesv5TraceAggregationDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4638,13 +4711,274 @@ export interface TelemetrytypesTelemetryFieldKeyDTO {
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal {
|
||||
logs = 'logs',
|
||||
}
|
||||
export enum TelemetrytypesSourceDTO {
|
||||
meter = 'meter',
|
||||
'' = '',
|
||||
}
|
||||
export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
disabled?: boolean;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
functions?: Querybuildertypesv5FunctionDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
having?: Querybuildertypesv5HavingDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
limit?: number;
|
||||
limitBy?: Querybuildertypesv5LimitByDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
order?: Querybuildertypesv5OrderByDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
|
||||
/**
|
||||
* @enum traces
|
||||
* @type string
|
||||
*/
|
||||
signal: DashboardtypesAIBuilderQuerySpecDTOSignal;
|
||||
source?: TelemetrytypesSourceDTO;
|
||||
stepInterval?: Querybuildertypesv5StepDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesAreaFillModeDTO {
|
||||
solid = 'solid',
|
||||
gradient = 'gradient',
|
||||
}
|
||||
/**
|
||||
* @minimum 0
|
||||
* @maximum 1
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesFillOpacityDTO = number | null;
|
||||
|
||||
export enum DashboardtypesLineInterpolationDTO {
|
||||
linear = 'linear',
|
||||
spline = 'spline',
|
||||
step_after = 'step_after',
|
||||
step_before = 'step_before',
|
||||
}
|
||||
export enum DashboardtypesLineStyleDTO {
|
||||
solid = 'solid',
|
||||
dashed = 'dashed',
|
||||
}
|
||||
export interface DashboardtypesSpanGapsDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected.
|
||||
*/
|
||||
fillLessThan?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
* @description Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected.
|
||||
*/
|
||||
fillOnlyBelow?: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAreaChartAppearanceDTO {
|
||||
fillMode?: DashboardtypesAreaFillModeDTO;
|
||||
fillOpacity?: DashboardtypesFillOpacityDTO | null;
|
||||
lineInterpolation?: DashboardtypesLineInterpolationDTO;
|
||||
lineStyle?: DashboardtypesLineStyleDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
showPoints?: boolean;
|
||||
spanGaps?: DashboardtypesSpanGapsDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAxesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
isLogScale?: boolean;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
softMax?: number | null;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
softMin?: number | null;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPrecisionOptionDTO {
|
||||
NUMBER_0 = '0',
|
||||
NUMBER_1 = '1',
|
||||
NUMBER_2 = '2',
|
||||
NUMBER_3 = '3',
|
||||
NUMBER_4 = '4',
|
||||
full = 'full',
|
||||
}
|
||||
export interface DashboardtypesPanelFormattingDTO {
|
||||
decimalPrecision?: DashboardtypesPrecisionOptionDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesLegendModeDTO {
|
||||
list = 'list',
|
||||
}
|
||||
export enum DashboardtypesLegendPositionDTO {
|
||||
bottom = 'bottom',
|
||||
right = 'right',
|
||||
}
|
||||
export type DashboardtypesLegendDTOCustomColorsAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesLegendDTOCustomColors =
|
||||
DashboardtypesLegendDTOCustomColorsAnyOf | null;
|
||||
|
||||
export interface DashboardtypesLegendDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
customColors?: DashboardtypesLegendDTOCustomColors;
|
||||
mode?: DashboardtypesLegendModeDTO;
|
||||
position?: DashboardtypesLegendPositionDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesThresholdWithLabelDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
color: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
|
||||
export enum DashboardtypesStackModeDTO {
|
||||
none = 'none',
|
||||
normal = 'normal',
|
||||
percent = 'percent',
|
||||
}
|
||||
export enum DashboardtypesTimePreferenceDTO {
|
||||
global_time = 'global_time',
|
||||
last_5_min = 'last_5_min',
|
||||
last_15_min = 'last_15_min',
|
||||
last_30_min = 'last_30_min',
|
||||
last_1_hr = 'last_1_hr',
|
||||
last_6_hr = 'last_6_hr',
|
||||
last_1_day = 'last_1_day',
|
||||
last_3_days = 'last_3_days',
|
||||
last_1_week = 'last_1_week',
|
||||
last_1_month = 'last_1_month',
|
||||
}
|
||||
export interface DashboardtypesAreaChartVisualizationDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
fillSpans?: boolean;
|
||||
stack?: DashboardtypesStackModeDTO;
|
||||
timePreference?: DashboardtypesTimePreferenceDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAreaChartPanelSpecDTO {
|
||||
axes?: DashboardtypesAxesDTO;
|
||||
chartAppearance?: DashboardtypesAreaChartAppearanceDTO;
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
|
||||
visualization?: DashboardtypesAreaChartVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesBarChartVisualizationDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
fillSpans?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
stackedBarChart?: boolean;
|
||||
timePreference?: DashboardtypesTimePreferenceDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesBarChartPanelSpecDTO {
|
||||
axes?: DashboardtypesAxesDTO;
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
|
||||
visualization?: DashboardtypesBarChartVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesBasicVisualizationDTO {
|
||||
timePreference?: DashboardtypesTimePreferenceDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5LogAggregationDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
alias?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal {
|
||||
logs = 'logs',
|
||||
}
|
||||
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -4835,17 +5169,6 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
stepInterval?: Querybuildertypesv5StepDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5TraceAggregationDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
alias?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal {
|
||||
traces = 'traces',
|
||||
}
|
||||
@@ -5404,6 +5727,18 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesBuilderQuerySpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind {
|
||||
'signoz/AIBuilderQuery' = 'signoz/AIBuilderQuery',
|
||||
}
|
||||
export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTO {
|
||||
/**
|
||||
* @enum signoz/AIBuilderQuery
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind;
|
||||
spec: DashboardtypesAIBuilderQuerySpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind {
|
||||
'signoz/CompositeQuery' = 'signoz/CompositeQuery',
|
||||
}
|
||||
@@ -5695,6 +6030,7 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQu
|
||||
|
||||
export type DashboardtypesQueryPluginDTO =
|
||||
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTO
|
||||
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTO
|
||||
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTO
|
||||
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormulaDTO
|
||||
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQueryDTO
|
||||
@@ -6373,6 +6709,7 @@ export interface DashboardtypesPostablePublicDashboardDTO {
|
||||
|
||||
export enum DashboardtypesQueryPluginKindDTO {
|
||||
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
|
||||
'signoz/AIBuilderQuery' = 'signoz/AIBuilderQuery',
|
||||
'signoz/CompositeQuery' = 'signoz/CompositeQuery',
|
||||
'signoz/Formula' = 'signoz/Formula',
|
||||
'signoz/PromQLQuery' = 'signoz/PromQLQuery',
|
||||
@@ -11452,6 +11789,16 @@ export interface SpantypesThreadSpanDTO {
|
||||
* @type array
|
||||
*/
|
||||
events: SpantypesEventDTO[];
|
||||
formatted_input: AiobservabilitytypesInputMessagesDTO;
|
||||
formatted_output: AiobservabilitytypesOutputMessagesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
formatter: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
formatter_warnings: string[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
|
||||
@@ -261,7 +261,7 @@ export const invalidateGetTraceSummary = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
|
||||
* Returns the spans carrying gen_ai input or output messages, or a tool execution, in timestamp order. Messages already in the OTel GenAI shape are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const getTraceThread = (
|
||||
|
||||
@@ -4,7 +4,11 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Formula } from 'container/QueryBuilder/components/Formula';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { getBuilderVariant } from 'lib/query/queryMode';
|
||||
import {
|
||||
BuilderVariant,
|
||||
IBuilderTraceOperator,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { QueryBuilderField } from './queryBuilderFields.types';
|
||||
@@ -60,8 +64,16 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
[config],
|
||||
);
|
||||
|
||||
const isAIQuery =
|
||||
getBuilderVariant(currentQuery.builder) === BuilderVariant.AI;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentDataSource !== initialDataSource || newPanelType !== panelType) {
|
||||
// AI queries are traces-only; BAR must not force metrics on them.
|
||||
if (isAIQuery) {
|
||||
handleSetConfig(newPanelType, DataSource.TRACES);
|
||||
return;
|
||||
}
|
||||
if (newPanelType === PANEL_TYPES.BAR) {
|
||||
handleSetConfig(PANEL_TYPES.BAR, DataSource.METRICS);
|
||||
return;
|
||||
@@ -74,15 +86,20 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
initialDataSource,
|
||||
currentDataSource,
|
||||
newPanelType,
|
||||
isAIQuery,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// always clear on mount and unmount to avoid stale data
|
||||
// hosts opting into savePreviousQuery own the clear, as they may remount this builder mid-edit
|
||||
if (savePreviousQuery) {
|
||||
return undefined;
|
||||
}
|
||||
clearPreviousQuery();
|
||||
return (): void => {
|
||||
clearPreviousQuery();
|
||||
};
|
||||
}, []);
|
||||
}, [savePreviousQuery]);
|
||||
|
||||
const resolvedConfig = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -30,7 +30,10 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
|
||||
> = {};
|
||||
const builderQueryTypes: Record<
|
||||
string,
|
||||
'builder_query' | 'builder_formula' | 'builder_trace_operator'
|
||||
| 'builder_query'
|
||||
| 'builder_ai_query'
|
||||
| 'builder_formula'
|
||||
| 'builder_trace_operator'
|
||||
> = {};
|
||||
const promQueries: IPromQLQuery[] = [];
|
||||
const clickhouseQueries: IClickHouseQuery[] = [];
|
||||
@@ -44,6 +47,14 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
|
||||
);
|
||||
builderQueryTypes[spec.name] = 'builder_query';
|
||||
}
|
||||
} else if (q.type === 'builder_ai_query') {
|
||||
if (spec.name) {
|
||||
builderQueries[spec.name] = {
|
||||
...convertBuilderQueryToIBuilderQuery(spec as BuilderQuery),
|
||||
builderQueryType: 'builder_ai_query',
|
||||
};
|
||||
builderQueryTypes[spec.name] = 'builder_ai_query';
|
||||
}
|
||||
} else if (q.type === 'builder_formula') {
|
||||
if (spec.name) {
|
||||
builderQueries[spec.name] = convertQueryBuilderFormulaToIBuilderFormula(
|
||||
|
||||
@@ -15,7 +15,10 @@ export const transformQueryBuilderDataModel = (
|
||||
data: BuilderQueryDataResourse,
|
||||
queryTypes?: Record<
|
||||
string,
|
||||
'builder_query' | 'builder_formula' | 'builder_trace_operator'
|
||||
| 'builder_query'
|
||||
| 'builder_ai_query'
|
||||
| 'builder_formula'
|
||||
| 'builder_trace_operator'
|
||||
>,
|
||||
): QueryBuilderData => {
|
||||
const queryData: QueryBuilderData['queryData'] = [];
|
||||
|
||||
53
frontend/src/lib/query/__tests__/builderEnvelope.test.ts
Normal file
53
frontend/src/lib/query/__tests__/builderEnvelope.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Querybuildertypesv5QueryEnvelopeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
isAIBuilderEnvelope,
|
||||
isBuilderEnvelope,
|
||||
isBuilderPluginKind,
|
||||
} from '../builderEnvelope';
|
||||
|
||||
const envelope = (type: string): Querybuildertypesv5QueryEnvelopeDTO =>
|
||||
({ type, spec: {} }) as unknown as Querybuildertypesv5QueryEnvelopeDTO;
|
||||
|
||||
describe('builder envelope predicates', () => {
|
||||
describe('isBuilderEnvelope', () => {
|
||||
it.each(['builder_query', 'builder_ai_query'])('accepts %s', (type) => {
|
||||
expect(isBuilderEnvelope(envelope(type))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'builder_formula',
|
||||
'builder_trace_operator',
|
||||
'promql',
|
||||
'clickhouse_sql',
|
||||
])('rejects %s', (type) => {
|
||||
expect(isBuilderEnvelope(envelope(type))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAIBuilderEnvelope', () => {
|
||||
it('accepts builder_ai_query', () => {
|
||||
expect(isAIBuilderEnvelope(envelope('builder_ai_query'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects builder_query', () => {
|
||||
expect(isAIBuilderEnvelope(envelope('builder_query'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBuilderPluginKind', () => {
|
||||
it.each(['signoz/BuilderQuery', 'signoz/AIBuilderQuery'])(
|
||||
'accepts %s',
|
||||
(kind) => {
|
||||
expect(isBuilderPluginKind(kind)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['signoz/CompositeQuery', 'signoz/PromQLQuery'])(
|
||||
'rejects %s',
|
||||
(kind) => {
|
||||
expect(isBuilderPluginKind(kind)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,16 @@ describe('panelTypeDataSourceFormValuesMap', () => {
|
||||
expect(uncovered).toStrictEqual([]);
|
||||
});
|
||||
|
||||
// The AI tab is derived from this tag, so a panel that drops it on a switch falls
|
||||
// back to plain Query Builder mid-edit.
|
||||
it('carries the AI query tag into every panel type', () => {
|
||||
Object.values(panelTypeDataSourceFormValuesMap).forEach((sources) => {
|
||||
expect(sources[DataSource.TRACES].builder.queryData).toContain(
|
||||
'builderQueryType',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shares one builder surface between logs and traces', () => {
|
||||
Object.values(panelTypeDataSourceFormValuesMap).forEach((sources) => {
|
||||
expect(sources[DataSource.LOGS].builder.queryData).toStrictEqual(
|
||||
|
||||
47
frontend/src/lib/query/builderEnvelope.ts
Normal file
47
frontend/src/lib/query/builderEnvelope.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
Querybuildertypesv5QueryEnvelopeBuilderAIDTO,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesQueryPluginKindDTO,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const BUILDER_ENVELOPE_TYPES: string[] = [
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query,
|
||||
];
|
||||
|
||||
/** True for `builder_query` and `builder_ai_query`. Formulas and trace operators are excluded. */
|
||||
export function isBuilderEnvelope(
|
||||
envelope: Querybuildertypesv5QueryEnvelopeDTO,
|
||||
): envelope is
|
||||
| Querybuildertypesv5QueryEnvelopeBuilderDTO
|
||||
| Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
|
||||
return BUILDER_ENVELOPE_TYPES.includes(envelope.type);
|
||||
}
|
||||
|
||||
export function isAIBuilderEnvelope(
|
||||
envelope: Querybuildertypesv5QueryEnvelopeDTO,
|
||||
): envelope is Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
|
||||
return (
|
||||
envelope.type ===
|
||||
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query
|
||||
);
|
||||
}
|
||||
|
||||
const BUILDER_PLUGIN_KINDS: string[] = [
|
||||
DashboardtypesQueryPluginKindDTO['signoz/BuilderQuery'],
|
||||
DashboardtypesQueryPluginKindDTO['signoz/AIBuilderQuery'],
|
||||
];
|
||||
|
||||
/**
|
||||
* True for a bare `signoz/BuilderQuery` or `signoz/AIBuilderQuery` plugin (the form List
|
||||
* panels use). Inside a CompositeQuery an AI query is a `builder_ai_query` envelope, matched
|
||||
* by `isBuilderEnvelope`.
|
||||
*/
|
||||
export function isBuilderPluginKind(kind: string): boolean {
|
||||
return BUILDER_PLUGIN_KINDS.includes(kind);
|
||||
}
|
||||
@@ -44,6 +44,8 @@ const IS_CARRIED = {
|
||||
orderBy: true,
|
||||
reduceTo: true,
|
||||
legend: true,
|
||||
// Identifies an AI query, so the panel keeps authoring in AI mode after the switch.
|
||||
builderQueryType: true,
|
||||
// `dataSource` is appended by the provider; the rest drive surfaces this switch
|
||||
// does not reach.
|
||||
// Heatmap only; listed for it below.
|
||||
@@ -54,7 +56,6 @@ const IS_CARRIED = {
|
||||
offset: false,
|
||||
selectColumns: false,
|
||||
source: false,
|
||||
builderQueryType: false,
|
||||
} satisfies Record<BuilderField, boolean>;
|
||||
|
||||
function omit(
|
||||
|
||||
51
frontend/src/lib/query/queryMode.ts
Normal file
51
frontend/src/lib/query/queryMode.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
BuilderVariant,
|
||||
type Query,
|
||||
QueryMode,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
/** Which variant the `builder` slot holds, whatever query type is active. */
|
||||
export function getBuilderVariant(builder: Query['builder']): BuilderVariant {
|
||||
return builder.queryData[0]?.builderQueryType === QueryMode.AI_QUERY_BUILDER
|
||||
? BuilderVariant.AI
|
||||
: BuilderVariant.MAIN;
|
||||
}
|
||||
|
||||
/** The variant a tab authors; `undefined` for tabs that own their own slot. */
|
||||
export function toBuilderVariant(mode: QueryMode): BuilderVariant | undefined {
|
||||
if (mode === QueryMode.AI_QUERY_BUILDER) {
|
||||
return BuilderVariant.AI;
|
||||
}
|
||||
return mode === QueryMode.QUERY_BUILDER ? BuilderVariant.MAIN : undefined;
|
||||
}
|
||||
|
||||
/** The tab a query belongs to: its query type, or AI when a builder query carries the tag. */
|
||||
export function getQueryMode(query: Query): QueryMode {
|
||||
if (query.queryType !== QueryMode.QUERY_BUILDER) {
|
||||
return query.queryType;
|
||||
}
|
||||
return getBuilderVariant(query.builder) === BuilderVariant.AI
|
||||
? QueryMode.AI_QUERY_BUILDER
|
||||
: QueryMode.QUERY_BUILDER;
|
||||
}
|
||||
|
||||
/** Query Builder and AI both author `Query.builder`; the other modes own their own slot. */
|
||||
export function isBuilderMode(mode: QueryMode): boolean {
|
||||
return toBuilderVariant(mode) !== undefined;
|
||||
}
|
||||
|
||||
/** Drops the AI tag, leaving the same query readable as a plain builder query. */
|
||||
export function withoutAIQueryTag(query: Query): Query {
|
||||
if (getBuilderVariant(query.builder) !== BuilderVariant.AI) {
|
||||
return query;
|
||||
}
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map(
|
||||
({ builderQueryType: _tag, ...queryData }) => queryData,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -80,4 +80,31 @@ describe('getPanelIdsReferencingVariable', () => {
|
||||
);
|
||||
expect(ids.sort()).toStrictEqual(['p1', 'p3']);
|
||||
});
|
||||
|
||||
it('includes an AI list panel and an AI composite panel', () => {
|
||||
const aiList = listPanel('service.name IN $svc');
|
||||
(
|
||||
aiList as {
|
||||
spec: { queries: { spec: { plugin: { kind: string } } }[] };
|
||||
}
|
||||
).spec.queries[0].spec.plugin.kind = 'signoz/AIBuilderQuery';
|
||||
|
||||
const aiComposite = compositePanel('service.name IN $svc');
|
||||
(
|
||||
aiComposite as {
|
||||
spec: {
|
||||
queries: {
|
||||
spec: { plugin: { spec: { queries: { type: string }[] } } };
|
||||
}[];
|
||||
};
|
||||
}
|
||||
).spec.queries[0].spec.plugin.spec.queries[0].type = 'builder_ai_query';
|
||||
|
||||
const ids = getPanelIdsReferencingVariable(
|
||||
panels({ aiList, aiComposite }),
|
||||
'service.name',
|
||||
'svc',
|
||||
);
|
||||
expect(ids.sort()).toStrictEqual(['aiComposite', 'aiList']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,11 @@ import type {
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
isBuilderEnvelope,
|
||||
isBuilderPluginKind,
|
||||
} from 'lib/query/builderEnvelope';
|
||||
|
||||
function clauseFor(attribute: string, variableName: string): string {
|
||||
return `${attribute} IN $${variableName}`;
|
||||
}
|
||||
@@ -20,15 +25,13 @@ function forEachBuilderSpec(
|
||||
}
|
||||
if (plugin.kind === 'signoz/CompositeQuery') {
|
||||
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
|
||||
(composite.queries ?? [])
|
||||
.filter((envelope) => envelope.type === 'builder_query')
|
||||
.forEach((envelope) => {
|
||||
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
|
||||
if (spec) {
|
||||
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
|
||||
}
|
||||
});
|
||||
} else if (plugin.kind === 'signoz/BuilderQuery') {
|
||||
(composite.queries ?? []).filter(isBuilderEnvelope).forEach((envelope) => {
|
||||
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
|
||||
if (spec) {
|
||||
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
|
||||
}
|
||||
});
|
||||
} else if (isBuilderPluginKind(plugin.kind)) {
|
||||
fn(plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
textContainsVariableReference,
|
||||
} from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import { isBuilderEnvelope } from 'lib/query/builderEnvelope';
|
||||
import { toQueryEnvelopes } from '../../../queryV5/buildQueryRangeRequest';
|
||||
import { getTextPanelBody } from './getTextPanelBody';
|
||||
import { dtoToFormModel } from '../variableAdapters';
|
||||
@@ -54,7 +55,7 @@ function envelopeReferenceText(
|
||||
const spec = envelope.spec as
|
||||
| { query?: string; filter?: { expression?: string } }
|
||||
| undefined;
|
||||
if (envelope.type === 'builder_query') {
|
||||
if (isBuilderEnvelope(envelope)) {
|
||||
const text = spec?.filter?.expression;
|
||||
return typeof text === 'string' ? { kind: 'builder', text } : null;
|
||||
}
|
||||
@@ -232,7 +233,7 @@ export function findApplyUsages(
|
||||
});
|
||||
};
|
||||
|
||||
if (envelope.type === 'builder_query') {
|
||||
if (isBuilderEnvelope(envelope)) {
|
||||
const spec = envelope.spec as
|
||||
| { filter?: { expression?: string } }
|
||||
| undefined;
|
||||
@@ -294,7 +295,7 @@ export function isVariableAppliedToAllPanels(
|
||||
return true;
|
||||
}
|
||||
return toQueryEnvelopes(queries).every((envelope) => {
|
||||
if (envelope.type === 'builder_query') {
|
||||
if (isBuilderEnvelope(envelope)) {
|
||||
const spec = envelope.spec as
|
||||
| { filter?: { expression?: string } }
|
||||
| undefined;
|
||||
|
||||
@@ -15,10 +15,10 @@ import type { EQueryType } from 'types/common/dashboard';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import PanelTitleInput from './PanelTitleInput/PanelTitleInput';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
|
||||
import styles from './ConfigPane.module.scss';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { PanelKind } from '../../Panels/types/panelKind';
|
||||
|
||||
interface ConfigPaneProps {
|
||||
@@ -106,12 +106,9 @@ function ConfigPane({
|
||||
<div className={styles.group}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Title</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-title"
|
||||
<PanelTitleInput
|
||||
value={spec.display.name}
|
||||
placeholder="Panel title"
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
onChange={(e): void => setDisplayField('name', e.target.value)}
|
||||
onChange={(value): void => setDisplayField('name', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.autoComplete {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { AutoComplete } from 'antd';
|
||||
import { useDashboardVariableNames } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames';
|
||||
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../../constants';
|
||||
import { findVariableToken, insertVariable } from './variableToken';
|
||||
|
||||
import styles from './PanelTitleInput.module.scss';
|
||||
|
||||
interface PanelTitleInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
interface VariableOption {
|
||||
/** Whole title after insertion, so antd's change event carries it. */
|
||||
value: string;
|
||||
label: string;
|
||||
cursor: number;
|
||||
}
|
||||
|
||||
function PanelTitleInput({
|
||||
value,
|
||||
onChange,
|
||||
}: PanelTitleInputProps): JSX.Element {
|
||||
const variableNames = useDashboardVariableNames();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const pendingCursor = useRef<number | null>(null);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
const options = useMemo<VariableOption[]>(() => {
|
||||
const token = findVariableToken(value, cursor);
|
||||
if (!token) {
|
||||
return [];
|
||||
}
|
||||
const query = token.query.toLowerCase();
|
||||
return variableNames
|
||||
.filter((name) => name.toLowerCase().startsWith(query))
|
||||
.map((name) => {
|
||||
const next = insertVariable(value, token, name);
|
||||
return { value: next.text, label: name, cursor: next.cursor };
|
||||
});
|
||||
}, [value, cursor, variableNames]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (pendingCursor.current === null) {
|
||||
return;
|
||||
}
|
||||
inputRef.current?.setSelectionRange(
|
||||
pendingCursor.current,
|
||||
pendingCursor.current,
|
||||
);
|
||||
setCursor(pendingCursor.current);
|
||||
pendingCursor.current = null;
|
||||
}, [value]);
|
||||
|
||||
const syncCursor = (): void => {
|
||||
setCursor(inputRef.current?.selectionStart ?? 0);
|
||||
};
|
||||
|
||||
const handleChange = (next: string): void => {
|
||||
const picked = options.find((option) => option.value === next);
|
||||
if (picked) {
|
||||
pendingCursor.current = picked.cursor;
|
||||
setDismissed(true);
|
||||
} else {
|
||||
setDismissed(false);
|
||||
syncCursor();
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Escape') {
|
||||
setDismissed(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
className={styles.autoComplete}
|
||||
value={value}
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
options={options}
|
||||
open={focused && !dismissed && options.length > 0}
|
||||
filterOption={false}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
testId="panel-editor-v2-title"
|
||||
placeholder="Panel title"
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={syncCursor}
|
||||
onClick={syncCursor}
|
||||
onFocus={(): void => setFocused(true)}
|
||||
onBlur={(): void => setFocused(false)}
|
||||
/>
|
||||
</AutoComplete>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelTitleInput;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import PanelTitleInput from '../PanelTitleInput';
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
|
||||
() => ({
|
||||
useDashboardVariableNames: (): string[] => ['service.name', 'env'],
|
||||
}),
|
||||
);
|
||||
|
||||
function Harness({ initial = '' }: { initial?: string }): JSX.Element {
|
||||
const [value, setValue] = useState(initial);
|
||||
return <PanelTitleInput value={value} onChange={setValue} />;
|
||||
}
|
||||
|
||||
describe('PanelTitleInput', () => {
|
||||
it('suggests no variables until a `$` is typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await user.type(screen.getByTestId('panel-editor-v2-title'), 'Latency');
|
||||
|
||||
expect(screen.queryByText('env')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('suggests variables matching the text after `$`', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId('panel-editor-v2-title'),
|
||||
'Latency of $se',
|
||||
);
|
||||
|
||||
await expect(screen.findByText('service.name')).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByText('env')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('inserts the picked variable into the title', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
const input = screen.getByTestId('panel-editor-v2-title');
|
||||
|
||||
await user.type(input, 'Latency of $se');
|
||||
await user.click(await screen.findByText('service.name'));
|
||||
|
||||
expect(input).toHaveValue('Latency of $service.name');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { findVariableToken, insertVariable } from '../variableToken';
|
||||
|
||||
describe('findVariableToken', () => {
|
||||
it('finds the token being typed before the cursor', () => {
|
||||
expect(findVariableToken('Latency of $ser', 15)).toStrictEqual({
|
||||
start: 11,
|
||||
query: 'ser',
|
||||
});
|
||||
});
|
||||
|
||||
it('matches a bare `$`', () => {
|
||||
expect(findVariableToken('by $', 4)).toStrictEqual({ start: 3, query: '' });
|
||||
});
|
||||
|
||||
it('ignores a token the cursor has moved past', () => {
|
||||
expect(findVariableToken('$env in prod', 12)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertVariable', () => {
|
||||
it('replaces the whole token, including text after the cursor', () => {
|
||||
const token = { start: 4, query: 'ser' };
|
||||
expect(
|
||||
insertVariable('p99 $service_x for', token, 'service.name'),
|
||||
).toStrictEqual({
|
||||
text: 'p99 $service.name for',
|
||||
cursor: 17,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
const TOKEN_BEFORE_CURSOR = /\$([\w.]*)$/;
|
||||
const IDENTIFIER_PREFIX = /^[\w.]*/;
|
||||
|
||||
export interface VariableToken {
|
||||
start: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
export function findVariableToken(
|
||||
text: string,
|
||||
cursor: number,
|
||||
): VariableToken | null {
|
||||
const match = TOKEN_BEFORE_CURSOR.exec(text.slice(0, cursor));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return { start: match.index, query: match[1] };
|
||||
}
|
||||
|
||||
/** Also replaces identifier chars after the cursor: `$ser|vice` leaves no `vice`. */
|
||||
export function insertVariable(
|
||||
text: string,
|
||||
token: VariableToken,
|
||||
name: string,
|
||||
): { text: string; cursor: number } {
|
||||
const before = text.slice(0, token.start);
|
||||
const after = text.slice(token.start + 1).replace(IDENTIFIER_PREFIX, '');
|
||||
const inserted = `${before}$${name}`;
|
||||
return { text: `${inserted}${after}`, cursor: inserted.length };
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { EQueryType } from 'types/common/dashboard';
|
||||
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeBrowser';
|
||||
import { getPanelDefinition } from '../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { JS_DRIVEN_SLIDE } from '../../../utils/drawerSlide';
|
||||
import ConfigField from '../controls/ConfigField/ConfigField';
|
||||
|
||||
import styles from './PanelTypeSwitcher.module.scss';
|
||||
@@ -102,6 +103,7 @@ function PanelTypeSwitcher({
|
||||
direction="right"
|
||||
width="wide"
|
||||
testId="panel-type-switcher-drawer"
|
||||
drawerContentProps={JS_DRIVEN_SLIDE}
|
||||
drawerDescriptionProps={{ className: styles.drawerBody }}
|
||||
>
|
||||
<PanelTypeBrowser
|
||||
|
||||
@@ -71,19 +71,29 @@ describe('PanelTypeSwitcher', () => {
|
||||
jest.clearAllMocks();
|
||||
// List supports only logs/traces; every other kind also supports metrics.
|
||||
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => ({
|
||||
...OPTIONS.find((option) => option.kind === kind),
|
||||
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
|
||||
supportedSignals:
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => {
|
||||
const signals =
|
||||
kind === 'signoz/ListPanel'
|
||||
? ['logs', 'traces']
|
||||
: ['metrics', 'logs', 'traces'],
|
||||
supportedQueryTypes: SUPPORTED_QUERY_TYPES[kind] ?? [
|
||||
: ['metrics', 'logs', 'traces'];
|
||||
const queryTypes = SUPPORTED_QUERY_TYPES[kind] ?? [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
}));
|
||||
];
|
||||
return {
|
||||
...OPTIONS.find((option) => option.kind === kind),
|
||||
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
|
||||
supportedQueryModesKindMap: Object.fromEntries(
|
||||
queryTypes.map((queryType) => [
|
||||
queryType,
|
||||
queryType === EQueryType.QUERY_BUILDER
|
||||
? { kind: 'signal', signals }
|
||||
: { kind: 'signal-less' },
|
||||
]),
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the current type and switches to the chosen one', async () => {
|
||||
|
||||
@@ -23,6 +23,11 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
|
||||
() => ({ useDashboardVariableNames: (): string[] => [] }),
|
||||
);
|
||||
|
||||
function textSpec(): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Runbook', description: 'steps' },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { dtoToFormModel } from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters';
|
||||
import { useDashboardFetchRequired } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired';
|
||||
import { useDashboardVariableNames } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames';
|
||||
|
||||
import type { VariableItem } from './types';
|
||||
|
||||
@@ -22,15 +21,7 @@ const GLOBAL_TIMESTAMP_VARIABLES: VariableItem[] = [
|
||||
export function useContextLinkVariables(): VariableItem[] {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const { variables: variableDtos } = useDashboardFetchRequired();
|
||||
|
||||
const dashboardVariableNames = useMemo(
|
||||
() =>
|
||||
variableDtos
|
||||
.map((dto) => dtoToFormModel(dto).name)
|
||||
.filter((name): name is string => !!name),
|
||||
[variableDtos],
|
||||
);
|
||||
const dashboardVariableNames = useDashboardVariableNames();
|
||||
|
||||
// `_`-prefixed to match V1 and avoid colliding with dashboard-variable names.
|
||||
const fieldVariableNames = useMemo(() => {
|
||||
|
||||
@@ -14,8 +14,11 @@ jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
|
||||
displayName: 'Time Series',
|
||||
icon: (): null => null,
|
||||
mode: 'query',
|
||||
supportedSignals: ['metrics', 'logs', 'traces'],
|
||||
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
|
||||
supportedQueryModesKindMap: {
|
||||
builder: { kind: 'signal', signals: ['metrics', 'logs', 'traces'] },
|
||||
clickhouse_sql: { kind: 'signal-less' },
|
||||
promql: { kind: 'signal-less' },
|
||||
},
|
||||
})),
|
||||
PANEL_OPTIONS: [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
|
||||
@@ -2,26 +2,33 @@ import {
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Atom, Terminal } from '@signozhq/icons';
|
||||
import { Atom, Brain, Terminal } from '@signozhq/icons';
|
||||
import { Tabs } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import PromQLIcon from 'assets/Dashboard/PromQl';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { clearPreviousQuery } from 'components/QueryBuilderV2/QueryV2/previousQuery.utils';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import ClickHouseQueryContainer from 'container/QueryBuilder/rawQueryEditors/ClickHouse';
|
||||
import PromQLQueryContainer from 'container/QueryBuilder/rawQueryEditors/PromQL';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { isRawRequest } from '../../Panels/types/panelCapabilities';
|
||||
import {
|
||||
getQueryModeSignals,
|
||||
isRawRequest,
|
||||
} from '../../Panels/types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
|
||||
import { toPanelType } from '../../Panels/types/panelKind';
|
||||
import { getQueryMode, isBuilderMode } from 'lib/query/queryMode';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
@@ -29,6 +36,8 @@ interface PanelEditorQueryBuilderProps {
|
||||
/** The edited kind's definition — drives supported query types, the signals the
|
||||
* builder may be pointed at, and how it narrows the builder's fields. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** Switch authoring tab; owned by the host, which remembers the Query Builder query. */
|
||||
onChangeQueryMode: (key: string) => void;
|
||||
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
|
||||
isLoadingQueries: boolean;
|
||||
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
|
||||
@@ -48,6 +57,7 @@ interface PanelEditorQueryBuilderProps {
|
||||
*/
|
||||
function PanelEditorQueryBuilder({
|
||||
panelDefinition,
|
||||
onChangeQueryMode,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
@@ -60,18 +70,16 @@ function PanelEditorQueryBuilder({
|
||||
// Raw rows: the builder drops its aggregation controls, and with them the trace
|
||||
// operator that combines aggregated trace queries (V1 parity).
|
||||
const isRawQuery = isRawRequest(panelDefinition.queryCapabilities);
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const handleQueryCategoryChange = useCallback(
|
||||
(queryType: string): void => {
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
queryType: queryType as EQueryType,
|
||||
});
|
||||
},
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
// Cleared here, not in QueryBuilderV2: builder panes unmount on every tab switch.
|
||||
useEffect(() => {
|
||||
clearPreviousQuery();
|
||||
return (): void => {
|
||||
clearPreviousQuery();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ⌘↵ / Ctrl+↵ stages and runs the query. Handled locally because the global
|
||||
// hotkeys provider ignores keydowns from inputs / the query editor, and on the
|
||||
@@ -88,10 +96,11 @@ function PanelEditorQueryBuilder({
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const { supportedQueryTypes } = panelDefinition;
|
||||
|
||||
const queryTypeComponents = {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
const queryTypeComponents: Record<
|
||||
QueryMode,
|
||||
{ icon: ReactNode; label: string; component: ReactNode }
|
||||
> = {
|
||||
[QueryMode.QUERY_BUILDER]: {
|
||||
icon: <Atom size={14} />,
|
||||
label: 'Query Builder',
|
||||
component: (
|
||||
@@ -99,7 +108,10 @@ function PanelEditorQueryBuilder({
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
fieldsConfig={panelDefinition.queryBuilderFields}
|
||||
allowedDataSources={panelDefinition.supportedSignals}
|
||||
allowedDataSources={getQueryModeSignals(
|
||||
panelDefinition.supportedQueryModesKindMap,
|
||||
QueryMode.QUERY_BUILDER,
|
||||
)}
|
||||
showTraceOperator={!isRawQuery}
|
||||
version="v3"
|
||||
isRawQuery={isRawQuery}
|
||||
@@ -109,12 +121,12 @@ function PanelEditorQueryBuilder({
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EQueryType.CLICKHOUSE]: {
|
||||
[QueryMode.CLICKHOUSE]: {
|
||||
icon: <Terminal size={14} />,
|
||||
label: 'ClickHouse Query',
|
||||
component: <ClickHouseQueryContainer />,
|
||||
},
|
||||
[EQueryType.PROM]: {
|
||||
[QueryMode.PROM]: {
|
||||
icon: (
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
@@ -123,9 +135,33 @@ function PanelEditorQueryBuilder({
|
||||
label: 'PromQL',
|
||||
component: <PromQLQueryContainer />,
|
||||
},
|
||||
[QueryMode.AI_QUERY_BUILDER]: {
|
||||
icon: <Brain size={14} />,
|
||||
label: 'AI Query Builder',
|
||||
component: (
|
||||
<div className="query-builder-v2-container">
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
fieldsConfig={panelDefinition.queryBuilderFields}
|
||||
showTraceOperator={false}
|
||||
version="v3"
|
||||
isRawQuery={isRawQuery}
|
||||
config={{
|
||||
initialDataSource: DataSource.TRACES,
|
||||
queryVariant: 'static',
|
||||
}}
|
||||
savePreviousQuery
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return supportedQueryTypes.map((queryType) => ({
|
||||
const modes = Object.keys(
|
||||
panelDefinition.supportedQueryModesKindMap,
|
||||
) as QueryMode[];
|
||||
|
||||
return modes.map((queryType) => ({
|
||||
key: queryType,
|
||||
label: (
|
||||
<div className={styles.queryTypeTab}>
|
||||
@@ -134,6 +170,8 @@ function PanelEditorQueryBuilder({
|
||||
</div>
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
// QB wants a null data source and AI pins traces: both mounted, they overwrite each other's provider config in a loop.
|
||||
destroyInactiveTabPane: isBuilderMode(queryType),
|
||||
}));
|
||||
}, [panelDefinition, panelType, isDarkMode, isRawQuery]);
|
||||
|
||||
@@ -150,8 +188,8 @@ function PanelEditorQueryBuilder({
|
||||
className={cx(styles.tabsContainer, {
|
||||
[styles.stickyNav]: stickyHeader,
|
||||
})}
|
||||
activeKey={currentQuery.queryType}
|
||||
onChange={handleQueryCategoryChange}
|
||||
activeKey={getQueryMode(currentQuery)}
|
||||
onChange={onChangeQueryMode}
|
||||
tabBarExtraContent={
|
||||
<span className={styles.runQueryBtnContainer}>
|
||||
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />
|
||||
|
||||
@@ -8,6 +8,7 @@ import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
|
||||
function QueryBuilderEditorPane({
|
||||
panelDefinition,
|
||||
isLoadingQueries,
|
||||
onChangeQueryMode,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
stickyHeader,
|
||||
@@ -16,6 +17,7 @@ function QueryBuilderEditorPane({
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={panelDefinition}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
onChangeQueryMode={onChangeQueryMode}
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
onCancelQuery={onCancelQuery}
|
||||
stickyHeader={stickyHeader}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { useRef } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
toPanelType,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import type { BuildersByVariant } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
|
||||
import { useQueryModeChange } from '../../hooks/useQueryModeChange';
|
||||
|
||||
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
|
||||
const mockQueryBuilderV2 = jest.fn();
|
||||
@@ -43,17 +49,41 @@ jest.mock('assets/Dashboard/PromQl', () => ({
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
|
||||
function renderBuilder(panelKind: string): void {
|
||||
render(
|
||||
const BUILDER_QUERY = {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [{ dataSource: 'traces' }] },
|
||||
};
|
||||
const AI_QUERY = {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: {
|
||||
queryData: [{ dataSource: 'traces', builderQueryType: 'builder_ai_query' }],
|
||||
},
|
||||
};
|
||||
|
||||
/** Stands in for the editor shell: owns the tab switch and its parked Query Builder query. */
|
||||
function BuilderHost({ panelKind }: { panelKind: PanelKind }): JSX.Element {
|
||||
const panelDefinition = requireQueryPanelDefinition(panelKind);
|
||||
const parkedBuilders = useRef<BuildersByVariant>({});
|
||||
const onChangeQueryMode = useQueryModeChange({
|
||||
panelType: toPanelType(panelKind),
|
||||
supportedQueryModesKindMap: panelDefinition.supportedQueryModesKindMap,
|
||||
parkedBuilders,
|
||||
});
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
|
||||
panelDefinition={panelDefinition}
|
||||
onChangeQueryMode={onChangeQueryMode}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={jest.fn()}
|
||||
onCancelQuery={jest.fn()}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBuilder(panelKind: string): void {
|
||||
render(<BuilderHost panelKind={panelKind as PanelKind} />);
|
||||
}
|
||||
|
||||
function lastQueryBuilderProps(): {
|
||||
panelType: string;
|
||||
isRawQuery: boolean;
|
||||
@@ -69,12 +99,12 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
|
||||
currentQuery: BUILDER_QUERY,
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('shows only the Query Builder tab for the List kind', () => {
|
||||
it('shows no ClickHouse or PromQL tab for the List kind', () => {
|
||||
renderBuilder('signoz/ListPanel');
|
||||
|
||||
expect(screen.getByText('Query Builder')).toBeInTheDocument();
|
||||
@@ -90,12 +120,126 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
|
||||
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all three tabs for the Time Series kind', () => {
|
||||
it('shows all four tabs for the Time Series kind', () => {
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
expect(screen.getByText('Query Builder')).toBeInTheDocument();
|
||||
expect(screen.getByText('ClickHouse Query')).toBeInTheDocument();
|
||||
expect(screen.getByText('PromQL')).toBeInTheDocument();
|
||||
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the AI tab for the Table kind', () => {
|
||||
renderBuilder('signoz/TablePanel');
|
||||
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the AI tab for the List kind', () => {
|
||||
renderBuilder('signoz/ListPanel');
|
||||
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PanelEditorQueryBuilder AI tab', () => {
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
const updateAllQueriesOperators = jest.fn((query: unknown) => query);
|
||||
|
||||
function mockBuilder(currentQuery: unknown): void {
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
updateAllQueriesOperators,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('activates the AI tab for an AI query and pins the builder to traces', () => {
|
||||
mockBuilder(AI_QUERY);
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'AI Query Builder', selected: true }),
|
||||
).toBeInTheDocument();
|
||||
expect(lastQueryBuilderProps()).toMatchObject({
|
||||
config: { initialDataSource: 'traces', queryVariant: 'static' },
|
||||
showTraceOperator: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds an AI traces query when switching to the AI tab', () => {
|
||||
mockBuilder(BUILDER_QUERY);
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
fireEvent.click(screen.getByText('AI Query Builder'));
|
||||
|
||||
expect(updateAllQueriesOperators).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'graph',
|
||||
'traces',
|
||||
);
|
||||
const [seeded] = redirectWithQueryBuilderData.mock.calls[0];
|
||||
expect(seeded.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(seeded.builder.queryData[0].builderQueryType).toBe('builder_ai_query');
|
||||
});
|
||||
|
||||
it('keeps the builder query when returning from ClickHouse', () => {
|
||||
const logsQuery = {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [{ dataSource: 'logs', legend: 'kept' }] },
|
||||
};
|
||||
mockBuilder(logsQuery);
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
fireEvent.click(screen.getByText('ClickHouse Query'));
|
||||
const [onClickHouse] = redirectWithQueryBuilderData.mock.calls[0];
|
||||
expect(onClickHouse.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
// The builder slot already holds the Query Builder query — nothing to swap.
|
||||
expect(onClickHouse.builder).toBe(logsQuery.builder);
|
||||
});
|
||||
|
||||
it('shows a fresh default query when leaving AI', () => {
|
||||
mockBuilder(AI_QUERY);
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
fireEvent.click(screen.getByText('Query Builder'));
|
||||
|
||||
const [next] = redirectWithQueryBuilderData.mock.calls[0];
|
||||
expect(next.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(next.builder.queryData[0].builderQueryType).toBeUndefined();
|
||||
expect(next.builder.queryData[0].dataSource).toBe('metrics');
|
||||
});
|
||||
|
||||
it("keeps each tab's own query across Query Builder ↔ AI round trips", () => {
|
||||
const logsQuery = {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [{ dataSource: 'logs' }] },
|
||||
};
|
||||
const host = (): JSX.Element => (
|
||||
<BuilderHost panelKind="signoz/TimeSeriesPanel" />
|
||||
);
|
||||
mockBuilder(logsQuery);
|
||||
const { rerender } = render(host());
|
||||
|
||||
fireEvent.click(screen.getByText('AI Query Builder'));
|
||||
const [aiQuery] = redirectWithQueryBuilderData.mock.calls[0];
|
||||
// The user writes an AI query, then goes back to Query Builder.
|
||||
const editedAIBuilder = {
|
||||
queryData: [{ dataSource: 'traces', builderQueryType: 'builder_ai_query' }],
|
||||
};
|
||||
mockBuilder({ ...aiQuery, builder: editedAIBuilder });
|
||||
rerender(host());
|
||||
fireEvent.click(screen.getByText('Query Builder'));
|
||||
const [backToQB] = redirectWithQueryBuilderData.mock.calls[1];
|
||||
expect(backToQB.builder).toBe(logsQuery.builder);
|
||||
|
||||
mockBuilder(backToQB);
|
||||
rerender(host());
|
||||
fireEvent.click(screen.getByText('AI Query Builder'));
|
||||
const [backToAI] = redirectWithQueryBuilderData.mock.calls[2];
|
||||
expect(backToAI.builder).toBe(editedAIBuilder);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,7 +247,7 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
|
||||
currentQuery: BUILDER_QUERY,
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
});
|
||||
});
|
||||
@@ -144,7 +288,7 @@ describe('PanelEditorQueryBuilder signal dropdown (driven by the capabilities gu
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
|
||||
currentQuery: BUILDER_QUERY,
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,8 @@ interface QueryEditorBodyProps {
|
||||
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
originalPanelKind?: PanelKind;
|
||||
/** Tab switch, owned by the shell alongside the kind switch it shares memory with. */
|
||||
onChangeQueryMode: (key: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +94,7 @@ function QueryEditorBody({
|
||||
panelDefinition,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
onChangeQueryMode,
|
||||
}: QueryEditorBodyProps): JSX.Element {
|
||||
// Read here rather than taken as props: this renders inside a loaded dashboard
|
||||
// subtree, so it resolves the same context every other consumer does.
|
||||
@@ -307,6 +310,7 @@ function QueryEditorBody({
|
||||
panelDefinition={panelDefinition}
|
||||
signal={listSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onChangeQueryMode={onChangeQueryMode}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
spec={spec}
|
||||
|
||||
@@ -60,8 +60,9 @@ jest.mock('../hooks/usePanelEditorQuerySync', () => ({
|
||||
}));
|
||||
|
||||
const mockUseTypeSwitch = jest.fn();
|
||||
jest.mock('../hooks/usePanelTypeSwitch', () => ({
|
||||
usePanelTypeSwitch: (args: unknown): unknown => mockUseTypeSwitch(args),
|
||||
jest.mock('../hooks/usePanelKindAndQueryModeSwitch', () => ({
|
||||
usePanelKindAndQueryModeSwitch: (args: unknown): unknown =>
|
||||
mockUseTypeSwitch(args),
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/usePanelEditorSave', () => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
@@ -21,6 +21,8 @@ import { usePanelEditorQuerySync } from '../hooks/usePanelEditorQuerySync';
|
||||
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
|
||||
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import { useQueryModeChange } from '../hooks/useQueryModeChange';
|
||||
import type { BuildersByVariant } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
|
||||
jest.mock('hooks/useSafeNavigate', () => {
|
||||
@@ -82,10 +84,18 @@ function EditorRoute(): JSX.Element {
|
||||
signal: TelemetrytypesSignalDTO.metrics,
|
||||
savedQueries: panel.spec.queries,
|
||||
});
|
||||
const panelDefinition = requireQueryPanelDefinition('signoz/TimeSeriesPanel');
|
||||
const parkedBuilders = useRef<BuildersByVariant>({});
|
||||
const onChangeQueryMode = useQueryModeChange({
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
supportedQueryModesKindMap: panelDefinition.supportedQueryModesKindMap,
|
||||
parkedBuilders,
|
||||
});
|
||||
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={requireQueryPanelDefinition('signoz/TimeSeriesPanel')}
|
||||
panelDefinition={panelDefinition}
|
||||
onChangeQueryMode={onChangeQueryMode}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={noop}
|
||||
onCancelQuery={noop}
|
||||
|
||||
@@ -45,8 +45,16 @@ const SAVED_QUERIES = [{ id: 'saved' }] as unknown as NonNullable<
|
||||
const CONVERTED_QUERIES = [{ id: 'converted' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const SEED_V1 = { id: 'seed', queryType: 'builder' } as unknown as Query;
|
||||
const STAGED_V1 = { id: 'staged', queryType: 'builder' } as unknown as Query;
|
||||
const SEED_V1 = {
|
||||
id: 'seed',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query;
|
||||
const STAGED_V1 = {
|
||||
id: 'staged',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query;
|
||||
|
||||
function makeDraft(
|
||||
queries = SAVED_QUERIES,
|
||||
@@ -74,7 +82,11 @@ function builderState(
|
||||
handleRunQuery: jest.Mock;
|
||||
} {
|
||||
return {
|
||||
currentQuery: { id: 'current', queryType: 'builder' } as unknown as Query,
|
||||
currentQuery: {
|
||||
id: 'current',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query,
|
||||
stagedQuery: STAGED_V1,
|
||||
handleRunQuery: jest.fn(),
|
||||
...overrides,
|
||||
@@ -396,8 +408,16 @@ describe('usePanelEditorQuerySync', () => {
|
||||
const EDITED_ENVELOPES = [
|
||||
{ id: 'edited-envelopes' },
|
||||
] as unknown as NonNullable<DashboardtypesPanelSpecDTO['queries']>;
|
||||
const editedQuery = { id: 'edited', queryType: 'builder' } as Query;
|
||||
const unchangedQuery = { id: 'unchanged', queryType: 'builder' } as Query;
|
||||
const editedQuery = {
|
||||
id: 'edited',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query;
|
||||
const unchangedQuery = {
|
||||
id: 'unchanged',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query;
|
||||
|
||||
beforeEach(() => {
|
||||
mockToPerses.mockImplementation((query: Query) =>
|
||||
@@ -468,4 +488,66 @@ describe('usePanelEditorQuerySync', () => {
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI queries are saved like any builder query', () => {
|
||||
const aiQuery = {
|
||||
id: 'ai',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
|
||||
} as unknown as Query;
|
||||
|
||||
it('keeps the AI tag when seeding, so a saved AI panel reopens on the AI tab', () => {
|
||||
const savedAiQuery = {
|
||||
id: 'saved-ai',
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
|
||||
],
|
||||
},
|
||||
} as unknown as Query;
|
||||
mockFromPerses.mockReturnValue(savedAiQuery);
|
||||
setup({ savedQueries: SAVED_QUERIES });
|
||||
|
||||
const [seeded] = mockUseShareBuilderUrl.mock.calls[0];
|
||||
expect(seeded.defaultValue).toBe(savedAiQuery);
|
||||
});
|
||||
|
||||
it('serializes the live AI query, not the seed', () => {
|
||||
// Input-sensitive so the envelope compare reads dirty and names what was serialized.
|
||||
mockToPerses.mockImplementation((query: Query) =>
|
||||
query?.id === 'ai' ? CONVERTED_QUERIES : SAVED_QUERIES,
|
||||
);
|
||||
mockUseQueryBuilder.mockReturnValue(builderState({ currentQuery: aiQuery }));
|
||||
const { result } = setup({ savedQueries: SAVED_QUERIES });
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
|
||||
...spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('still saves a ClickHouse query while an AI query sits in the builder slot', () => {
|
||||
const clickHouseQuery = {
|
||||
...aiQuery,
|
||||
id: 'ch',
|
||||
queryType: 'clickhouse_sql',
|
||||
} as unknown as Query;
|
||||
// Input-sensitive so the envelope compare reads dirty and the normal path runs.
|
||||
mockToPerses.mockImplementation((query: Query) =>
|
||||
query?.id === 'ch' ? CONVERTED_QUERIES : SAVED_QUERIES,
|
||||
);
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: clickHouseQuery }),
|
||||
);
|
||||
const { result } = setup({ savedQueries: SAVED_QUERIES });
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
|
||||
...spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,11 @@ import { handleQueryChange } from 'lib/query/panelQuery';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { resolveQueryType } from '../../../Panels/capabilities';
|
||||
import { resolveQueryMode } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
|
||||
import { usePanelTypeSwitch } from '../usePanelTypeSwitch';
|
||||
import { usePanelKindAndQueryModeSwitch } from '../usePanelKindAndQueryModeSwitch';
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
@@ -18,7 +18,10 @@ jest.mock('lib/query/panelQuery', () => ({
|
||||
handleQueryChange: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../Panels/capabilities', () => ({
|
||||
resolveQueryType: jest.fn(),
|
||||
resolveQueryMode: jest.fn(),
|
||||
getSupportedSignals: jest.fn(() => ['metrics']),
|
||||
getQueryPanelDefinition: jest.requireActual('../../../Panels/capabilities')
|
||||
.getQueryPanelDefinition,
|
||||
// Real predicate: these specs use real (query) kinds and the static path is
|
||||
// exercised through its own cases below.
|
||||
isStaticPanelKind: jest.requireActual('../../../Panels/capabilities')
|
||||
@@ -36,7 +39,7 @@ jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
|
||||
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
|
||||
const mockResolveQueryType = resolveQueryType as unknown as jest.Mock;
|
||||
const mockResolveQueryMode = resolveQueryMode as unknown as jest.Mock;
|
||||
const mockToPerses = toPerses as unknown as jest.Mock;
|
||||
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
|
||||
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
|
||||
@@ -79,14 +82,27 @@ const tableSpec = makeSpec(
|
||||
);
|
||||
const listSpec = makeSpec('signoz/ListPanel', LIST_PLUGIN_SPEC, LIST_QUERIES);
|
||||
|
||||
function fakeQuery(
|
||||
id: string,
|
||||
queryType: string,
|
||||
queryData: Record<string, unknown>[] = [],
|
||||
): Query {
|
||||
return { id, queryType, builder: { queryData } } as unknown as Query;
|
||||
}
|
||||
|
||||
function builderState(currentQuery: Query): {
|
||||
currentQuery: Query;
|
||||
redirectWithQueryBuilderData: jest.Mock;
|
||||
updateAllQueriesOperators: jest.Mock;
|
||||
} {
|
||||
return { currentQuery, redirectWithQueryBuilderData: jest.fn() };
|
||||
return {
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
updateAllQueriesOperators: jest.fn((query: Query) => query),
|
||||
};
|
||||
}
|
||||
|
||||
describe('usePanelTypeSwitch', () => {
|
||||
describe('usePanelKindAndQueryModeSwitch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockHandleQueryChange.mockReturnValue(TRANSFORMED);
|
||||
@@ -95,16 +111,16 @@ describe('usePanelTypeSwitch', () => {
|
||||
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
|
||||
// The guard owns coercion (tested in capabilities.test.ts); here it always
|
||||
// resolves to Query Builder so the coerced type flows into handleQueryChange.
|
||||
mockResolveQueryType.mockReturnValue('builder');
|
||||
mockResolveQueryMode.mockReturnValue('builder');
|
||||
});
|
||||
|
||||
it('does nothing when switching to the current kind', () => {
|
||||
const setSpec = jest.fn();
|
||||
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
|
||||
const state = builderState(fakeQuery('q', 'builder'));
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: tableSpec,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
setSpec,
|
||||
@@ -118,12 +134,12 @@ describe('usePanelTypeSwitch', () => {
|
||||
|
||||
it('on first visit: transforms the query and resets the spec to the new kind', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
const tableQuery = fakeQuery('table-current', 'builder');
|
||||
const state = builderState(tableQuery);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: tableSpec,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
setSpec,
|
||||
@@ -146,7 +162,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
it('seeds timestamp-desc Order By on every query when switching to a List panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
|
||||
builderState(fakeQuery('ts-current', 'builder')),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
@@ -155,7 +171,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
} as unknown as Query);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
@@ -174,7 +190,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
it('keeps one query and drops the formulas when switching to a Heatmap panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
|
||||
builderState(fakeQuery('ts-current', 'builder')),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
@@ -186,7 +202,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
} as unknown as Query);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
@@ -204,7 +220,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
it('swaps a percentile spatial aggregation for count when switching to a Heatmap panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
|
||||
builderState(fakeQuery('ts-current', 'builder')),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
@@ -222,7 +238,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
} as unknown as Query);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
@@ -242,7 +258,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
it('swaps count back for a percentile when a histogram leaves a Heatmap panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'heatmap-current', queryType: 'builder' } as Query),
|
||||
builderState(fakeQuery('heatmap-current', 'builder')),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
@@ -263,7 +279,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
} as unknown as Query);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.HEATMAP,
|
||||
setSpec,
|
||||
@@ -283,7 +299,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
it('leaves a non-histogram metric on sum when it leaves a Heatmap panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'heatmap-current', queryType: 'builder' } as Query),
|
||||
builderState(fakeQuery('heatmap-current', 'builder')),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
@@ -304,7 +320,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
} as unknown as Query);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.HEATMAP,
|
||||
setSpec,
|
||||
@@ -318,11 +334,11 @@ describe('usePanelTypeSwitch', () => {
|
||||
|
||||
it('coerces the query type when the new kind disallows it (promql → List)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
|
||||
const promQuery = fakeQuery('prom', 'promql');
|
||||
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
@@ -331,7 +347,7 @@ describe('usePanelTypeSwitch', () => {
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
// The hook asks the guard to resolve the active query type against the new kind…
|
||||
expect(mockResolveQueryType).toHaveBeenCalledWith(
|
||||
expect(mockResolveQueryMode).toHaveBeenCalledWith(
|
||||
'signoz/ListPanel',
|
||||
'promql',
|
||||
);
|
||||
@@ -340,16 +356,224 @@ describe('usePanelTypeSwitch', () => {
|
||||
expect((queryArg as Query).queryType).toBe('builder');
|
||||
});
|
||||
|
||||
it('keeps the AI tag when the new kind supports AI', () => {
|
||||
const setSpec = jest.fn();
|
||||
const aiQuery = fakeQuery('ai', 'builder', [
|
||||
{ builderQueryType: 'builder_ai_query' },
|
||||
]);
|
||||
mockUseQueryBuilder.mockReturnValue(builderState(aiQuery));
|
||||
mockResolveQueryMode.mockReturnValue('builder_ai_query');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: tableSpec,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/TimeSeriesPanel'));
|
||||
|
||||
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
|
||||
expect((queryArg as Query).builder).toBe(aiQuery.builder);
|
||||
expect((queryArg as Query).builder.queryData[0].builderQueryType).toBe(
|
||||
'builder_ai_query',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the AI tag when the new kind does not support AI', () => {
|
||||
const setSpec = jest.fn();
|
||||
const aiQuery = fakeQuery('ai', 'builder', [
|
||||
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
|
||||
]);
|
||||
const state = builderState(aiQuery);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
mockHandleQueryChange.mockImplementation((_type, query) => query);
|
||||
// The guard coerces to Query Builder; the tag must not survive it.
|
||||
mockResolveQueryMode.mockReturnValue('builder');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: tableSpec,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/TimeSeriesPanel'));
|
||||
|
||||
const redirected = state.redirectWithQueryBuilderData.mock
|
||||
.calls[0][0] as Query;
|
||||
expect(redirected.builder.queryData[0].builderQueryType).toBeUndefined();
|
||||
expect(redirected.builder.queryData[0].dataSource).toBe('traces');
|
||||
});
|
||||
|
||||
it('stays on the AI tab when returning to a kind last left on Query Builder', () => {
|
||||
const setSpec = jest.fn();
|
||||
const qbQuery = fakeQuery('qb', 'builder', [{ dataSource: 'logs' }]);
|
||||
const aiQuery = fakeQuery('ai', 'builder', [
|
||||
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
|
||||
]);
|
||||
let state = builderState(qbQuery);
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockHandleQueryChange.mockImplementation((_type, query) => query);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
|
||||
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
|
||||
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
|
||||
);
|
||||
|
||||
// Leave Table on Query Builder — its stash holds the QB query.
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
// On List the user moves to the AI tab, then returns to Table.
|
||||
state = builderState(aiQuery);
|
||||
mockResolveQueryMode.mockReturnValue('builder_ai_query');
|
||||
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
|
||||
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
|
||||
|
||||
const redirected = state.redirectWithQueryBuilderData.mock
|
||||
.calls[0][0] as Query;
|
||||
expect(redirected.builder.queryData[0].builderQueryType).toBe(
|
||||
'builder_ai_query',
|
||||
);
|
||||
const restored = setSpec.mock.calls[
|
||||
setSpec.mock.calls.length - 1
|
||||
][0] as DashboardtypesPanelSpecDTO;
|
||||
// The kind's own display spec still comes back; only the query follows the tab.
|
||||
expect(restored.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
|
||||
expect(restored.queries).toBe(CONVERTED);
|
||||
|
||||
// Table's Query Builder query waited behind the AI tab — the tab switch gives it back.
|
||||
act(() => result.current.onChangeQueryMode('builder'));
|
||||
const backToQB = state.redirectWithQueryBuilderData.mock.calls[1][0] as Query;
|
||||
expect(backToQB.builder).toBe(qbQuery.builder);
|
||||
});
|
||||
|
||||
describe('both builder tabs, per kind', () => {
|
||||
const qbQuery = fakeQuery('qb', 'builder', [{ dataSource: 'logs' }]);
|
||||
const aiQuery = fakeQuery('ai', 'builder', [
|
||||
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
|
||||
]);
|
||||
|
||||
// Table gets a Query Builder query and an AI one; the user leaves it on AI for List.
|
||||
function leaveTableOnAI(): {
|
||||
result: { current: ReturnType<typeof usePanelKindAndQueryModeSwitch> };
|
||||
rerender: (props: {
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
panelType: PANEL_TYPES;
|
||||
}) => void;
|
||||
setSpec: jest.Mock;
|
||||
setState: (query: Query) => ReturnType<typeof builderState>;
|
||||
} {
|
||||
const setSpec = jest.fn();
|
||||
let state = builderState(qbQuery);
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockHandleQueryChange.mockImplementation((_type, query) => query);
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
|
||||
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
|
||||
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
|
||||
);
|
||||
const setState = (query: Query): ReturnType<typeof builderState> => {
|
||||
state = builderState(query);
|
||||
return state;
|
||||
};
|
||||
|
||||
act(() => result.current.onChangeQueryMode('builder_ai_query'));
|
||||
setState(aiQuery);
|
||||
rerender({ spec: tableSpec, panelType: PANEL_TYPES.TABLE });
|
||||
mockResolveQueryMode.mockReturnValue('builder_ai_query');
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
return { result, rerender, setSpec, setState };
|
||||
}
|
||||
|
||||
it('rebuilds the parked query for the new kind on a first visit', () => {
|
||||
const { result } = leaveTableOnAI();
|
||||
|
||||
// Table's Query Builder query was rebuilt for List, and is one tab away there.
|
||||
expect(mockHandleQueryChange).toHaveBeenCalledWith(
|
||||
'list',
|
||||
expect.objectContaining({ builder: qbQuery.builder }),
|
||||
PANEL_TYPES.TABLE,
|
||||
);
|
||||
act(() => result.current.onChangeQueryMode('builder'));
|
||||
const { redirectWithQueryBuilderData } =
|
||||
mockUseQueryBuilder.mock.results[
|
||||
mockUseQueryBuilder.mock.results.length - 1
|
||||
].value;
|
||||
const backToQB = redirectWithQueryBuilderData.mock.calls[
|
||||
redirectWithQueryBuilderData.mock.calls.length - 1
|
||||
][0] as Query;
|
||||
expect(backToQB.builder.queryData[0].dataSource).toBe('logs');
|
||||
expect(backToQB.builder.queryData[0].builderQueryType).toBeUndefined();
|
||||
});
|
||||
|
||||
it("brings back both tabs' queries when revisiting a kind", () => {
|
||||
const { result, rerender, setSpec, setState } = leaveTableOnAI();
|
||||
|
||||
// Back to Table from List, still on the AI tab.
|
||||
const state = setState(
|
||||
fakeQuery('list-ai', 'builder', [
|
||||
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
|
||||
]),
|
||||
);
|
||||
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
|
||||
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
|
||||
|
||||
const restored = state.redirectWithQueryBuilderData.mock
|
||||
.calls[0][0] as Query;
|
||||
expect(restored.builder).toBe(aiQuery.builder);
|
||||
const restoredSpec = setSpec.mock.calls[
|
||||
setSpec.mock.calls.length - 1
|
||||
][0] as DashboardtypesPanelSpecDTO;
|
||||
expect(restoredSpec.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
|
||||
expect(restoredSpec.queries).toBe(TABLE_QUERIES);
|
||||
|
||||
// Table's Query Builder query is one tab away.
|
||||
act(() => result.current.onChangeQueryMode('builder'));
|
||||
const backToQB = state.redirectWithQueryBuilderData.mock
|
||||
.calls[1][0] as Query;
|
||||
expect(backToQB.builder).toBe(qbQuery.builder);
|
||||
});
|
||||
|
||||
it('swaps in the Query Builder query when revisiting a kind left on AI', () => {
|
||||
const { result, rerender, setSpec, setState } = leaveTableOnAI();
|
||||
|
||||
// On List the user is back on Query Builder, then returns to Table.
|
||||
const state = setState(
|
||||
fakeQuery('list-qb', 'builder', [{ dataSource: 'logs' }]),
|
||||
);
|
||||
mockResolveQueryMode.mockReturnValue('builder');
|
||||
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
|
||||
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
|
||||
|
||||
const restored = state.redirectWithQueryBuilderData.mock
|
||||
.calls[0][0] as Query;
|
||||
expect(restored.builder).toBe(qbQuery.builder);
|
||||
// The stashed preview queries were the AI query's; they're rebuilt for the swap.
|
||||
const restoredSpec = setSpec.mock.calls[
|
||||
setSpec.mock.calls.length - 1
|
||||
][0] as DashboardtypesPanelSpecDTO;
|
||||
expect(restoredSpec.queries).toBe(CONVERTED);
|
||||
|
||||
// Table's AI query is one tab away.
|
||||
act(() => result.current.onChangeQueryMode('builder_ai_query'));
|
||||
const backToAI = state.redirectWithQueryBuilderData.mock
|
||||
.calls[1][0] as Query;
|
||||
expect(backToAI.builder).toBe(aiQuery.builder);
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the original kind verbatim on switch-back (reversibility)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
|
||||
const tableQuery = fakeQuery('table-current', 'builder');
|
||||
const listQuery = fakeQuery('list-current', 'builder');
|
||||
let state = builderState(tableQuery);
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
|
||||
usePanelTypeSwitch({ ...props, setSpec }),
|
||||
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
|
||||
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
|
||||
);
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import {
|
||||
getSupportedSignals,
|
||||
requireQueryPanelDefinition,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
@@ -91,7 +94,7 @@ export function usePanelEditSession({
|
||||
// View modal shells) — asserted rather than assumed.
|
||||
const panelDefinition = requireQueryPanelDefinition(panelKind);
|
||||
const panelType = toPanelType(panelKind);
|
||||
const defaultSignal = panelDefinition.supportedSignals[0];
|
||||
const defaultSignal = getSupportedSignals(panelKind)[0];
|
||||
|
||||
const query = usePanelQuery({
|
||||
panel: draft,
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type {
|
||||
DashboardtypesPanelPluginDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { handleQueryChange } from 'lib/query/panelQuery';
|
||||
import type { PartialPanelTypes } from 'lib/query/panelTypeDataSourceFormValuesMap';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import {
|
||||
type BuilderVariant,
|
||||
type BuildersByVariant,
|
||||
type OrderByPayload,
|
||||
type Query,
|
||||
QueryMode,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
getQueryPanelDefinition,
|
||||
isStaticPanelKind,
|
||||
resolveQueryMode,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { getQueryModeSignals } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
|
||||
import {
|
||||
getBuilderVariant,
|
||||
getQueryMode,
|
||||
toBuilderVariant,
|
||||
withoutAIQueryTag,
|
||||
} from 'lib/query/queryMode';
|
||||
import { seedBuilderForMode } from 'pages/DashboardPage/DashboardContainer/Panels/utils/seedBuilderForMode';
|
||||
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
|
||||
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
import {
|
||||
getSwitchedPluginSpec,
|
||||
type SwitchedPluginSpec,
|
||||
} from '../getSwitchedPluginSpec';
|
||||
import {
|
||||
withPercentileHistogramAggregation,
|
||||
withSingleHeatmapQuery,
|
||||
} from '../utils/heatmapQuery';
|
||||
import { useQueryModeChange } from './useQueryModeChange';
|
||||
|
||||
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
|
||||
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
|
||||
{ columnName: 'timestamp', order: 'desc' },
|
||||
];
|
||||
|
||||
function withDefaultListOrder(query: Query): Query {
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((qd) =>
|
||||
qd.orderBy && qd.orderBy.length > 0
|
||||
? qd
|
||||
: { ...qd, orderBy: DEFAULT_LIST_ORDER_BY },
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** What a kind looks like when you leave it; restored verbatim if you return. */
|
||||
interface KindState {
|
||||
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
|
||||
queries: DashboardtypesQueryDTO[];
|
||||
query: Query;
|
||||
/** Both builder variants' queries as the kind was left. */
|
||||
builders: BuildersByVariant;
|
||||
}
|
||||
|
||||
interface UsePanelKindAndQueryModeSwitchArgs {
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
panelType: PANEL_TYPES;
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
interface UsePanelKindAndQueryModeSwitchApi {
|
||||
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
|
||||
onChangePanelKind: (newKind: PanelKind) => void;
|
||||
/** Switch authoring tab; shares the off-screen builder stash with the kind switch. */
|
||||
onChangeQueryMode: (key: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the edited panel's visualization kind. Mutating `plugin.kind` re-derives the
|
||||
* renderer, config sections, query-builder tabs and request type for free; this hook adds
|
||||
* the two things that don't: a per-kind session cache that makes switching reversible
|
||||
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
|
||||
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
|
||||
* It also owns the tab switch, stashing the off-screen builder variant per kind.
|
||||
*/
|
||||
export function usePanelKindAndQueryModeSwitch({
|
||||
spec,
|
||||
panelType,
|
||||
setSpec,
|
||||
}: UsePanelKindAndQueryModeSwitchArgs): UsePanelKindAndQueryModeSwitchApi {
|
||||
const {
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
updateAllQueriesOperators,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
|
||||
// Builder queries for the tabs not on screen; the live one is `currentQuery.builder`.
|
||||
const parkedBuilders = useRef<BuildersByVariant>({});
|
||||
|
||||
// Latest spec/query/type, read inside the stable callback without re-subscribing.
|
||||
const specRef = useRef(spec);
|
||||
specRef.current = spec;
|
||||
const queryRef = useRef(currentQuery);
|
||||
queryRef.current = currentQuery;
|
||||
const panelTypeRef = useRef(panelType);
|
||||
panelTypeRef.current = panelType;
|
||||
|
||||
const onChangeQueryMode = useQueryModeChange({
|
||||
panelType,
|
||||
supportedQueryModesKindMap:
|
||||
getQueryPanelDefinition(spec.plugin.kind as PanelKind)
|
||||
?.supportedQueryModesKindMap ?? {},
|
||||
parkedBuilders,
|
||||
});
|
||||
|
||||
const onChangePanelKind = useCallback(
|
||||
(newKind: PanelKind): void => {
|
||||
const currentSpec = specRef.current;
|
||||
const oldKind = currentSpec.plugin.kind as PanelKind;
|
||||
if (newKind === oldKind) {
|
||||
return;
|
||||
}
|
||||
void logEvent(DashboardDetailEvents.PanelTypeChanged, {
|
||||
from: oldKind,
|
||||
to: newKind,
|
||||
});
|
||||
const query = queryRef.current;
|
||||
|
||||
cacheRef.current.set(oldKind, {
|
||||
pluginSpec: currentSpec.plugin.spec,
|
||||
queries: currentSpec.queries,
|
||||
query,
|
||||
builders: {
|
||||
...parkedBuilders.current,
|
||||
[getBuilderVariant(query.builder)]: query.builder,
|
||||
},
|
||||
});
|
||||
|
||||
const newPanelType = toPanelType(newKind);
|
||||
const targetMode = resolveQueryMode(newKind, getQueryMode(query));
|
||||
const targetVariant = toBuilderVariant(targetMode);
|
||||
// An AI query is a builder query carrying the tag, so it rides on `builder`.
|
||||
const targetQueryType =
|
||||
targetMode === QueryMode.AI_QUERY_BUILDER
|
||||
? QueryMode.QUERY_BUILDER
|
||||
: targetMode;
|
||||
|
||||
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
|
||||
// dynamically-chosen kind can't be correlated with its spec statically (as in
|
||||
// `createDefaultPanel`). The surrounding spec stays fully typed.
|
||||
const buildSpec = (
|
||||
pluginSpec: DashboardtypesPanelPluginDTO['spec'] | SwitchedPluginSpec,
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
): DashboardtypesPanelSpecDTO => ({
|
||||
...currentSpec,
|
||||
plugin: {
|
||||
...currentSpec.plugin,
|
||||
kind: newKind,
|
||||
spec: pluginSpec,
|
||||
} as DashboardtypesPanelPluginDTO,
|
||||
queries,
|
||||
});
|
||||
const rebuildForNewKind = (source: Query): Query => {
|
||||
const transformed = handleQueryChange(
|
||||
newPanelType as keyof PartialPanelTypes,
|
||||
{ ...source, queryType: targetQueryType },
|
||||
panelTypeRef.current,
|
||||
);
|
||||
// Match a fresh list panel's default order so the builder's Order By isn't empty.
|
||||
const ordered =
|
||||
newKind === 'signoz/ListPanel'
|
||||
? withDefaultListOrder(transformed)
|
||||
: transformed;
|
||||
return newKind === 'signoz/HeatmapPanel'
|
||||
? withSingleHeatmapQuery(ordered)
|
||||
: withPercentileHistogramAggregation(ordered);
|
||||
};
|
||||
// Tab unused on this kind: AI gets a fresh seed, Query Builder reuses `source` untagged.
|
||||
const seedTargetTab = (source: Query): Query =>
|
||||
targetMode === QueryMode.AI_QUERY_BUILDER
|
||||
? {
|
||||
...source,
|
||||
builder: seedBuilderForMode({
|
||||
mode: targetMode,
|
||||
defaultSignal: getQueryModeSignals(
|
||||
getQueryPanelDefinition(newKind)?.supportedQueryModesKindMap,
|
||||
QueryMode.QUERY_BUILDER,
|
||||
)[0],
|
||||
panelType: newPanelType,
|
||||
updateAllQueriesOperators,
|
||||
}),
|
||||
}
|
||||
: withoutAIQueryTag(source);
|
||||
// Target tab's query comes from the stash; the rest stay parked.
|
||||
const unparkTargetTab = (
|
||||
source: Query,
|
||||
builders: BuildersByVariant,
|
||||
): { query: Query; parked: BuildersByVariant } => {
|
||||
const stored = targetVariant ? builders[targetVariant] : source.builder;
|
||||
const next = stored
|
||||
? { ...source, builder: stored }
|
||||
: seedTargetTab(source);
|
||||
const parked: BuildersByVariant = { ...builders };
|
||||
delete parked[getBuilderVariant(next.builder)];
|
||||
return { query: next, parked };
|
||||
};
|
||||
|
||||
// Revisit → restore the stash in the active mode; a static kind has no builder to re-seed.
|
||||
const cached = cacheRef.current.get(newKind);
|
||||
if (cached) {
|
||||
if (isStaticPanelKind(newKind)) {
|
||||
setSpec(buildSpec(cached.pluginSpec, cached.queries));
|
||||
return;
|
||||
}
|
||||
const restored = unparkTargetTab(cached.query, cached.builders);
|
||||
parkedBuilders.current = restored.parked;
|
||||
// The stashed preview queries belong to the stashed query, not a swapped-in one.
|
||||
setSpec(
|
||||
buildSpec(
|
||||
cached.pluginSpec,
|
||||
restored.query.builder === cached.query.builder
|
||||
? cached.queries
|
||||
: toPerses(restored.query, newPanelType),
|
||||
),
|
||||
);
|
||||
redirectWithQueryBuilderData({
|
||||
...restored.query,
|
||||
queryType: targetQueryType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// First visit to a static kind → fresh spec from its sections, queries
|
||||
// emptied (the API accepts nothing else), and the query builder left as-is:
|
||||
// the stash above keeps the old kind's query for the return trip.
|
||||
if (isStaticPanelKind(newKind)) {
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
setSpec(buildSpec(getSwitchedPluginSpec(currentSpec, newKind, signal), []));
|
||||
return;
|
||||
}
|
||||
|
||||
// First visit → rebuild both builder tabs' queries for the new panel type.
|
||||
const transformed = rebuildForNewKind(query);
|
||||
const rebuiltBuilders: BuildersByVariant = {};
|
||||
(Object.keys(parkedBuilders.current) as BuilderVariant[]).forEach(
|
||||
(variant) => {
|
||||
const parked = parkedBuilders.current[variant];
|
||||
if (parked) {
|
||||
rebuiltBuilders[variant] = rebuildForNewKind({
|
||||
...query,
|
||||
builder: parked,
|
||||
}).builder;
|
||||
}
|
||||
},
|
||||
);
|
||||
rebuiltBuilders[getBuilderVariant(transformed.builder)] =
|
||||
transformed.builder;
|
||||
const picked = unparkTargetTab(transformed, rebuiltBuilders);
|
||||
parkedBuilders.current = picked.parked;
|
||||
const nextQuery = picked.query;
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
|
||||
setSpec(
|
||||
buildSpec(
|
||||
getSwitchedPluginSpec(currentSpec, newKind, signal),
|
||||
toPerses(nextQuery, newPanelType),
|
||||
),
|
||||
);
|
||||
redirectWithQueryBuilderData(nextQuery);
|
||||
},
|
||||
[setSpec, redirectWithQueryBuilderData, updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
return { onChangePanelKind, onChangeQueryMode };
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import type {
|
||||
DashboardtypesPanelPluginDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ATTRIBUTE_TYPES, type PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { handleQueryChange } from 'lib/query/panelQuery';
|
||||
import type { PartialPanelTypes } from 'lib/query/panelTypeDataSourceFormValuesMap';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import type {
|
||||
IBuilderQuery,
|
||||
OrderByPayload,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import type {
|
||||
MetricAggregation,
|
||||
SpaceAggregation,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
import { isStaticPanelKind, resolveQueryType } from '../../Panels/capabilities';
|
||||
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
|
||||
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../queryV5/persesQueryAdapters';
|
||||
import {
|
||||
getSwitchedPluginSpec,
|
||||
type SwitchedPluginSpec,
|
||||
} from '../getSwitchedPluginSpec';
|
||||
|
||||
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
|
||||
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
|
||||
{ columnName: 'timestamp', order: 'desc' },
|
||||
];
|
||||
|
||||
const PERCENTILE_SPACE_AGGREGATIONS = new Set<SpaceAggregation>([
|
||||
'p50',
|
||||
'p75',
|
||||
'p90',
|
||||
'p95',
|
||||
'p99',
|
||||
]);
|
||||
|
||||
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
]);
|
||||
|
||||
const DEFAULT_HISTOGRAM_SPACE_AGGREGATION = 'p90';
|
||||
|
||||
/** What a heatmap cell holds, and the one space aggregation the kind offers. */
|
||||
const HEATMAP_HISTOGRAM_SPACE_AGGREGATION = 'count';
|
||||
|
||||
function withDefaultListOrder(query: Query): Query {
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((qd) =>
|
||||
qd.orderBy && qd.orderBy.length > 0
|
||||
? qd
|
||||
: { ...qd, orderBy: DEFAULT_LIST_ORDER_BY },
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A heatmap draws against one bucket axis, so the request takes exactly one enabled
|
||||
* query and refuses a formula over the cells. The per-kind cache restores whatever is
|
||||
* dropped here if the panel switches back.
|
||||
*
|
||||
* Percentiles go the same way: a histogram heatmap takes its axis from the `le` labels,
|
||||
* so a percentile draws the grid a count already draws, and the statement builder sums
|
||||
* the `le` counts either way.
|
||||
*/
|
||||
function withSingleHeatmapQuery(query: Query): Query {
|
||||
const [first] = query.builder.queryData;
|
||||
if (!first) {
|
||||
return query;
|
||||
}
|
||||
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: [
|
||||
{
|
||||
...first,
|
||||
spaceAggregation: withoutPercentile(first.spaceAggregation),
|
||||
aggregations: first.aggregations?.map((aggregation) => ({
|
||||
...aggregation,
|
||||
spaceAggregation: withoutPercentile(
|
||||
(aggregation as MetricAggregation).spaceAggregation,
|
||||
),
|
||||
})) as IBuilderQuery['aggregations'],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Cast because the substitute is a `SpaceAggregation` whichever of its callers `T` came from. */
|
||||
function withoutPercentile<T extends string | undefined>(
|
||||
spaceAggregation: T,
|
||||
): T {
|
||||
return (
|
||||
PERCENTILE_SPACE_AGGREGATIONS.has(spaceAggregation as SpaceAggregation)
|
||||
? HEATMAP_HISTOGRAM_SPACE_AGGREGATION
|
||||
: spaceAggregation
|
||||
) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes the count above: every other kind offers a histogram metric percentiles alone,
|
||||
* so a count carried out of a heatmap would sit in the selector with no option behind
|
||||
* it. Other metric types are left alone — count is a real choice for a sum or a gauge.
|
||||
*/
|
||||
function withPercentileHistogramAggregation(query: Query): Query {
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((queryData) => {
|
||||
if (
|
||||
!HISTOGRAM_ATTRIBUTE_TYPES.has(queryData.aggregateAttribute?.type ?? '')
|
||||
) {
|
||||
return queryData;
|
||||
}
|
||||
|
||||
return {
|
||||
...queryData,
|
||||
spaceAggregation: withoutCount(queryData.spaceAggregation),
|
||||
aggregations: queryData.aggregations?.map((aggregation) => ({
|
||||
...aggregation,
|
||||
spaceAggregation: withoutCount(
|
||||
(aggregation as MetricAggregation).spaceAggregation,
|
||||
),
|
||||
})) as IBuilderQuery['aggregations'],
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withoutCount<T extends string | undefined>(spaceAggregation: T): T {
|
||||
return (
|
||||
spaceAggregation === HEATMAP_HISTOGRAM_SPACE_AGGREGATION
|
||||
? DEFAULT_HISTOGRAM_SPACE_AGGREGATION
|
||||
: spaceAggregation
|
||||
) as T;
|
||||
}
|
||||
|
||||
/** What a kind looks like when you leave it; restored verbatim if you return. */
|
||||
interface KindState {
|
||||
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
|
||||
queries: DashboardtypesQueryDTO[];
|
||||
builderQuery: Query;
|
||||
}
|
||||
|
||||
interface UsePanelTypeSwitchArgs {
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
panelType: PANEL_TYPES;
|
||||
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
}
|
||||
|
||||
interface UsePanelTypeSwitchApi {
|
||||
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
|
||||
onChangePanelKind: (newKind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the edited panel's visualization kind. Mutating `plugin.kind` re-derives the
|
||||
* renderer, config sections, query-builder tabs and request type for free; this hook adds
|
||||
* the two things that don't: a per-kind session cache that makes switching reversible
|
||||
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
|
||||
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
|
||||
*/
|
||||
export function usePanelTypeSwitch({
|
||||
spec,
|
||||
panelType,
|
||||
setSpec,
|
||||
}: UsePanelTypeSwitchArgs): UsePanelTypeSwitchApi {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
|
||||
|
||||
// Latest spec/query/type, read inside the stable callback without re-subscribing.
|
||||
const specRef = useRef(spec);
|
||||
specRef.current = spec;
|
||||
const queryRef = useRef(currentQuery);
|
||||
queryRef.current = currentQuery;
|
||||
const panelTypeRef = useRef(panelType);
|
||||
panelTypeRef.current = panelType;
|
||||
|
||||
const onChangePanelKind = useCallback(
|
||||
(newKind: PanelKind): void => {
|
||||
const currentSpec = specRef.current;
|
||||
const oldKind = currentSpec.plugin.kind as PanelKind;
|
||||
if (newKind === oldKind) {
|
||||
return;
|
||||
}
|
||||
void logEvent(DashboardDetailEvents.PanelTypeChanged, {
|
||||
from: oldKind,
|
||||
to: newKind,
|
||||
});
|
||||
const query = queryRef.current;
|
||||
|
||||
cacheRef.current.set(oldKind, {
|
||||
pluginSpec: currentSpec.plugin.spec,
|
||||
queries: currentSpec.queries,
|
||||
builderQuery: query,
|
||||
});
|
||||
|
||||
const newPanelType = toPanelType(newKind);
|
||||
|
||||
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
|
||||
// dynamically-chosen kind can't be correlated with its spec statically (as in
|
||||
// `createDefaultPanel`). The surrounding spec stays fully typed.
|
||||
const buildSpec = (
|
||||
pluginSpec: DashboardtypesPanelPluginDTO['spec'] | SwitchedPluginSpec,
|
||||
queries: DashboardtypesQueryDTO[],
|
||||
): DashboardtypesPanelSpecDTO => ({
|
||||
...currentSpec,
|
||||
plugin: {
|
||||
...currentSpec.plugin,
|
||||
kind: newKind,
|
||||
spec: pluginSpec,
|
||||
} as DashboardtypesPanelPluginDTO,
|
||||
queries,
|
||||
});
|
||||
|
||||
// Revisit → restore the stash verbatim (the reversibility path). A static
|
||||
// kind's stash carries `queries: []` and its builder query is untouched —
|
||||
// there is no builder to re-seed for it.
|
||||
const cached = cacheRef.current.get(newKind);
|
||||
if (cached) {
|
||||
setSpec(buildSpec(cached.pluginSpec, cached.queries));
|
||||
if (!isStaticPanelKind(newKind)) {
|
||||
redirectWithQueryBuilderData(cached.builderQuery);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// First visit to a static kind → fresh spec from its sections, queries
|
||||
// emptied (the API accepts nothing else), and the query builder left as-is:
|
||||
// the stash above keeps the old kind's query for the return trip.
|
||||
if (isStaticPanelKind(newKind)) {
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
setSpec(buildSpec(getSwitchedPluginSpec(currentSpec, newKind, signal), []));
|
||||
return;
|
||||
}
|
||||
|
||||
// First visit → coerce the query type if the new kind disallows it, then
|
||||
// rebuild the builder query for the new type.
|
||||
const queryType = resolveQueryType(newKind, query.queryType);
|
||||
const transformed = handleQueryChange(
|
||||
newPanelType as keyof PartialPanelTypes,
|
||||
{ ...query, queryType },
|
||||
panelTypeRef.current,
|
||||
);
|
||||
// Match a fresh list panel's default order so the builder's Order By isn't empty.
|
||||
let nextQuery =
|
||||
newKind === 'signoz/ListPanel'
|
||||
? withDefaultListOrder(transformed)
|
||||
: transformed;
|
||||
nextQuery =
|
||||
newKind === 'signoz/HeatmapPanel'
|
||||
? withSingleHeatmapQuery(nextQuery)
|
||||
: withPercentileHistogramAggregation(nextQuery);
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
|
||||
setSpec(
|
||||
buildSpec(
|
||||
getSwitchedPluginSpec(currentSpec, newKind, signal),
|
||||
toPerses(nextQuery, newPanelType),
|
||||
),
|
||||
);
|
||||
redirectWithQueryBuilderData(nextQuery);
|
||||
},
|
||||
[setSpec, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
return { onChangePanelKind };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type MutableRefObject, useCallback } from 'react';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
|
||||
import {
|
||||
getQueryModeSignals,
|
||||
type SupportedQueryModesKindMap,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
|
||||
import {
|
||||
type BuildersByVariant,
|
||||
type Query,
|
||||
QueryMode,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { getBuilderVariant, toBuilderVariant } from 'lib/query/queryMode';
|
||||
import { seedBuilderForMode } from 'pages/DashboardPage/DashboardContainer/Panels/utils/seedBuilderForMode';
|
||||
|
||||
interface UseQueryModeChangeArgs {
|
||||
panelType: PANEL_TYPES;
|
||||
supportedQueryModesKindMap: SupportedQueryModesKindMap;
|
||||
/** Builder queries for the variant not on screen. */
|
||||
parkedBuilders: MutableRefObject<BuildersByVariant>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab switching. Query Builder and AI share `currentQuery.builder`, so crossing between
|
||||
* them swaps in the parked query, seeding a first visit; ClickHouse and PromQL pass through.
|
||||
*/
|
||||
export function useQueryModeChange({
|
||||
panelType,
|
||||
supportedQueryModesKindMap,
|
||||
parkedBuilders,
|
||||
}: UseQueryModeChangeArgs): (key: string) => void {
|
||||
const {
|
||||
currentQuery,
|
||||
redirectWithQueryBuilderData,
|
||||
updateAllQueriesOperators,
|
||||
} = useQueryBuilder();
|
||||
|
||||
return useCallback(
|
||||
(key: string): void => {
|
||||
const targetMode = key as QueryMode;
|
||||
const targetVariant = toBuilderVariant(targetMode);
|
||||
const heldVariant = getBuilderVariant(currentQuery.builder);
|
||||
|
||||
const seedFor = (mode: QueryMode): Query['builder'] =>
|
||||
seedBuilderForMode({
|
||||
mode,
|
||||
defaultSignal: getQueryModeSignals(
|
||||
supportedQueryModesKindMap,
|
||||
QueryMode.QUERY_BUILDER,
|
||||
)[0],
|
||||
panelType,
|
||||
updateAllQueriesOperators,
|
||||
});
|
||||
|
||||
// Keyed on what `builder` holds, not the active tab: ClickHouse → QB must not reseed.
|
||||
let { builder } = currentQuery;
|
||||
if (targetVariant && targetVariant !== heldVariant) {
|
||||
parkedBuilders.current[heldVariant] = currentQuery.builder;
|
||||
builder = parkedBuilders.current[targetVariant] ?? seedFor(targetMode);
|
||||
delete parkedBuilders.current[targetVariant];
|
||||
}
|
||||
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
// An AI query is a builder query carrying the AI tag; its query type stays `builder`.
|
||||
queryType:
|
||||
targetMode === QueryMode.AI_QUERY_BUILDER
|
||||
? QueryMode.QUERY_BUILDER
|
||||
: targetMode,
|
||||
builder,
|
||||
});
|
||||
},
|
||||
[
|
||||
currentQuery,
|
||||
panelType,
|
||||
parkedBuilders,
|
||||
redirectWithQueryBuilderData,
|
||||
supportedQueryModesKindMap,
|
||||
updateAllQueriesOperators,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { NewPanelTarget } from '../patchOps';
|
||||
import QueryEditorBody from './QueryEditorBody';
|
||||
import StaticEditorBody from './StaticEditorBody';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
|
||||
import { usePanelKindAndQueryModeSwitch } from './hooks/usePanelKindAndQueryModeSwitch';
|
||||
|
||||
export interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
@@ -40,11 +40,12 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
const originalPanelKind = (savedPanel ?? panel).spec.plugin.kind;
|
||||
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draftApi.draft.spec,
|
||||
panelType: toPanelType(panelKind),
|
||||
setSpec: draftApi.setSpec,
|
||||
});
|
||||
const { onChangePanelKind, onChangeQueryMode } =
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: draftApi.draft.spec,
|
||||
panelType: toPanelType(panelKind),
|
||||
setSpec: draftApi.setSpec,
|
||||
});
|
||||
|
||||
if (panelDefinition.mode === 'static') {
|
||||
return (
|
||||
@@ -65,6 +66,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
|
||||
panelDefinition={panelDefinition}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
onChangeQueryMode={onChangeQueryMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
|
||||
import type {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import type {
|
||||
MetricAggregation,
|
||||
SpaceAggregation,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
const PERCENTILE_SPACE_AGGREGATIONS = new Set<SpaceAggregation>([
|
||||
'p50',
|
||||
'p75',
|
||||
'p90',
|
||||
'p95',
|
||||
'p99',
|
||||
]);
|
||||
|
||||
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
]);
|
||||
|
||||
const DEFAULT_HISTOGRAM_SPACE_AGGREGATION = 'p90';
|
||||
|
||||
/** What a heatmap cell holds, and the one space aggregation the kind offers. */
|
||||
const HEATMAP_HISTOGRAM_SPACE_AGGREGATION = 'count';
|
||||
|
||||
/**
|
||||
* A heatmap draws against one bucket axis, so the request takes exactly one enabled
|
||||
* query and refuses a formula over the cells. The per-kind cache restores whatever is
|
||||
* dropped here if the panel switches back.
|
||||
*
|
||||
* Percentiles go the same way: a histogram heatmap takes its axis from the `le` labels,
|
||||
* so a percentile draws the grid a count already draws, and the statement builder sums
|
||||
* the `le` counts either way.
|
||||
*/
|
||||
export function withSingleHeatmapQuery(query: Query): Query {
|
||||
const [first] = query.builder.queryData;
|
||||
if (!first) {
|
||||
return query;
|
||||
}
|
||||
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: [
|
||||
{
|
||||
...first,
|
||||
spaceAggregation: withoutPercentile(first.spaceAggregation),
|
||||
aggregations: first.aggregations?.map((aggregation) => ({
|
||||
...aggregation,
|
||||
spaceAggregation: withoutPercentile(
|
||||
(aggregation as MetricAggregation).spaceAggregation,
|
||||
),
|
||||
})) as IBuilderQuery['aggregations'],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Cast because the substitute is a `SpaceAggregation` whichever of its callers `T` came from. */
|
||||
function withoutPercentile<T extends string | undefined>(
|
||||
spaceAggregation: T,
|
||||
): T {
|
||||
return (
|
||||
PERCENTILE_SPACE_AGGREGATIONS.has(spaceAggregation as SpaceAggregation)
|
||||
? HEATMAP_HISTOGRAM_SPACE_AGGREGATION
|
||||
: spaceAggregation
|
||||
) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes the count above: every other kind offers a histogram metric percentiles alone,
|
||||
* so a count carried out of a heatmap would sit in the selector with no option behind
|
||||
* it. Other metric types are left alone — count is a real choice for a sum or a gauge.
|
||||
*/
|
||||
export function withPercentileHistogramAggregation(query: Query): Query {
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((queryData) => {
|
||||
if (
|
||||
!HISTOGRAM_ATTRIBUTE_TYPES.has(queryData.aggregateAttribute?.type ?? '')
|
||||
) {
|
||||
return queryData;
|
||||
}
|
||||
|
||||
return {
|
||||
...queryData,
|
||||
spaceAggregation: withoutCount(queryData.spaceAggregation),
|
||||
aggregations: queryData.aggregations?.map((aggregation) => ({
|
||||
...aggregation,
|
||||
spaceAggregation: withoutCount(
|
||||
(aggregation as MetricAggregation).spaceAggregation,
|
||||
),
|
||||
})) as IBuilderQuery['aggregations'],
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withoutCount<T extends string | undefined>(spaceAggregation: T): T {
|
||||
return (
|
||||
spaceAggregation === HEATMAP_HISTOGRAM_SPACE_AGGREGATION
|
||||
? DEFAULT_HISTOGRAM_SPACE_AGGREGATION
|
||||
: spaceAggregation
|
||||
) as T;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { UNSUPPORTED_PANEL } from '../kinds/UnsupportedPanel/definition';
|
||||
import { getPanelDefinition, isPanelKindSupported } from '../registry';
|
||||
@@ -11,12 +12,13 @@ import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
|
||||
import {
|
||||
getQueryPanelDefinition,
|
||||
requireQueryPanelDefinition,
|
||||
getSupportedQueryModes,
|
||||
getSupportedQueryTypes,
|
||||
getSupportedSignals,
|
||||
isPanelCombinationValid,
|
||||
isQueryTypeSupportedByPanelKind,
|
||||
isSignalSupported,
|
||||
resolveQueryType,
|
||||
resolveQueryMode,
|
||||
} from '../capabilities';
|
||||
import type { PanelKind } from '../types/panelKind';
|
||||
|
||||
@@ -38,6 +40,48 @@ const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
|
||||
'signoz/TextPanel': [],
|
||||
};
|
||||
|
||||
const EXPECTED_QUERY_MODES: Record<PanelKind, QueryMode[]> = {
|
||||
'signoz/TimeSeriesPanel': [
|
||||
QUERY_BUILDER,
|
||||
CLICKHOUSE,
|
||||
PROM,
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
],
|
||||
'signoz/BarChartPanel': [
|
||||
QUERY_BUILDER,
|
||||
CLICKHOUSE,
|
||||
PROM,
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
],
|
||||
'signoz/AreaChartPanel': [
|
||||
QUERY_BUILDER,
|
||||
CLICKHOUSE,
|
||||
PROM,
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
],
|
||||
'signoz/NumberPanel': [
|
||||
QUERY_BUILDER,
|
||||
CLICKHOUSE,
|
||||
PROM,
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
],
|
||||
'signoz/HistogramPanel': [
|
||||
QUERY_BUILDER,
|
||||
CLICKHOUSE,
|
||||
PROM,
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
],
|
||||
'signoz/HeatmapPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
|
||||
'signoz/PieChartPanel': [
|
||||
QUERY_BUILDER,
|
||||
CLICKHOUSE,
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
],
|
||||
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE, QueryMode.AI_QUERY_BUILDER],
|
||||
'signoz/ListPanel': [QUERY_BUILDER, QueryMode.AI_QUERY_BUILDER],
|
||||
'signoz/TextPanel': [],
|
||||
};
|
||||
|
||||
const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
|
||||
'signoz/TimeSeriesPanel': [metrics, logs, traces],
|
||||
'signoz/BarChartPanel': [metrics, logs, traces],
|
||||
@@ -223,6 +267,15 @@ describe('panel capabilities guard', () => {
|
||||
expect(isSignalSupported('signoz/ListPanel', logs)).toBe(true);
|
||||
expect(isSignalSupported('signoz/ListPanel', traces)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a specific mode when one is given', () => {
|
||||
expect(
|
||||
getSupportedSignals('signoz/TablePanel', QueryMode.AI_QUERY_BUILDER),
|
||||
).toStrictEqual([traces]);
|
||||
expect(
|
||||
getSupportedSignals('signoz/TablePanel', QueryMode.CLICKHOUSE),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPanelCombinationValid', () => {
|
||||
@@ -271,18 +324,36 @@ describe('panel capabilities guard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveQueryType', () => {
|
||||
it('keeps a supported query type', () => {
|
||||
expect(resolveQueryType('signoz/TimeSeriesPanel', PROM)).toBe(PROM);
|
||||
expect(resolveQueryType('signoz/ListPanel', QUERY_BUILDER)).toBe(
|
||||
QUERY_BUILDER,
|
||||
describe('getSupportedQueryModes', () => {
|
||||
it.each(Object.entries(EXPECTED_QUERY_MODES))(
|
||||
'%s supports %j',
|
||||
(kind, modes) => {
|
||||
expect(getSupportedQueryModes(kind as PanelKind)).toStrictEqual(modes);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps AI out of the query types', () => {
|
||||
expect(getSupportedQueryTypes('signoz/TimeSeriesPanel')).not.toContain(
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('coerces an unsupported query type to the first supported one', () => {
|
||||
describe('resolveQueryMode', () => {
|
||||
it('keeps a supported mode', () => {
|
||||
expect(resolveQueryMode('signoz/TimeSeriesPanel', PROM)).toBe(PROM);
|
||||
expect(
|
||||
resolveQueryMode('signoz/TablePanel', QueryMode.AI_QUERY_BUILDER),
|
||||
).toBe(QueryMode.AI_QUERY_BUILDER);
|
||||
expect(
|
||||
resolveQueryMode('signoz/ListPanel', QueryMode.AI_QUERY_BUILDER),
|
||||
).toBe(QueryMode.AI_QUERY_BUILDER);
|
||||
});
|
||||
|
||||
it('coerces an unsupported mode to the first supported one', () => {
|
||||
// PromQL → List has no PromQL, falls back to its first (and only) type.
|
||||
expect(resolveQueryType('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
|
||||
expect(resolveQueryType('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
|
||||
expect(resolveQueryMode('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
|
||||
expect(resolveQueryMode('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { type TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getPanelDefinition } from './registry';
|
||||
import { getQueryModeSignals } from './types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
|
||||
import type { PanelKind } from './types/panelKind';
|
||||
|
||||
@@ -48,11 +50,17 @@ export function requireQueryPanelDefinition(
|
||||
return definition;
|
||||
}
|
||||
|
||||
/** Signals a kind can visualize. */
|
||||
const DEFAULT_QUERY_MODE = QueryMode.QUERY_BUILDER;
|
||||
|
||||
/** Signals a kind can visualize in `mode`. */
|
||||
export function getSupportedSignals(
|
||||
kind: PanelKind,
|
||||
mode: QueryMode = DEFAULT_QUERY_MODE,
|
||||
): TelemetrytypesSignalDTO[] {
|
||||
return getQueryPanelDefinition(kind)?.supportedSignals ?? [];
|
||||
return getQueryModeSignals(
|
||||
getQueryPanelDefinition(kind)?.supportedQueryModesKindMap,
|
||||
mode,
|
||||
);
|
||||
}
|
||||
|
||||
export function isSignalSupported(
|
||||
@@ -62,9 +70,16 @@ export function isSignalSupported(
|
||||
return getSupportedSignals(kind).includes(signal);
|
||||
}
|
||||
|
||||
/** Authoring modes a kind supports, in tab order. */
|
||||
export function getSupportedQueryModes(kind: PanelKind): QueryMode[] {
|
||||
const modes = getQueryPanelDefinition(kind)?.supportedQueryModesKindMap ?? {};
|
||||
return Object.keys(modes) as QueryMode[];
|
||||
}
|
||||
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
|
||||
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
|
||||
return getQueryPanelDefinition(kind)?.supportedQueryTypes ?? [];
|
||||
return getSupportedQueryModes(kind).filter(
|
||||
(mode): mode is EQueryType => mode !== QueryMode.AI_QUERY_BUILDER,
|
||||
);
|
||||
}
|
||||
|
||||
export function isQueryTypeSupportedByPanelKind(
|
||||
@@ -101,20 +116,15 @@ export function isPanelCombinationValid({
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The query type to use for a kind given a `preferred` one: keep it if the kind
|
||||
* supports it, otherwise fall back to the kind's first supported type. Used when
|
||||
* switching panel kinds to coerce an unsupported active query type (e.g. PromQL → a
|
||||
* List panel coerces to Query Builder).
|
||||
*/
|
||||
export function resolveQueryType(
|
||||
/** `preferred` if the kind supports it, else its first supported mode (e.g. PromQL → List gives Query Builder). */
|
||||
export function resolveQueryMode(
|
||||
kind: PanelKind,
|
||||
preferred: EQueryType,
|
||||
): EQueryType {
|
||||
const supported = getSupportedQueryTypes(kind);
|
||||
preferred: QueryMode,
|
||||
): QueryMode {
|
||||
const supported = getSupportedQueryModes(kind);
|
||||
if (supported.includes(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
// A query-less kind has no supported types; the builder is the neutral answer.
|
||||
return supported[0] ?? EQueryType.QUERY_BUILDER;
|
||||
// A query-less kind has no supported modes; the builder is the neutral answer.
|
||||
return supported[0] ?? DEFAULT_QUERY_MODE;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChartArea } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import { QueryModeKind } from '../../types/panelCapabilities';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/AreaChartPanel'> = {
|
||||
kind: 'signoz/AreaChartPanel',
|
||||
@@ -18,16 +19,22 @@ export const definition: PanelDefinition<'signoz/AreaChartPanel'> = {
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportedQueryModesKindMap: {
|
||||
[QueryMode.QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
},
|
||||
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
[QueryMode.AI_QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [TelemetrytypesSignalDTO.traces],
|
||||
},
|
||||
},
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BarChart } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import { QueryModeKind } from '../../types/panelCapabilities';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
kind: 'signoz/BarChartPanel',
|
||||
@@ -18,16 +19,22 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportedQueryModesKindMap: {
|
||||
[QueryMode.QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
},
|
||||
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
[QueryMode.AI_QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [TelemetrytypesSignalDTO.traces],
|
||||
},
|
||||
},
|
||||
queryBuilderFields: {},
|
||||
// Bars are binned client-side from a raw time series, so the request asks for a
|
||||
// step interval wide enough to keep the bar count readable (V1 parity).
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LayoutGrid } from '@signozhq/icons';
|
||||
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import { QueryModeKind } from '../../types/panelCapabilities';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/HeatmapPanel'> = {
|
||||
kind: 'signoz/HeatmapPanel',
|
||||
@@ -20,13 +21,15 @@ export const definition: PanelDefinition<'signoz/HeatmapPanel'> = {
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
// Only metrics carry a bucket axis; the request rejects the other signals.
|
||||
supportedSignals: [TelemetrytypesSignalDTO.metrics],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportedQueryModesKindMap: {
|
||||
// Only metrics carry a bucket axis; the request rejects the other signals.
|
||||
[QueryMode.QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [TelemetrytypesSignalDTO.metrics],
|
||||
},
|
||||
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
},
|
||||
// The request rejects both: a point is a count per bucket, not a single value.
|
||||
queryBuilderFields: {
|
||||
[QueryBuilderField.Functions]: { state: 'hidden' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BarChart } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import { QueryModeKind } from '../../types/panelCapabilities';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
kind: 'signoz/HistogramPanel',
|
||||
@@ -18,16 +19,22 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportedQueryModesKindMap: {
|
||||
[QueryMode.QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
},
|
||||
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
[QueryMode.AI_QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [TelemetrytypesSignalDTO.traces],
|
||||
},
|
||||
},
|
||||
queryBuilderFields: {},
|
||||
// Buckets are computed client-side from the raw series, so the request is a plain
|
||||
// time series — the bucket count is a display concern, not a query one.
|
||||
|
||||
@@ -10,6 +10,7 @@ function ListEditorPane({
|
||||
panelDefinition,
|
||||
signal,
|
||||
isLoadingQueries,
|
||||
onChangeQueryMode,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
stickyHeader,
|
||||
@@ -20,6 +21,7 @@ function ListEditorPane({
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={panelDefinition}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
onChangeQueryMode={onChangeQueryMode}
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
onCancelQuery={onCancelQuery}
|
||||
stickyHeader={stickyHeader}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { List } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import { QueryModeKind } from '../../types/panelCapabilities';
|
||||
import ListEditorPane from './ListEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
kind: 'signoz/ListPanel',
|
||||
@@ -18,11 +19,16 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
Renderer,
|
||||
EditorPane: ListEditorPane,
|
||||
// Raw records come from logs and traces; metrics don't produce row data.
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER],
|
||||
supportedQueryModesKindMap: {
|
||||
[QueryMode.QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [TelemetrytypesSignalDTO.logs, TelemetrytypesSignalDTO.traces],
|
||||
},
|
||||
[QueryMode.AI_QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [TelemetrytypesSignalDTO.traces],
|
||||
},
|
||||
},
|
||||
// No deviation from the baseline the raw request type below already implies.
|
||||
queryBuilderFields: {},
|
||||
sections,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Hash } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import { QueryModeKind } from '../../types/panelCapabilities';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
kind: 'signoz/NumberPanel',
|
||||
@@ -18,16 +19,22 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportedQueryModesKindMap: {
|
||||
[QueryMode.QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
},
|
||||
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
[QueryMode.AI_QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [TelemetrytypesSignalDTO.traces],
|
||||
},
|
||||
},
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChartPie } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import { QueryModeKind } from '../../types/panelCapabilities';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
kind: 'signoz/PieChartPanel',
|
||||
@@ -18,12 +19,21 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
supportedQueryModesKindMap: {
|
||||
[QueryMode.QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
},
|
||||
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
[QueryMode.AI_QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [TelemetrytypesSignalDTO.traces],
|
||||
},
|
||||
},
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Table } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import { QueryModeKind } from '../../types/panelCapabilities';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
kind: 'signoz/TablePanel',
|
||||
@@ -18,12 +19,21 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
supportedQueryModesKindMap: {
|
||||
[QueryMode.QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
},
|
||||
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
[QueryMode.AI_QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [TelemetrytypesSignalDTO.traces],
|
||||
},
|
||||
},
|
||||
queryBuilderFields: {},
|
||||
// The only kind that asks the server to transpose its scalar result into UI rows.
|
||||
queryCapabilities: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChartLine } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import { QueryModeKind } from '../../types/panelCapabilities';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
@@ -18,16 +19,22 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections,
|
||||
supportedSignals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [
|
||||
EQueryType.QUERY_BUILDER,
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportedQueryModesKindMap: {
|
||||
[QueryMode.QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
},
|
||||
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
|
||||
[QueryMode.AI_QUERY_BUILDER]: {
|
||||
kind: QueryModeKind.SIGNAL,
|
||||
signals: [TelemetrytypesSignalDTO.traces],
|
||||
},
|
||||
},
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
|
||||
@@ -25,8 +25,7 @@ export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
|
||||
Renderer,
|
||||
EditorPane: QueryBuilderEditorPane,
|
||||
sections: [],
|
||||
supportedSignals: [],
|
||||
supportedQueryTypes: [],
|
||||
supportedQueryModesKindMap: {},
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
type TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export type { QueryBuilderFieldsConfig } from 'components/QueryBuilderV2/queryBuilderFields.types';
|
||||
|
||||
@@ -34,3 +38,25 @@ export interface PanelQueryCapabilities {
|
||||
export function isRawRequest(capabilities: PanelQueryCapabilities): boolean {
|
||||
return capabilities.requestType === Querybuildertypesv5RequestTypeDTO.raw;
|
||||
}
|
||||
|
||||
export enum QueryModeKind {
|
||||
SIGNAL = 'signal',
|
||||
SIGNAL_LESS = 'signal-less',
|
||||
}
|
||||
|
||||
export type QueryModeCapability =
|
||||
| { kind: QueryModeKind.SIGNAL; signals: TelemetrytypesSignalDTO[] }
|
||||
| { kind: QueryModeKind.SIGNAL_LESS };
|
||||
|
||||
export type SupportedQueryModesKindMap = Partial<
|
||||
Record<QueryMode, QueryModeCapability>
|
||||
>;
|
||||
|
||||
/** Signals `mode` accepts; empty for a signal-less or undeclared mode. */
|
||||
export function getQueryModeSignals(
|
||||
modes: SupportedQueryModesKindMap | undefined,
|
||||
mode: QueryMode,
|
||||
): TelemetrytypesSignalDTO[] {
|
||||
const capability = modes?.[mode];
|
||||
return capability?.kind === QueryModeKind.SIGNAL ? capability.signals : [];
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ChartLine } from '@signozhq/icons';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { SectionConfig } from './sections';
|
||||
import type { AnyPanelInteractionProps } from './interactions';
|
||||
@@ -12,6 +11,7 @@ import type { PanelKind } from './panelKind';
|
||||
import type {
|
||||
PanelQueryCapabilities,
|
||||
QueryBuilderFieldsConfig,
|
||||
SupportedQueryModesKindMap,
|
||||
} from './panelCapabilities';
|
||||
import type {
|
||||
BaseRendererProps,
|
||||
@@ -94,6 +94,8 @@ export interface QueryEditorPaneProps {
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
isLoadingQueries: boolean;
|
||||
/** Switch authoring tab (Query Builder / AI / ClickHouse / PromQL), owned by the host. */
|
||||
onChangeQueryMode: (key: string) => void;
|
||||
onStageRunQuery: () => void;
|
||||
onCancelQuery: () => void;
|
||||
/** Pin the tabs row to the pane top; the View modal opts out. */
|
||||
@@ -114,10 +116,8 @@ export interface QueryPanelDefinition<
|
||||
Renderer: ComponentType<PanelRendererProps<K>>;
|
||||
/** Lower editor pane — the shared query-builder pane, or a kind wrapper of it. */
|
||||
EditorPane: ComponentType<QueryEditorPaneProps>;
|
||||
/** Signals this kind can visualize. */
|
||||
supportedSignals: TelemetrytypesSignalDTO[];
|
||||
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
|
||||
supportedQueryTypes: EQueryType[];
|
||||
/** Authoring modes (Query Builder / ClickHouse / PromQL / AI) and the signals each accepts. */
|
||||
supportedQueryModesKindMap: SupportedQueryModesKindMap;
|
||||
queryBuilderFields: QueryBuilderFieldsConfig;
|
||||
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
|
||||
queryCapabilities: PanelQueryCapabilities;
|
||||
|
||||
@@ -4,6 +4,11 @@ import type {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
isBuilderEnvelope,
|
||||
isBuilderPluginKind,
|
||||
} from 'lib/query/builderEnvelope';
|
||||
|
||||
/**
|
||||
* Flattens a panel's queries into its builder queries, unwrapping
|
||||
* `CompositeQuery` envelopes. Non-builder kinds (PromQL, ClickHouseSQL, Formula,
|
||||
@@ -16,13 +21,13 @@ export function getBuilderQueries(
|
||||
const flattened: BuilderQuery[] = [];
|
||||
queries.forEach((envelope) => {
|
||||
const plugin = envelope.spec.plugin;
|
||||
if (plugin.kind === 'signoz/BuilderQuery') {
|
||||
if (isBuilderPluginKind(plugin.kind)) {
|
||||
flattened.push(plugin.spec as BuilderQuery);
|
||||
return;
|
||||
}
|
||||
if (plugin.kind === 'signoz/CompositeQuery') {
|
||||
(plugin.spec.queries || []).forEach((sub) => {
|
||||
if (sub.type === 'builder_query') {
|
||||
if (isBuilderEnvelope(sub)) {
|
||||
flattened.push(sub.spec as BuilderQuery);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getQueryPanelDefinition } from '../capabilities';
|
||||
import { getQueryPanelDefinition, getSupportedSignals } from '../capabilities';
|
||||
import { toPanelType } from '../types/panelKind';
|
||||
import { fromPerses } from '../../queryV5/persesQueryAdapters';
|
||||
|
||||
@@ -22,7 +22,7 @@ export function getPanelBuilderQuery(
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
const [defaultSignal] = definition.supportedSignals;
|
||||
const [defaultSignal] = getSupportedSignals(kind);
|
||||
// A query-less panel seeds from the kind's first supported signal — `fromPerses`'s
|
||||
// metrics default isn't authorable in every kind (e.g. List).
|
||||
if (panel.spec.queries.length === 0 && defaultSignal) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryAIWithType,
|
||||
type PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
DataSource,
|
||||
type QueryBuilderContextType,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
interface SeedBuilderForModeArgs {
|
||||
mode: QueryMode;
|
||||
/** The kind's first Query Builder signal; what a fresh panel of this kind starts on. */
|
||||
defaultSignal?: TelemetrytypesSignalDTO;
|
||||
panelType: PANEL_TYPES;
|
||||
updateAllQueriesOperators: QueryBuilderContextType['updateAllQueriesOperators'];
|
||||
}
|
||||
|
||||
/** A fresh builder query for `mode`: AI is traces-only, everything else follows the kind. */
|
||||
export function seedBuilderForMode({
|
||||
mode,
|
||||
defaultSignal,
|
||||
panelType,
|
||||
updateAllQueriesOperators,
|
||||
}: SeedBuilderForModeArgs): Query['builder'] {
|
||||
if (mode === QueryMode.AI_QUERY_BUILDER) {
|
||||
return updateAllQueriesOperators(
|
||||
initialQueryAIWithType,
|
||||
panelType,
|
||||
DataSource.TRACES,
|
||||
).builder;
|
||||
}
|
||||
const dataSource = (defaultSignal ??
|
||||
TelemetrytypesSignalDTO.metrics) as unknown as DataSource;
|
||||
return updateAllQueriesOperators(
|
||||
initialQueriesMap[dataSource],
|
||||
panelType,
|
||||
dataSource,
|
||||
).builder;
|
||||
}
|
||||
@@ -416,6 +416,42 @@ describe('usePanelActionItems', () => {
|
||||
expect(mockOpenView).toHaveBeenCalledWith('panel-1', baseArgs.panel);
|
||||
});
|
||||
|
||||
it('disables only Create Alerts on an AI query panel', () => {
|
||||
const aiPanel = {
|
||||
...mockPanel,
|
||||
spec: {
|
||||
...mockPanel.spec,
|
||||
queries: [
|
||||
{
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/CompositeQuery',
|
||||
spec: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_ai_query',
|
||||
spec: { name: 'A', signal: 'traces' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({ ...baseArgs, panel: aiPanel }),
|
||||
);
|
||||
|
||||
const disabled = disabledKeys(result.current);
|
||||
expect(disabled).toContain('create-alert');
|
||||
expect(disabled).not.toContain('view-panel');
|
||||
expect(disabled).not.toContain('edit-panel');
|
||||
});
|
||||
|
||||
it('create-alert seeds an alert from this panel', () => {
|
||||
const { result } = renderHook(() => usePanelActionItems(baseArgs));
|
||||
const createAlert = result.current.items.find(
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useViewPanel } from '../hooks/useViewPanel';
|
||||
import { buildMoveItems } from '../utils/buildMoveItems';
|
||||
import MenuActionItem from '../../../components/MenuActionItem/MenuActionItem';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { getBlockedPanelActions } from '../utils/getBlockedPanelActions';
|
||||
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
@@ -63,6 +64,7 @@ export function usePanelActionItems({
|
||||
panelActions,
|
||||
}: UsePanelActionItemsArgs): PanelActionItems {
|
||||
const panelKind = panel.spec.plugin.kind;
|
||||
const blocked = useMemo(() => getBlockedPanelActions(panel), [panel]);
|
||||
const { isEditable, editChecks, editDisabledTooltip } =
|
||||
useDashboardEditContext();
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
@@ -103,13 +105,17 @@ export function usePanelActionItems({
|
||||
const row = (
|
||||
text: string,
|
||||
icon: ReactElement,
|
||||
opts: { checks?: BrandedPermission[]; destructive?: boolean } = {},
|
||||
opts: {
|
||||
checks?: BrandedPermission[];
|
||||
destructive?: boolean;
|
||||
disabledTooltip?: string;
|
||||
} = {},
|
||||
): ReactNode => (
|
||||
<MenuActionItem
|
||||
label={text}
|
||||
icon={icon}
|
||||
checks={opts.checks ?? editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
disabledTooltip={opts.disabledTooltip ?? editDisabledTooltip}
|
||||
destructive={opts.destructive}
|
||||
/>
|
||||
);
|
||||
@@ -157,7 +163,11 @@ export function usePanelActionItems({
|
||||
if (panelCapabilities.createAlert) {
|
||||
dataGroup.push({
|
||||
key: 'create-alert',
|
||||
label: row('Create Alerts', <Bell size={14} />, { checks: [] }),
|
||||
label: row('Create Alerts', <Bell size={14} />, {
|
||||
checks: [],
|
||||
disabledTooltip: blocked.createAlert,
|
||||
}),
|
||||
disabled: !!blocked.createAlert,
|
||||
onClick: (): void => createAlert(panel, panelId),
|
||||
});
|
||||
}
|
||||
@@ -197,6 +207,7 @@ export function usePanelActionItems({
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
panelCapabilities,
|
||||
blocked,
|
||||
panel,
|
||||
panelActions,
|
||||
sections,
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/quer
|
||||
import type { PanelActionsConfig } from '../Panel';
|
||||
import PanelActionsMenu from '../PanelActionsMenu/PanelActionsMenu';
|
||||
import { EMPTY_PANEL_QUERY_DATA } from '../utils/emptyPanelQueryData';
|
||||
import { usePanelTitle } from '../hooks/usePanelTitle';
|
||||
import PanelHeaderSearch from './PanelHeaderSearch';
|
||||
import PanelStatusPopover from '../PanelStatus/PanelStatusPopover';
|
||||
import {
|
||||
@@ -67,7 +68,7 @@ function PanelHeader(props: PanelHeaderProps): JSX.Element {
|
||||
const { panelId, panel, panelActions, hideActions } = props;
|
||||
const query = props.mode === 'query' ? props : null;
|
||||
|
||||
const name = panel.spec.display.name;
|
||||
const name = usePanelTitle(panel);
|
||||
const description = panel.spec.display.description;
|
||||
const errorDetail = useMemo(
|
||||
() => panelStatusFromError(query?.error),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useDashboardSections } from '../../../hooks/useDashboardSections';
|
||||
import { releasePanelPickerTarget } from '../../../store/usePanelPickerTargetStore';
|
||||
import type { NewPanelTarget } from '../../../patchOps';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { JS_DRIVEN_SLIDE } from '../../../utils/drawerSlide';
|
||||
import AddPanelSplitButton from './AddPanelSplitButton';
|
||||
import NewSectionNameInput from './NewSectionNameInput';
|
||||
import PanelTypeBrowser from './PanelTypeBrowser';
|
||||
@@ -106,6 +107,7 @@ function PanelTypeSelectionModal({
|
||||
direction="right"
|
||||
width="wide"
|
||||
testId="panel-type-drawer"
|
||||
drawerContentProps={JS_DRIVEN_SLIDE}
|
||||
drawerDescriptionProps={{ className: styles.body }}
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
|
||||
@@ -31,6 +31,8 @@ interface QueryViewModalBodyProps {
|
||||
draftApi: PanelEditorDraftApi;
|
||||
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/** Tab switch, owned by the shell alongside the kind switch it shares memory with. */
|
||||
onChangeQueryMode: (key: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +47,7 @@ function QueryViewModalBody({
|
||||
onClose,
|
||||
draftApi,
|
||||
onChangePanelKind,
|
||||
onChangeQueryMode,
|
||||
}: QueryViewModalBodyProps): JSX.Element | null {
|
||||
const {
|
||||
timeOverride,
|
||||
@@ -162,6 +165,7 @@ function QueryViewModalBody({
|
||||
panelDefinition={panelDefinition}
|
||||
signal={signal}
|
||||
isLoadingQueries={isFetching}
|
||||
onChangeQueryMode={onChangeQueryMode}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
stickyHeader={false}
|
||||
|
||||
@@ -5,13 +5,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@signozhq/ui/dialog';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import ViewPanelModalContent from './ViewPanelModalContent';
|
||||
import ViewPanelModalTitle from './ViewPanelModalTitle';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
|
||||
interface ViewPanelModalProps {
|
||||
@@ -31,8 +31,6 @@ function ViewPanelModal({
|
||||
open,
|
||||
onClose,
|
||||
}: ViewPanelModalProps): JSX.Element {
|
||||
const name = panel?.spec.display.name ?? '';
|
||||
|
||||
// Render antd popups into the dialog (not document.body) so they stay inside the
|
||||
// modal's interactive, focus-trapped layer instead of being blocked by Radix.
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
@@ -54,11 +52,11 @@ function ViewPanelModal({
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<TooltipSimple title={name} arrow>
|
||||
<Typography.Text className={styles.title}>
|
||||
{name ? `${name} - (View mode)` : 'View mode'}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
{panel ? (
|
||||
<ViewPanelModalTitle panel={panel} />
|
||||
) : (
|
||||
<Typography.Text className={styles.title}>View mode</Typography.Text>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogCloseButton />
|
||||
|
||||
@@ -8,7 +8,7 @@ import { QueryParams } from 'constants/query';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { usePanelEditorDraft } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelEditorDraft';
|
||||
import { usePanelTypeSwitch } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch';
|
||||
import { usePanelKindAndQueryModeSwitch } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelKindAndQueryModeSwitch';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { buildViewPanelSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
|
||||
@@ -73,11 +73,12 @@ function ViewPanelModalContent({
|
||||
const draftKind = draftApi.draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(draftKind);
|
||||
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draftApi.draft.spec,
|
||||
panelType: toPanelType(draftKind),
|
||||
setSpec: draftApi.setSpec,
|
||||
});
|
||||
const { onChangePanelKind, onChangeQueryMode } =
|
||||
usePanelKindAndQueryModeSwitch({
|
||||
spec: draftApi.draft.spec,
|
||||
panelType: toPanelType(draftKind),
|
||||
setSpec: draftApi.setSpec,
|
||||
});
|
||||
|
||||
if (panelDefinition.mode === 'static') {
|
||||
return (
|
||||
@@ -97,6 +98,7 @@ function ViewPanelModalContent({
|
||||
onClose={onClose}
|
||||
draftApi={draftApi}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
onChangeQueryMode={onChangeQueryMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { usePanelTitle } from '../hooks/usePanelTitle';
|
||||
import styles from './ViewPanelModal.module.scss';
|
||||
|
||||
interface ViewPanelModalTitleProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
function ViewPanelModalTitle({ panel }: ViewPanelModalTitleProps): JSX.Element {
|
||||
const name = usePanelTitle(panel);
|
||||
|
||||
return (
|
||||
<TooltipSimple title={name} arrow>
|
||||
<Typography.Text className={styles.title}>
|
||||
{name ? `${name} - (View mode)` : 'View mode'}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewPanelModalTitle;
|
||||
@@ -1,8 +1,12 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
Querybuildertypesv5VariableTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { Warning } from 'types/api';
|
||||
|
||||
@@ -100,6 +104,30 @@ describe('PanelHeader title and description', () => {
|
||||
expect(screen.getByText('My panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('substitutes dashboard variables into the panel name', () => {
|
||||
useDashboardStore.setState({
|
||||
dashboardId: 'dash-1',
|
||||
resolvedVariables: {
|
||||
'dash-1': {
|
||||
service: {
|
||||
type: Querybuildertypesv5VariableTypeDTO.query,
|
||||
value: ['cart', 'api'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
renderWithProvider(
|
||||
<PanelHeader
|
||||
{...baseProps}
|
||||
panel={makePanel({ name: 'Latency of $service ({{missing}})' })}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Latency of cart, api ({{missing}})'),
|
||||
).toBeInTheDocument();
|
||||
useDashboardStore.setState({ dashboardId: '', resolvedVariables: {} });
|
||||
});
|
||||
|
||||
it('shows the description info icon when a description is provided', () => {
|
||||
renderWithProvider(
|
||||
<PanelHeader
|
||||
|
||||
@@ -40,9 +40,11 @@ jest.mock('hooks/useUrlQuery', () => ({
|
||||
default: (): URLSearchParams => new URLSearchParams(),
|
||||
}));
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch',
|
||||
'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelKindAndQueryModeSwitch',
|
||||
() => ({
|
||||
usePanelTypeSwitch: (): unknown => ({ onChangePanelKind: jest.fn() }),
|
||||
usePanelKindAndQueryModeSwitch: (): unknown => ({
|
||||
onChangePanelKind: jest.fn(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
selector({ dashboardId: 'dash-1', resolvedVariables: {} }),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
selector({ dashboardId: 'dash-1', resolvedVariables: {} }),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/
|
||||
import { downloadCsv } from 'pages/DashboardPage/DashboardContainer/Panels/utils/downloadCsv';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
|
||||
import { usePanelTitle } from './usePanelTitle';
|
||||
|
||||
interface UseDownloadPanelCsvArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
data: PanelQueryData;
|
||||
@@ -29,7 +31,7 @@ export function useDownloadPanelCsv({
|
||||
data,
|
||||
canDownloadCsv,
|
||||
}: UseDownloadPanelCsvArgs): () => void {
|
||||
const fileName = panel.spec.display.name;
|
||||
const fileName = usePanelTitle(panel);
|
||||
|
||||
return useCallback((): void => {
|
||||
if (!canDownloadCsv) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/quer
|
||||
import { buildDownloadMenuItem } from '../utils/buildDownloadMenuItem';
|
||||
import { useDownloadPanelCsv } from './useDownloadPanelCsv';
|
||||
import { useDownloadPanelImage } from './useDownloadPanelImage';
|
||||
import { usePanelTitle } from './usePanelTitle';
|
||||
|
||||
interface UseDownloadPanelMenuItemArgs {
|
||||
panelId: string;
|
||||
@@ -28,7 +29,7 @@ export function useDownloadPanelMenuItem({
|
||||
data,
|
||||
actions,
|
||||
}: UseDownloadPanelMenuItemArgs): MenuItem | null {
|
||||
const panelName = panel.spec.display.name;
|
||||
const panelName = usePanelTitle(panel);
|
||||
const downloadPanelCsv = useDownloadPanelCsv({
|
||||
panel,
|
||||
data,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { interpolateVariables } from 'pages/DashboardPage/DashboardContainer/Panels/utils/interpolateVariables';
|
||||
import { selectResolvedVariables } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
|
||||
|
||||
export function usePanelTitle(panel: DashboardtypesPanelDTO): string {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
|
||||
const name = panel.spec.display.name;
|
||||
|
||||
return useMemo(() => interpolateVariables(name, variables), [name, variables]);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { isAIBuilderEnvelope } from 'lib/query/builderEnvelope';
|
||||
import { toQueryEnvelopes } from '../../../queryV5/buildQueryRangeRequest';
|
||||
import type { PanelActionId } from '../PanelActionsMenu/panelActionMeta';
|
||||
|
||||
//TODO: @tewarig will enable these actions for AI Observability Panels
|
||||
const AI_PANEL_REASON = 'Coming Soon For AI Observability Panels';
|
||||
|
||||
export type BlockedPanelActions = Partial<Record<PanelActionId, string>>;
|
||||
|
||||
/** Actions the panel's queries don't support yet, mapped to the disabled reason. */
|
||||
export function getBlockedPanelActions(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
): BlockedPanelActions {
|
||||
// The alert builder has no AI query mode. View in Logs/Traces stays on.
|
||||
if (toQueryEnvelopes(panel.spec.queries).some(isAIBuilderEnvelope)) {
|
||||
return { createAlert: AI_PANEL_REASON };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMemo } from 'react';
|
||||
import { dtoToFormModel } from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters';
|
||||
|
||||
import { useDashboardFetchRequired } from './useDashboardFetchRequired';
|
||||
|
||||
export function useDashboardVariableNames(): string[] {
|
||||
const { variables } = useDashboardFetchRequired();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
variables
|
||||
.map((dto) => dtoToFormModel(dto).name)
|
||||
.filter((name): name is string => !!name),
|
||||
[variables],
|
||||
);
|
||||
}
|
||||
@@ -99,6 +99,23 @@ describe('toQueryEnvelopes', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('wraps a bare AI builder plugin as a builder_ai_query envelope', () => {
|
||||
const ai = [
|
||||
{
|
||||
kind: 'TimeSeriesQuery',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/AIBuilderQuery',
|
||||
spec: { name: 'A', signal: 'traces' },
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
expect(toQueryEnvelopes(ai)).toStrictEqual([
|
||||
{ type: 'builder_ai_query', spec: { name: 'A', signal: 'traces' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes a CompositeQuery envelope list through verbatim', () => {
|
||||
const subqueries = [
|
||||
{ type: 'builder_query', spec: { name: 'A' } },
|
||||
@@ -207,6 +224,30 @@ describe('buildQueryRangeRequest', () => {
|
||||
expect(request.formatOptions?.fillGaps).toBe(true);
|
||||
});
|
||||
|
||||
it('stamps offset/limit onto an AI builder query when pagination is given', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: [
|
||||
{
|
||||
kind: 'raw',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/AIBuilderQuery',
|
||||
spec: { name: 'A', signal: 'traces' },
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[],
|
||||
queryCapabilities: LIST_PANEL_CAPABILITIES,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
pagination: { offset: 25, limit: 10 },
|
||||
});
|
||||
expect(request.compositeQuery?.queries?.[0]).toMatchObject({
|
||||
type: 'builder_ai_query',
|
||||
spec: { name: 'A', signal: 'traces', offset: 25, limit: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
it('stamps offset/limit onto builder queries when pagination is given', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', signal: 'logs' }),
|
||||
|
||||
@@ -4,7 +4,11 @@ import type {
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Querybuildertypesv5BucketOptionsLogDTOKind } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryAIWithType,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -221,6 +225,38 @@ describe('persesQueryAdapters', () => {
|
||||
expect(result[0].kind).toBe('scalar');
|
||||
});
|
||||
|
||||
it('emits a bare signoz/AIBuilderQuery for an AI List panel', () => {
|
||||
const result = toPerses(initialQueryAIWithType, PANEL_TYPES.LIST);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].spec.plugin.kind).toBe('signoz/AIBuilderQuery');
|
||||
});
|
||||
|
||||
it('preserves an AI List query through toPerses → fromPerses', () => {
|
||||
const restored = fromPerses(
|
||||
toPerses(initialQueryAIWithType, PANEL_TYPES.LIST),
|
||||
PANEL_TYPES.LIST,
|
||||
);
|
||||
|
||||
expect(restored.builder.queryData[0].builderQueryType).toBe(
|
||||
'builder_ai_query',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves an AI time-series query through fromPerses → toPerses', () => {
|
||||
const perses = toPerses(initialQueryAIWithType, PANEL_TYPES.TIME_SERIES);
|
||||
const restored = fromPerses(perses, PANEL_TYPES.TIME_SERIES);
|
||||
const savedAgain = toPerses(restored, PANEL_TYPES.TIME_SERIES);
|
||||
const envelopes = (
|
||||
savedAgain[0].spec.plugin.spec as { queries?: { type?: string }[] }
|
||||
).queries;
|
||||
|
||||
expect(restored.builder.queryData[0].builderQueryType).toBe(
|
||||
'builder_ai_query',
|
||||
);
|
||||
expect(envelopes?.[0]?.type).toBe('builder_ai_query');
|
||||
});
|
||||
|
||||
it('emits a bare signoz/BuilderQuery for a List panel (not a CompositeQuery)', () => {
|
||||
const result = toPerses(
|
||||
initialQueriesMap[DataSource.LOGS],
|
||||
|
||||
@@ -5,18 +5,21 @@ import type {
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
Querybuildertypesv5OrderByDTO,
|
||||
Querybuildertypesv5PromQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderAIDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTOVariables,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
|
||||
Querybuildertypesv5QueryEnvelopePromQLDTOType,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities';
|
||||
import { isBuilderEnvelope } from 'lib/query/builderEnvelope';
|
||||
|
||||
// Narrow view over the envelope spec variants. Orval erases envelope `spec` to `unknown`, so
|
||||
// shared fields are read through this view with a localized cast at the envelope boundary.
|
||||
@@ -58,6 +61,15 @@ export function toQueryEnvelopes(
|
||||
spec: plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
},
|
||||
];
|
||||
case 'signoz/AIBuilderQuery':
|
||||
// Same spec shape as a traces builder query. The envelope type is what
|
||||
// tells the querier to run the AI statement builder.
|
||||
return [
|
||||
{
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query,
|
||||
spec: plugin.spec,
|
||||
} as unknown as Querybuildertypesv5QueryEnvelopeBuilderAIDTO,
|
||||
];
|
||||
case 'signoz/PromQLQuery':
|
||||
return [
|
||||
{
|
||||
@@ -127,13 +139,10 @@ function withBucketedStepInterval(
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] {
|
||||
const stepInterval = getBucketedStepIntervalSeconds(startMs, endMs);
|
||||
return envelopes.map((envelope) => {
|
||||
if (
|
||||
envelope.type !==
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
|
||||
) {
|
||||
if (!isBuilderEnvelope(envelope)) {
|
||||
return envelope;
|
||||
}
|
||||
if (envelope.spec?.stepInterval) {
|
||||
if ((envelope.spec as QuerySpecView)?.stepInterval) {
|
||||
return envelope;
|
||||
}
|
||||
return {
|
||||
@@ -141,8 +150,8 @@ function withBucketedStepInterval(
|
||||
spec: {
|
||||
...envelope.spec,
|
||||
stepInterval,
|
||||
} as Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
};
|
||||
},
|
||||
} as Querybuildertypesv5QueryEnvelopeDTO;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,10 +164,7 @@ function withListOrderTiebreaker(
|
||||
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] {
|
||||
return envelopes.map((envelope) => {
|
||||
if (
|
||||
envelope.type !==
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
|
||||
) {
|
||||
if (!isBuilderEnvelope(envelope)) {
|
||||
return envelope;
|
||||
}
|
||||
const spec = envelope.spec as QuerySpecView;
|
||||
@@ -183,8 +189,8 @@ function withListOrderTiebreaker(
|
||||
...primary,
|
||||
{ key: { name: 'id' }, direction: primary[0].direction },
|
||||
],
|
||||
} as Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
};
|
||||
},
|
||||
} as Querybuildertypesv5QueryEnvelopeDTO;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -197,10 +203,7 @@ function withPagination(
|
||||
{ offset, limit }: { offset: number; limit: number },
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] {
|
||||
return envelopes.map((envelope) => {
|
||||
if (
|
||||
envelope.type !==
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
|
||||
) {
|
||||
if (!isBuilderEnvelope(envelope)) {
|
||||
return envelope;
|
||||
}
|
||||
return {
|
||||
@@ -209,8 +212,8 @@ function withPagination(
|
||||
...envelope.spec,
|
||||
offset,
|
||||
limit,
|
||||
} as Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
};
|
||||
},
|
||||
} as Querybuildertypesv5QueryEnvelopeDTO;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -303,11 +306,7 @@ export function hasRunnableQueries(queries: DashboardtypesQueryDTO[]): boolean {
|
||||
}
|
||||
|
||||
const metricsSpecs = envelopes
|
||||
.filter(
|
||||
(envelope) =>
|
||||
envelope.type ===
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
|
||||
)
|
||||
.filter(isBuilderEnvelope)
|
||||
.map((envelope) => envelope.spec as QuerySpecView)
|
||||
.filter((spec) => spec.signal === 'metrics');
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type {
|
||||
DashboardtypesAIBuilderQuerySpecDTO,
|
||||
DashboardtypesBuilderQuerySpecDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind as AIBuilderQueryPluginKind,
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind as BuilderQueryPluginKind,
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind as CompositeQueryPluginKind,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
|
||||
Querybuildertypesv5QueryEnvelopePromQLDTOType,
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
@@ -21,6 +22,10 @@ import type { QueryEnvelope } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
isAIBuilderEnvelope,
|
||||
isBuilderEnvelope,
|
||||
} from 'lib/query/builderEnvelope';
|
||||
import { toQueryEnvelopes } from './buildQueryRangeRequest';
|
||||
|
||||
/**
|
||||
@@ -44,11 +49,6 @@ const toGeneratedEnvelopes = (
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] =>
|
||||
envelopes as unknown as Querybuildertypesv5QueryEnvelopeDTO[];
|
||||
|
||||
const isBuilderQueryEnvelope = (
|
||||
envelope: Querybuildertypesv5QueryEnvelopeDTO,
|
||||
): boolean =>
|
||||
envelope.type === Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query;
|
||||
|
||||
/**
|
||||
* Clears the V1 explorer's `pageSize`/`offset` before conversion — the shared mapper folds
|
||||
* `pageSize` into the V5 `limit`, which usePanelQuery would read as a user cap and hide the
|
||||
@@ -158,8 +158,9 @@ export function fromPerses(
|
||||
/**
|
||||
* V1 `Query` → perses panel queries (to write the builder result back to the editor
|
||||
* draft). Wrapped in a single `signoz/CompositeQuery` to satisfy the
|
||||
* `panel.queries.length === 1` invariant. Exception: List emits its one builder query
|
||||
* as a bare `signoz/BuilderQuery` because the backend rejects a `signoz/CompositeQuery`.
|
||||
* `panel.queries.length === 1` invariant. Exception: List rejects `signoz/CompositeQuery`
|
||||
* backend-side, so it emits its one builder query as the bare plugin matching the query —
|
||||
* a bare plugin carries no envelope `type`, so the kind is what keeps an AI list AI.
|
||||
*/
|
||||
export function toPerses(
|
||||
query: Query,
|
||||
@@ -173,21 +174,23 @@ export function toPerses(
|
||||
const envelopes = toGeneratedEnvelopes(composite.queries ?? []);
|
||||
|
||||
if (panelType === PANEL_TYPES.LIST) {
|
||||
const builder = envelopes.find(isBuilderQueryEnvelope);
|
||||
const builder = envelopes.find(isBuilderEnvelope);
|
||||
if (!builder) {
|
||||
return [];
|
||||
}
|
||||
const plugin = isAIBuilderEnvelope(builder)
|
||||
? {
|
||||
kind: AIBuilderQueryPluginKind['signoz/AIBuilderQuery'],
|
||||
spec: builder.spec as unknown as DashboardtypesAIBuilderQuerySpecDTO,
|
||||
}
|
||||
: {
|
||||
kind: BuilderQueryPluginKind['signoz/BuilderQuery'],
|
||||
spec: builder.spec as DashboardtypesBuilderQuerySpecDTO,
|
||||
};
|
||||
return [
|
||||
{
|
||||
kind: panelTypeToRequestType(panelType),
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: BuilderQueryPluginKind['signoz/BuilderQuery'],
|
||||
// The generated envelope union doesn't discriminate `spec` by `type`, so
|
||||
// narrow the filtered builder query to the dashboard builder spec.
|
||||
spec: builder.spec as DashboardtypesBuilderQuerySpecDTO,
|
||||
},
|
||||
},
|
||||
spec: { plugin },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@ import type {
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
Querybuildertypesv5ScalarDataDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { isBuilderEnvelope } from 'lib/query/builderEnvelope';
|
||||
|
||||
import type { PanelTable, PanelTableColumn } from './types';
|
||||
|
||||
@@ -28,10 +27,7 @@ export function extractAggregationsPerQuery(
|
||||
): AggregationsPerQuery {
|
||||
const perQuery: AggregationsPerQuery = {};
|
||||
(requestPayload?.compositeQuery?.queries ?? []).forEach((envelope) => {
|
||||
if (
|
||||
envelope.type !==
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
|
||||
) {
|
||||
if (!isBuilderEnvelope(envelope)) {
|
||||
return;
|
||||
}
|
||||
const spec = envelope.spec;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { DrawerWrapperProps } from '@signozhq/ui/drawer';
|
||||
import { noop } from 'lodash-es';
|
||||
|
||||
/**
|
||||
* `DrawerWrapper`'s motion@11 runs its slide on WAAPI and paints one stale frame
|
||||
* when it finishes; an `onUpdate` (untyped, forwarded to motion) keeps it JS-driven.
|
||||
*/
|
||||
export const JS_DRIVEN_SLIDE = {
|
||||
onUpdate: noop,
|
||||
} as DrawerWrapperProps['drawerContentProps'];
|
||||
@@ -7,3 +7,7 @@
|
||||
cursor: default;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
composes: tooltipContent from '../shared.module.scss';
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './EntityMetadataItem.module.scss';
|
||||
|
||||
interface EntityMetadataItemProps {
|
||||
tooltip: string;
|
||||
tooltip: TooltipSimpleProps['title'];
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -16,7 +16,10 @@ function EntityMetadataItem({
|
||||
children,
|
||||
}: EntityMetadataItemProps): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple title={tooltip}>
|
||||
<TooltipSimple
|
||||
title={tooltip}
|
||||
tooltipContentProps={{ className: styles.tooltip }}
|
||||
>
|
||||
<span className={styles.item}>
|
||||
{icon}
|
||||
<Typography.Text as="span">{children}</Typography.Text>
|
||||
|
||||
@@ -8,3 +8,12 @@
|
||||
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
|
||||
--typography-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.tokenTooltip {
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.tokenTooltipTitle {
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { CalendarClock, Server, Timer } from '@signozhq/icons';
|
||||
import { CalendarClock, Coins, Landmark, Server, Timer } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import cx from 'classnames';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
|
||||
import {
|
||||
AiTokenCounts,
|
||||
formatCost,
|
||||
formatTokens,
|
||||
getTotalInputTokens,
|
||||
hasValue,
|
||||
} from '../utils/aiTraceUtils';
|
||||
import EntityMetadataItem from './EntityMetadataItem';
|
||||
import TokenUsageTooltip from './TokenUsageTooltip';
|
||||
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
@@ -16,6 +24,8 @@ interface EntityMetadataRowProps {
|
||||
execTimePercent?: number;
|
||||
timestamp?: string;
|
||||
statusCode?: string | number;
|
||||
tokens?: AiTokenCounts;
|
||||
cost?: number | null;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
@@ -32,8 +42,11 @@ function EntityMetadataRow({
|
||||
execTimePercent,
|
||||
timestamp,
|
||||
statusCode,
|
||||
tokens,
|
||||
cost,
|
||||
}: EntityMetadataRowProps): JSX.Element {
|
||||
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
|
||||
const totalInput = tokens && getTotalInputTokens(tokens);
|
||||
const durationTooltip =
|
||||
entity === 'trace' ? 'Trace Duration' : 'Span Duration';
|
||||
// Single source of duration formatting so both rows label units identically.
|
||||
@@ -91,6 +104,24 @@ function EntityMetadataRow({
|
||||
<HttpStatusBadge statusCode={statusCode} />
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{tokens && (
|
||||
<EntityMetadataItem
|
||||
tooltip={<TokenUsageTooltip tokens={tokens} />}
|
||||
icon={<Coins size={ICON_SIZE} />}
|
||||
>
|
||||
Tokens: {formatTokens(totalInput)} → {formatTokens(tokens.output)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{hasValue(cost) && (
|
||||
<EntityMetadataItem
|
||||
tooltip="Total cost"
|
||||
icon={<Landmark size={ICON_SIZE} />}
|
||||
>
|
||||
Cost — {formatCost(cost)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,6 +133,8 @@ EntityMetadataRow.defaultProps = {
|
||||
execTimePercent: undefined,
|
||||
timestamp: undefined,
|
||||
statusCode: undefined,
|
||||
tokens: undefined,
|
||||
cost: undefined,
|
||||
};
|
||||
|
||||
export default EntityMetadataRow;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
|
||||
import { AiTokenCounts } from '../utils/aiTraceUtils';
|
||||
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
interface TokenUsageTooltipProps {
|
||||
tokens: AiTokenCounts;
|
||||
}
|
||||
|
||||
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
|
||||
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
|
||||
|
||||
return (
|
||||
<div className={styles.tokenTooltip}>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
weight="normal"
|
||||
className={styles.tokenTooltipTitle}
|
||||
>
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
<TokenBreakdown
|
||||
input={input}
|
||||
output={output}
|
||||
cacheRead={cacheRead}
|
||||
cacheWrite={cacheWrite}
|
||||
reasoning={reasoning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenUsageTooltip;
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link2 } from '@signozhq/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import { getAIUsageDetails, convertToAiTokens } from '../utils/aiTraceUtils';
|
||||
import { HIGHLIGHTED_OPTIONS } from './config';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
@@ -31,6 +33,10 @@ function SpanSummary({
|
||||
}: SpanSummaryProps): JSX.Element {
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
const aiUsage = useMemo(
|
||||
() => getAIUsageDetails(selectedSpan)?.usage,
|
||||
[selectedSpan],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -63,6 +69,8 @@ function SpanSummary({
|
||||
: undefined
|
||||
}
|
||||
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
|
||||
tokens={aiUsage && convertToAiTokens(aiUsage)}
|
||||
cost={aiUsage?.cost}
|
||||
/>
|
||||
|
||||
<div className={styles.spanInfoItem}>
|
||||
|
||||
@@ -95,4 +95,26 @@ describe('SpanSummary', () => {
|
||||
|
||||
expect(screen.queryByText(/of total exec time/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tokens and cost from the gen_ai usage attributes', () => {
|
||||
const span = {
|
||||
...createMockSpan(),
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 1000,
|
||||
'gen_ai.usage.output_tokens': 9,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.000009,
|
||||
},
|
||||
};
|
||||
render(<SpanSummary selectedSpan={span} />);
|
||||
|
||||
expect(screen.getByText(/Tokens: 1,000 → 9/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Cost — \$ 0.000009/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits tokens and cost for non-AI spans', () => {
|
||||
render(<SpanSummary selectedSpan={createMockSpan()} />);
|
||||
|
||||
expect(screen.queryByText(/Tokens:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getAiSpanKind } from '../utils/aiTraceUtils';
|
||||
import { AI_SPAN_BADGES } from './constants';
|
||||
|
||||
interface SpanHintBadgeProps {
|
||||
span: SpanV3;
|
||||
}
|
||||
|
||||
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
|
||||
const aiSpanKind = getAiSpanKind(span);
|
||||
|
||||
if (aiSpanKind) {
|
||||
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
// The outline variant derives text, fill and border from this color.
|
||||
style={{ '--badge-background': color } as CSSProperties}
|
||||
data-testid={`span-hint-badge-${aiSpanKind}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (!span.response_status_code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HttpStatusBadge statusCode={span.response_status_code} />;
|
||||
}
|
||||
|
||||
export default SpanHintBadge;
|
||||
10
frontend/src/pages/TraceDetailsV3/SpanHintBadge/constants.ts
Normal file
10
frontend/src/pages/TraceDetailsV3/SpanHintBadge/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { AiSpanKind } from '../utils/aiTraceUtils';
|
||||
|
||||
export const AI_SPAN_BADGES: Record<
|
||||
AiSpanKind,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
[AiSpanKind.Llm]: { label: 'LLM', color: 'rgba(155, 255, 0, 1)' },
|
||||
[AiSpanKind.Tool]: { label: 'TOOL', color: 'rgba(255, 209, 0, 1)' },
|
||||
[AiSpanKind.Agent]: { label: 'AGENT', color: 'rgba(255, 10, 138, 1)' },
|
||||
};
|
||||
@@ -8,16 +8,12 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l2-foreground);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.name {
|
||||
|
||||
@@ -9,40 +9,24 @@
|
||||
}
|
||||
|
||||
.popover {
|
||||
composes: tooltipContent from '../shared.module.scss';
|
||||
// Hover card may be rendered while the SpanDetailsPanel is docked as
|
||||
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
|
||||
--tooltip-z-index: 1000;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
min-width: 223px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
.title {
|
||||
composes: tooltipTitle from '../shared.module.scss';
|
||||
}
|
||||
|
||||
.row {
|
||||
line-height: 1.5;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.preview {
|
||||
// container for additional preview rows
|
||||
}
|
||||
|
||||
.previewKey {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.previewValue {
|
||||
color: var(--l1-foreground);
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
@@ -12,6 +13,11 @@ import { useMemo } from 'react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { getAIUsageDetails, SpanAiDetails } from '../utils/aiTraceUtils';
|
||||
import SpanUsageBreakdown from './SpanUsageBreakdown';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
import styles from './SpanHoverCard.module.scss';
|
||||
|
||||
/**
|
||||
@@ -38,6 +44,7 @@ export interface SpanTooltipContentProps {
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
previewRows?: SpanPreviewRow[];
|
||||
ai?: SpanAiDetails;
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
@@ -47,33 +54,54 @@ export function SpanTooltipContent({
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
previewRows,
|
||||
ai,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.name} style={{ color }}>
|
||||
{spanName}
|
||||
</div>
|
||||
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
|
||||
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
|
||||
<div className={styles.row}>
|
||||
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
<div className={styles.header}>
|
||||
<Typography.Text
|
||||
className={styles.title}
|
||||
size="small"
|
||||
weight="normal"
|
||||
style={{ color }}
|
||||
>
|
||||
{spanName}
|
||||
</Typography.Text>
|
||||
{ai?.usage && (
|
||||
<Typography.Text size="small" weight="normal">
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
|
||||
<TraceTooltipSection>
|
||||
{ai?.model && <TraceTooltipRow label="model" value={ai.model} />}
|
||||
{ai?.toolName && <TraceTooltipRow label="tool" value={ai.toolName} />}
|
||||
{ai?.agentName && <TraceTooltipRow label="agent" value={ai.agentName} />}
|
||||
<TraceTooltipRow label="status" value={hasError ? 'error' : 'ok'} />
|
||||
<TraceTooltipRow
|
||||
label="start"
|
||||
value={`${toFixed(relativeStartMs, 2)} ms`}
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label="duration"
|
||||
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<div className={styles.preview}>
|
||||
<TraceTooltipSection>
|
||||
{previewRows.map((row) => (
|
||||
<div
|
||||
<TraceTooltipRow
|
||||
key={row.key}
|
||||
className={styles.row}
|
||||
data-testid={`span-hover-card-preview-${row.key}`}
|
||||
>
|
||||
<span className={styles.previewKey}>{row.key}:</span>{' '}
|
||||
<span className={styles.previewValue}>{row.value}</span>
|
||||
</div>
|
||||
label={row.key}
|
||||
value={row.value}
|
||||
testId={`span-hover-card-preview-${row.key}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TraceTooltipSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -137,6 +165,7 @@ export function SpanHoverCard({
|
||||
relativeStartMs: span.timestamp - traceStartTime,
|
||||
durationMs: span.duration_nano / 1e6,
|
||||
previewRows,
|
||||
ai: getAIUsageDetails(span),
|
||||
},
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { formatCost, hasValue, SpanAiUsage } from '../utils/aiTraceUtils';
|
||||
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
interface SpanUsageBreakdownProps {
|
||||
usage: SpanAiUsage;
|
||||
}
|
||||
|
||||
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
|
||||
const {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
reasoningTokens,
|
||||
cost,
|
||||
} = usage;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TokenBreakdown
|
||||
input={inputTokens}
|
||||
output={outputTokens}
|
||||
cacheRead={cacheReadTokens}
|
||||
cacheWrite={cacheCreationTokens}
|
||||
reasoning={reasoningTokens}
|
||||
/>
|
||||
{hasValue(cost) && (
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow isHeading label="Cost" value={formatCost(cost)} />
|
||||
</TraceTooltipSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanUsageBreakdown;
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
AiTokenCounts,
|
||||
formatTokens,
|
||||
getTokenLabel,
|
||||
getTotalInputTokens,
|
||||
} from '../utils/aiTraceUtils';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
type TokenBreakdownProps = AiTokenCounts;
|
||||
|
||||
function TokenBreakdown({
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
reasoning,
|
||||
}: TokenBreakdownProps): JSX.Element {
|
||||
const totalInput = getTotalInputTokens({ input, cacheRead, cacheWrite });
|
||||
|
||||
return (
|
||||
<>
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Total Input', totalInput)}
|
||||
value={formatTokens(totalInput)}
|
||||
isHeading
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Cache Read', cacheRead)}
|
||||
value={formatTokens(cacheRead)}
|
||||
isNested
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Cache Write', cacheWrite)}
|
||||
value={formatTokens(cacheWrite)}
|
||||
isNested
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Input', input)}
|
||||
value={formatTokens(input)}
|
||||
isNested
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Reasoning', reasoning)}
|
||||
value={formatTokens(reasoning)}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Output', output)}
|
||||
value={formatTokens(output)}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label="Total Token Usage"
|
||||
value={formatTokens(totalInput + (output ?? 0))}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TokenBreakdown.defaultProps = {
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
reasoning: undefined,
|
||||
};
|
||||
|
||||
export default TokenBreakdown;
|
||||
@@ -195,6 +195,8 @@ function TraceDetailsHeader({
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
)}
|
||||
statusCode={traceSummary.rootSpanStatusCode}
|
||||
tokens={traceSummary.ai?.tokens}
|
||||
cost={traceSummary.ai?.totalCost}
|
||||
/>
|
||||
) : (
|
||||
<DetailsLoader />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
@@ -176,9 +177,19 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
hasMissingSpans: false,
|
||||
totalSpansCount: 42,
|
||||
totalErrorSpansCount: 0,
|
||||
ai: {
|
||||
tokens: {
|
||||
input: 12040,
|
||||
output: 3110,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
reasoning: 0,
|
||||
},
|
||||
totalCost: 0.0421,
|
||||
},
|
||||
};
|
||||
|
||||
const mockSummary = (data?: typeof traceMetadata): void => {
|
||||
const mockSummary = (data?: SpantypesGettableTraceSummaryDTO): void => {
|
||||
jest.mocked(useTraceSummary).mockReturnValue({ data, isLoading: false });
|
||||
};
|
||||
|
||||
@@ -201,6 +212,77 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
expect(screen.getByText(duration)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders AI tokens and cost when the summary has them', () => {
|
||||
mockSummary(traceMetadata);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits AI tokens and cost when the summary has no ai field', () => {
|
||||
mockSummary({ ...traceMetadata, ai: undefined });
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows AI tokens when input and output are both zero', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: {
|
||||
...traceMetadata.ai,
|
||||
tokens: { ...traceMetadata.ai.tokens, input: 0, output: 0 },
|
||||
},
|
||||
} as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero for a missing input', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: {
|
||||
...traceMetadata.ai,
|
||||
tokens: { ...traceMetadata.ai.tokens, input: undefined },
|
||||
},
|
||||
} as unknown as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 0 → 3,110')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero AI tokens when input and output are both missing', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: {
|
||||
...traceMetadata.ai,
|
||||
tokens: {
|
||||
...traceMetadata.ai.tokens,
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
},
|
||||
},
|
||||
} as unknown as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the cost when the summary sends null', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: { ...traceMetadata.ai, totalCost: null },
|
||||
} as unknown as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
mockSummary(traceMetadata);
|
||||
|
||||
@@ -30,13 +30,9 @@
|
||||
// lives here. Shares visual treatment with the waterfall hover popover
|
||||
// but is positioned `fixed` instead of anchored.
|
||||
.tooltip {
|
||||
composes: tooltipSurface from '../shared.module.scss';
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EventTooltipContent } from '../SpanHoverCard/EventTooltipContent';
|
||||
import { SpanTooltipContent } from '../SpanHoverCard/SpanHoverCard';
|
||||
import { DEFAULT_ROW_HEIGHT } from './constants';
|
||||
import { useCanvasSetup } from './hooks/useCanvasSetup';
|
||||
import { useCursorTooltipPosition } from './hooks/useCursorTooltipPosition';
|
||||
import { useFlamegraphCrosshair } from './hooks/useFlamegraphCrosshair';
|
||||
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
|
||||
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
|
||||
@@ -200,15 +201,11 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
handleHoverMouseLeave,
|
||||
]);
|
||||
|
||||
const tooltipRef = useCursorTooltipPosition<HTMLDivElement>(tooltipContent);
|
||||
|
||||
const tooltipElement = tooltipContent
|
||||
? createPortal(
|
||||
<div
|
||||
className={styles.tooltip}
|
||||
style={{
|
||||
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
|
||||
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
|
||||
}}
|
||||
>
|
||||
<div ref={tooltipRef} className={styles.tooltip}>
|
||||
{tooltipContent.event ? (
|
||||
<EventTooltipContent
|
||||
eventName={tooltipContent.event.name}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { RefObject, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
const CURSOR_OFFSET = 15;
|
||||
|
||||
interface CursorAnchor {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
function resolveAxis(cursor: number, size: number, viewport: number): number {
|
||||
const after = cursor + CURSOR_OFFSET;
|
||||
if (after + size <= viewport) {
|
||||
return after;
|
||||
}
|
||||
return Math.max(0, cursor - CURSOR_OFFSET - size);
|
||||
}
|
||||
|
||||
/** Places a fixed element beside the cursor, flipping sides when it would overflow the viewport. */
|
||||
export function useCursorTooltipPosition<T extends HTMLElement>(
|
||||
anchor: CursorAnchor | null,
|
||||
): RefObject<T> {
|
||||
const ref = useRef<T>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || !anchor) {
|
||||
return;
|
||||
}
|
||||
const left = resolveAxis(anchor.clientX, el.offsetWidth, window.innerWidth);
|
||||
const top = resolveAxis(anchor.clientY, el.offsetHeight, window.innerHeight);
|
||||
el.style.left = `${left}px`;
|
||||
el.style.top = `${top}px`;
|
||||
}, [anchor]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user