Compare commits

..

3 Commits

Author SHA1 Message Date
Naman Verma
2a9246bfe3 chore: dummy commit to trigger review 2026-10-13 11:59:19 +05:30
Naman Verma
e59afbec38 chore: better code comments 2026-10-13 11:30:46 +05:30
Naman Verma
b1e2164739 fix: make related logs/traces link carry the relevant query from a rule 2026-10-13 11:16:59 +05:30
134 changed files with 1513 additions and 5242 deletions

View File

@@ -17,4 +17,3 @@ 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.

View File

@@ -1,219 +1,5 @@
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:
@@ -3581,69 +3367,6 @@ 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:
@@ -4697,7 +4420,6 @@ components:
DashboardtypesQueryPlugin:
discriminator:
mapping:
signoz/AIBuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
signoz/BuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
signoz/ClickHouseSQL: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
signoz/CompositeQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
@@ -4707,7 +4429,6 @@ components:
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
@@ -4717,25 +4438,12 @@ 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:
@@ -10517,16 +10225,6 @@ 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:
@@ -10569,10 +10267,6 @@ components:
- attributes
- events
- references
- formatted_input
- formatted_output
- formatter
- formatter_warnings
type: object
SpantypesTraceAISummary:
properties:
@@ -16440,12 +16134,9 @@ paths:
/api/v1/traces/{traceID}/thread:
get:
deprecated: false
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.
description: Returns the spans carrying gen_ai input or output messages in timestamp
order. Pass nextCursor as after or prevCursor as before to page, or spanId
to open the page around a span.
operationId: GetTraceThread
parameters:
- description: Page size, at most 100. 0 means 20.

View File

@@ -4,264 +4,6 @@
* * 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
@@ -4469,10 +4211,195 @@ export interface DashboardGridLayoutSpecDTO {
repeatVariable?: string;
}
export enum DashboardtypesAIBuilderQuerySpecDTOSignal {
traces = 'traces',
export enum DashboardtypesAreaFillModeDTO {
solid = 'solid',
gradient = 'gradient',
}
export interface Querybuildertypesv5TraceAggregationDTO {
/**
* @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
*/
@@ -4711,274 +4638,13 @@ 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
@@ -5169,6 +4835,17 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
stepInterval?: Querybuildertypesv5StepDTO;
}
export interface Querybuildertypesv5TraceAggregationDTO {
/**
* @type string
*/
alias?: string;
/**
* @type string
*/
expression?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal {
traces = 'traces',
}
@@ -5727,18 +5404,6 @@ 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',
}
@@ -6030,7 +5695,6 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQu
export type DashboardtypesQueryPluginDTO =
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormulaDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQueryDTO
@@ -6709,7 +6373,6 @@ 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',
@@ -11789,16 +11452,6 @@ export interface SpantypesThreadSpanDTO {
* @type array
*/
events: SpantypesEventDTO[];
formatted_input: AiobservabilitytypesInputMessagesDTO;
formatted_output: AiobservabilitytypesOutputMessagesDTO;
/**
* @type string
*/
formatter: string;
/**
* @type array
*/
formatter_warnings: string[];
/**
* @type boolean
*/

View File

@@ -261,7 +261,7 @@ export const invalidateGetTraceSummary = async (
};
/**
* 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.
* Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
* @summary Get thread view for a trace
*/
export const getTraceThread = (

View File

@@ -4,11 +4,7 @@ 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 { getBuilderVariant } from 'lib/query/queryMode';
import {
BuilderVariant,
IBuilderTraceOperator,
} from 'types/api/queryBuilder/queryBuilderData';
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderField } from './queryBuilderFields.types';
@@ -64,16 +60,8 @@ 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;
@@ -86,20 +74,15 @@ 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(
() =>

View File

@@ -30,10 +30,7 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
> = {};
const builderQueryTypes: Record<
string,
| 'builder_query'
| 'builder_ai_query'
| 'builder_formula'
| 'builder_trace_operator'
'builder_query' | 'builder_formula' | 'builder_trace_operator'
> = {};
const promQueries: IPromQLQuery[] = [];
const clickhouseQueries: IClickHouseQuery[] = [];
@@ -47,14 +44,6 @@ 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(

View File

@@ -15,10 +15,7 @@ export const transformQueryBuilderDataModel = (
data: BuilderQueryDataResourse,
queryTypes?: Record<
string,
| 'builder_query'
| 'builder_ai_query'
| 'builder_formula'
| 'builder_trace_operator'
'builder_query' | 'builder_formula' | 'builder_trace_operator'
>,
): QueryBuilderData => {
const queryData: QueryBuilderData['queryData'] = [];

View File

@@ -1,53 +0,0 @@
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);
},
);
});
});

View File

@@ -61,16 +61,6 @@ 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(

View File

@@ -1,47 +0,0 @@
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);
}

View File

@@ -44,8 +44,6 @@ 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.
@@ -56,6 +54,7 @@ const IS_CARRIED = {
offset: false,
selectColumns: false,
source: false,
builderQueryType: false,
} satisfies Record<BuilderField, boolean>;
function omit(

View File

@@ -1,51 +0,0 @@
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,
),
},
};
}

View File

@@ -80,31 +80,4 @@ 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']);
});
});

View File

@@ -6,11 +6,6 @@ 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}`;
}
@@ -25,13 +20,15 @@ function forEachBuilderSpec(
}
if (plugin.kind === 'signoz/CompositeQuery') {
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
(composite.queries ?? []).filter(isBuilderEnvelope).forEach((envelope) => {
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
if (spec) {
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
});
} else if (isBuilderPluginKind(plugin.kind)) {
(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') {
fn(plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
}

View File

@@ -11,7 +11,6 @@ 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';
@@ -55,7 +54,7 @@ function envelopeReferenceText(
const spec = envelope.spec as
| { query?: string; filter?: { expression?: string } }
| undefined;
if (isBuilderEnvelope(envelope)) {
if (envelope.type === 'builder_query') {
const text = spec?.filter?.expression;
return typeof text === 'string' ? { kind: 'builder', text } : null;
}
@@ -233,7 +232,7 @@ export function findApplyUsages(
});
};
if (isBuilderEnvelope(envelope)) {
if (envelope.type === 'builder_query') {
const spec = envelope.spec as
| { filter?: { expression?: string } }
| undefined;
@@ -295,7 +294,7 @@ export function isVariableAppliedToAllPanels(
return true;
}
return toQueryEnvelopes(queries).every((envelope) => {
if (isBuilderEnvelope(envelope)) {
if (envelope.type === 'builder_query') {
const spec = envelope.spec as
| { filter?: { expression?: string } }
| undefined;

View File

@@ -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,9 +106,12 @@ function ConfigPane({
<div className={styles.group}>
<div className={styles.field}>
<Typography.Text>Title</Typography.Text>
<PanelTitleInput
<Input
data-testid="panel-editor-v2-title"
value={spec.display.name}
onChange={(value): void => setDisplayField('name', value)}
placeholder="Panel title"
maxLength={DASHBOARD_NAME_MAX_LENGTH}
onChange={(e): void => setDisplayField('name', e.target.value)}
/>
</div>

View File

@@ -1,107 +0,0 @@
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;

View File

@@ -1,51 +0,0 @@
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');
});
});

View File

@@ -1,30 +0,0 @@
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,
});
});
});

View File

@@ -1,30 +0,0 @@
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 };
}

View File

@@ -8,7 +8,6 @@ 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';
@@ -103,7 +102,6 @@ function PanelTypeSwitcher({
direction="right"
width="wide"
testId="panel-type-switcher-drawer"
drawerContentProps={JS_DRIVEN_SLIDE}
drawerDescriptionProps={{ className: styles.drawerBody }}
>
<PanelTypeBrowser

View File

@@ -71,29 +71,19 @@ 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) => {
const signals =
mockGetPanelDefinition.mockImplementation((kind: string) => ({
...OPTIONS.find((option) => option.kind === kind),
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']
: ['metrics', 'logs', 'traces'];
const queryTypes = SUPPORTED_QUERY_TYPES[kind] ?? [
: ['metrics', 'logs', 'traces'],
supportedQueryTypes: 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 () => {

View File

@@ -23,11 +23,6 @@ jest.mock(
}),
);
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames',
() => ({ useDashboardVariableNames: (): string[] => [] }),
);
function textSpec(): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Runbook', description: 'steps' },

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useDashboardVariableNames } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames';
import { dtoToFormModel } from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters';
import { useDashboardFetchRequired } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired';
import type { VariableItem } from './types';
@@ -21,7 +22,15 @@ const GLOBAL_TIMESTAMP_VARIABLES: VariableItem[] = [
export function useContextLinkVariables(): VariableItem[] {
const { currentQuery } = useQueryBuilder();
const dashboardVariableNames = useDashboardVariableNames();
const { variables: variableDtos } = useDashboardFetchRequired();
const dashboardVariableNames = useMemo(
() =>
variableDtos
.map((dto) => dtoToFormModel(dto).name)
.filter((name): name is string => !!name),
[variableDtos],
);
// `_`-prefixed to match V1 and avoid colliding with dashboard-variable names.
const fieldVariableNames = useMemo(() => {

View File

@@ -14,11 +14,8 @@ jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
displayName: 'Time Series',
icon: (): null => null,
mode: 'query',
supportedQueryModesKindMap: {
builder: { kind: 'signal', signals: ['metrics', 'logs', 'traces'] },
clickhouse_sql: { kind: 'signal-less' },
promql: { kind: 'signal-less' },
},
supportedSignals: ['metrics', 'logs', 'traces'],
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
})),
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },

View File

@@ -2,33 +2,26 @@ import {
type KeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Atom, Brain, Terminal } from '@signozhq/icons';
import { Atom, 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 { DataSource } from 'types/common/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import {
getQueryModeSignals,
isRawRequest,
} from '../../Panels/types/panelCapabilities';
import { 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';
@@ -36,8 +29,6 @@ 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. */
@@ -57,7 +48,6 @@ interface PanelEditorQueryBuilderProps {
*/
function PanelEditorQueryBuilder({
panelDefinition,
onChangeQueryMode,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
@@ -70,16 +60,18 @@ 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 } = useQueryBuilder();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
// Cleared here, not in QueryBuilderV2: builder panes unmount on every tab switch.
useEffect(() => {
clearPreviousQuery();
return (): void => {
clearPreviousQuery();
};
}, []);
const handleQueryCategoryChange = useCallback(
(queryType: string): void => {
redirectWithQueryBuilderData({
...currentQuery,
queryType: queryType as EQueryType,
});
},
[currentQuery, redirectWithQueryBuilderData],
);
// ⌘↵ / Ctrl+↵ stages and runs the query. Handled locally because the global
// hotkeys provider ignores keydowns from inputs / the query editor, and on the
@@ -96,11 +88,10 @@ function PanelEditorQueryBuilder({
);
const items = useMemo(() => {
const queryTypeComponents: Record<
QueryMode,
{ icon: ReactNode; label: string; component: ReactNode }
> = {
[QueryMode.QUERY_BUILDER]: {
const { supportedQueryTypes } = panelDefinition;
const queryTypeComponents = {
[EQueryType.QUERY_BUILDER]: {
icon: <Atom size={14} />,
label: 'Query Builder',
component: (
@@ -108,10 +99,7 @@ function PanelEditorQueryBuilder({
<QueryBuilderV2
panelType={panelType}
fieldsConfig={panelDefinition.queryBuilderFields}
allowedDataSources={getQueryModeSignals(
panelDefinition.supportedQueryModesKindMap,
QueryMode.QUERY_BUILDER,
)}
allowedDataSources={panelDefinition.supportedSignals}
showTraceOperator={!isRawQuery}
version="v3"
isRawQuery={isRawQuery}
@@ -121,12 +109,12 @@ function PanelEditorQueryBuilder({
</div>
),
},
[QueryMode.CLICKHOUSE]: {
[EQueryType.CLICKHOUSE]: {
icon: <Terminal size={14} />,
label: 'ClickHouse Query',
component: <ClickHouseQueryContainer />,
},
[QueryMode.PROM]: {
[EQueryType.PROM]: {
icon: (
<PromQLIcon
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
@@ -135,33 +123,9 @@ 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>
),
},
};
const modes = Object.keys(
panelDefinition.supportedQueryModesKindMap,
) as QueryMode[];
return modes.map((queryType) => ({
return supportedQueryTypes.map((queryType) => ({
key: queryType,
label: (
<div className={styles.queryTypeTab}>
@@ -170,8 +134,6 @@ 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]);
@@ -188,8 +150,8 @@ function PanelEditorQueryBuilder({
className={cx(styles.tabsContainer, {
[styles.stickyNav]: stickyHeader,
})}
activeKey={getQueryMode(currentQuery)}
onChange={onChangeQueryMode}
activeKey={currentQuery.queryType}
onChange={handleQueryCategoryChange}
tabBarExtraContent={
<span className={styles.runQueryBtnContainer}>
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />

View File

@@ -8,7 +8,6 @@ import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
function QueryBuilderEditorPane({
panelDefinition,
isLoadingQueries,
onChangeQueryMode,
onStageRunQuery,
onCancelQuery,
stickyHeader,
@@ -17,7 +16,6 @@ function QueryBuilderEditorPane({
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
isLoadingQueries={isLoadingQueries}
onChangeQueryMode={onChangeQueryMode}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}

View File

@@ -1,17 +1,11 @@
import { useRef } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { 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 {
toPanelType,
type PanelKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { BuildersByVariant } from 'types/api/queryBuilder/queryBuilderData';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
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();
@@ -49,41 +43,17 @@ jest.mock('assets/Dashboard/PromQl', () => ({
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
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 (
function renderBuilder(panelKind: string): void {
render(
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
onChangeQueryMode={onChangeQueryMode}
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
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;
@@ -99,12 +69,12 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: BUILDER_QUERY,
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
redirectWithQueryBuilderData: jest.fn(),
});
});
it('shows no ClickHouse or PromQL tab for the List kind', () => {
it('shows only the Query Builder tab for the List kind', () => {
renderBuilder('signoz/ListPanel');
expect(screen.getByText('Query Builder')).toBeInTheDocument();
@@ -120,126 +90,12 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
});
it('shows all four tabs for the Time Series kind', () => {
it('shows all three 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);
});
});
@@ -247,7 +103,7 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: BUILDER_QUERY,
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
redirectWithQueryBuilderData: jest.fn(),
});
});
@@ -288,7 +144,7 @@ describe('PanelEditorQueryBuilder signal dropdown (driven by the capabilities gu
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: BUILDER_QUERY,
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
redirectWithQueryBuilderData: jest.fn(),
});
});

View File

@@ -71,8 +71,6 @@ 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;
}
/**
@@ -94,7 +92,6 @@ 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.
@@ -310,7 +307,6 @@ function QueryEditorBody({
panelDefinition={panelDefinition}
signal={listSignal}
isLoadingQueries={isFetching}
onChangeQueryMode={onChangeQueryMode}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
spec={spec}

View File

@@ -60,9 +60,8 @@ jest.mock('../hooks/usePanelEditorQuerySync', () => ({
}));
const mockUseTypeSwitch = jest.fn();
jest.mock('../hooks/usePanelKindAndQueryModeSwitch', () => ({
usePanelKindAndQueryModeSwitch: (args: unknown): unknown =>
mockUseTypeSwitch(args),
jest.mock('../hooks/usePanelTypeSwitch', () => ({
usePanelTypeSwitch: (args: unknown): unknown => mockUseTypeSwitch(args),
}));
jest.mock('../hooks/usePanelEditorSave', () => ({

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useRef, useState } from 'react';
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider as ReduxProvider } from 'react-redux';
@@ -21,8 +21,6 @@ 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', () => {
@@ -84,18 +82,10 @@ 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={panelDefinition}
onChangeQueryMode={onChangeQueryMode}
panelDefinition={requireQueryPanelDefinition('signoz/TimeSeriesPanel')}
isLoadingQueries={false}
onStageRunQuery={noop}
onCancelQuery={noop}

View File

@@ -45,16 +45,8 @@ 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',
builder: { queryData: [] },
} as unknown as Query;
const STAGED_V1 = {
id: 'staged',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
const SEED_V1 = { id: 'seed', queryType: 'builder' } as unknown as Query;
const STAGED_V1 = { id: 'staged', queryType: 'builder' } as unknown as Query;
function makeDraft(
queries = SAVED_QUERIES,
@@ -82,11 +74,7 @@ function builderState(
handleRunQuery: jest.Mock;
} {
return {
currentQuery: {
id: 'current',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query,
currentQuery: { id: 'current', queryType: 'builder' } as unknown as Query,
stagedQuery: STAGED_V1,
handleRunQuery: jest.fn(),
...overrides,
@@ -408,16 +396,8 @@ describe('usePanelEditorQuerySync', () => {
const EDITED_ENVELOPES = [
{ id: 'edited-envelopes' },
] as unknown as NonNullable<DashboardtypesPanelSpecDTO['queries']>;
const editedQuery = {
id: 'edited',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
const unchangedQuery = {
id: 'unchanged',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
const editedQuery = { id: 'edited', queryType: 'builder' } as Query;
const unchangedQuery = { id: 'unchanged', queryType: 'builder' } as Query;
beforeEach(() => {
mockToPerses.mockImplementation((query: Query) =>
@@ -488,66 +468,4 @@ 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,
});
});
});
});

View File

@@ -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 { resolveQueryMode } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { resolveQueryType } from '../../../Panels/capabilities';
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
import { usePanelKindAndQueryModeSwitch } from '../usePanelKindAndQueryModeSwitch';
import { usePanelTypeSwitch } from '../usePanelTypeSwitch';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
@@ -18,10 +18,7 @@ jest.mock('lib/query/panelQuery', () => ({
handleQueryChange: jest.fn(),
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryMode: jest.fn(),
getSupportedSignals: jest.fn(() => ['metrics']),
getQueryPanelDefinition: jest.requireActual('../../../Panels/capabilities')
.getQueryPanelDefinition,
resolveQueryType: jest.fn(),
// Real predicate: these specs use real (query) kinds and the static path is
// exercised through its own cases below.
isStaticPanelKind: jest.requireActual('../../../Panels/capabilities')
@@ -39,7 +36,7 @@ jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
const mockResolveQueryMode = resolveQueryMode as unknown as jest.Mock;
const mockResolveQueryType = resolveQueryType 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;
@@ -82,27 +79,14 @@ 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(),
updateAllQueriesOperators: jest.fn((query: Query) => query),
};
return { currentQuery, redirectWithQueryBuilderData: jest.fn() };
}
describe('usePanelKindAndQueryModeSwitch', () => {
describe('usePanelTypeSwitch', () => {
beforeEach(() => {
jest.clearAllMocks();
mockHandleQueryChange.mockReturnValue(TRANSFORMED);
@@ -111,16 +95,16 @@ describe('usePanelKindAndQueryModeSwitch', () => {
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.
mockResolveQueryMode.mockReturnValue('builder');
mockResolveQueryType.mockReturnValue('builder');
});
it('does nothing when switching to the current kind', () => {
const setSpec = jest.fn();
const state = builderState(fakeQuery('q', 'builder'));
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
usePanelTypeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
@@ -134,12 +118,12 @@ describe('usePanelKindAndQueryModeSwitch', () => {
it('on first visit: transforms the query and resets the spec to the new kind', () => {
const setSpec = jest.fn();
const tableQuery = fakeQuery('table-current', 'builder');
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const state = builderState(tableQuery);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
usePanelTypeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
@@ -162,7 +146,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
it('seeds timestamp-desc Order By on every query when switching to a List panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState(fakeQuery('ts-current', 'builder')),
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -171,7 +155,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -190,7 +174,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
it('keeps one query and drops the formulas when switching to a Heatmap panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState(fakeQuery('ts-current', 'builder')),
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -202,7 +186,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -220,7 +204,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
it('swaps a percentile spatial aggregation for count when switching to a Heatmap panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState(fakeQuery('ts-current', 'builder')),
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -238,7 +222,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -258,7 +242,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
it('swaps count back for a percentile when a histogram leaves a Heatmap panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState(fakeQuery('heatmap-current', 'builder')),
builderState({ id: 'heatmap-current', queryType: 'builder' } as Query),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -279,7 +263,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
usePanelTypeSwitch({
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.HEATMAP,
setSpec,
@@ -299,7 +283,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
it('leaves a non-histogram metric on sum when it leaves a Heatmap panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState(fakeQuery('heatmap-current', 'builder')),
builderState({ id: 'heatmap-current', queryType: 'builder' } as Query),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -320,7 +304,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
usePanelTypeSwitch({
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.HEATMAP,
setSpec,
@@ -334,11 +318,11 @@ describe('usePanelKindAndQueryModeSwitch', () => {
it('coerces the query type when the new kind disallows it (promql → List)', () => {
const setSpec = jest.fn();
const promQuery = fakeQuery('prom', 'promql');
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -347,7 +331,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// The hook asks the guard to resolve the active query type against the new kind…
expect(mockResolveQueryMode).toHaveBeenCalledWith(
expect(mockResolveQueryType).toHaveBeenCalledWith(
'signoz/ListPanel',
'promql',
);
@@ -356,224 +340,16 @@ describe('usePanelKindAndQueryModeSwitch', () => {
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 = fakeQuery('table-current', 'builder');
const listQuery = fakeQuery('list-current', 'builder');
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
let state = builderState(tableQuery);
mockUseQueryBuilder.mockImplementation(() => state);
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
usePanelTypeSwitch({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);

View File

@@ -4,10 +4,7 @@ import type {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import {
getSupportedSignals,
requireQueryPanelDefinition,
} from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { 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';
@@ -94,7 +91,7 @@ export function usePanelEditSession({
// View modal shells) — asserted rather than assumed.
const panelDefinition = requireQueryPanelDefinition(panelKind);
const panelType = toPanelType(panelKind);
const defaultSignal = getSupportedSignals(panelKind)[0];
const defaultSignal = panelDefinition.supportedSignals[0];
const query = usePanelQuery({
panel: draft,

View File

@@ -1,289 +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 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 };
}

View File

@@ -0,0 +1,290 @@
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 };
}

View File

@@ -1,83 +0,0 @@
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,
],
);
}

View File

@@ -6,7 +6,7 @@ import type { NewPanelTarget } from '../patchOps';
import QueryEditorBody from './QueryEditorBody';
import StaticEditorBody from './StaticEditorBody';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelKindAndQueryModeSwitch } from './hooks/usePanelKindAndQueryModeSwitch';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
export interface PanelEditorContainerProps {
dashboardId: string;
@@ -40,12 +40,11 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
const panelDefinition = getPanelDefinition(panelKind);
const originalPanelKind = (savedPanel ?? panel).spec.plugin.kind;
const { onChangePanelKind, onChangeQueryMode } =
usePanelKindAndQueryModeSwitch({
spec: draftApi.draft.spec,
panelType: toPanelType(panelKind),
setSpec: draftApi.setSpec,
});
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: toPanelType(panelKind),
setSpec: draftApi.setSpec,
});
if (panelDefinition.mode === 'static') {
return (
@@ -66,7 +65,6 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
onChangeQueryMode={onChangeQueryMode}
/>
);
}

View File

@@ -1,114 +0,0 @@
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;
}

View File

@@ -3,7 +3,6 @@ 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';
@@ -12,13 +11,12 @@ import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
import {
getQueryPanelDefinition,
requireQueryPanelDefinition,
getSupportedQueryModes,
getSupportedQueryTypes,
getSupportedSignals,
isPanelCombinationValid,
isQueryTypeSupportedByPanelKind,
isSignalSupported,
resolveQueryMode,
resolveQueryType,
} from '../capabilities';
import type { PanelKind } from '../types/panelKind';
@@ -40,48 +38,6 @@ 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],
@@ -267,15 +223,6 @@ 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', () => {
@@ -324,36 +271,18 @@ describe('panel capabilities guard', () => {
});
});
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,
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('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', () => {
it('coerces an unsupported query type to the first supported one', () => {
// PromQL → List has no PromQL, falls back to its first (and only) type.
expect(resolveQueryMode('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
expect(resolveQueryMode('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
expect(resolveQueryType('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
expect(resolveQueryType('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
});
});
});

View File

@@ -1,9 +1,7 @@
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';
@@ -50,17 +48,11 @@ export function requireQueryPanelDefinition(
return definition;
}
const DEFAULT_QUERY_MODE = QueryMode.QUERY_BUILDER;
/** Signals a kind can visualize in `mode`. */
/** Signals a kind can visualize. */
export function getSupportedSignals(
kind: PanelKind,
mode: QueryMode = DEFAULT_QUERY_MODE,
): TelemetrytypesSignalDTO[] {
return getQueryModeSignals(
getQueryPanelDefinition(kind)?.supportedQueryModesKindMap,
mode,
);
return getQueryPanelDefinition(kind)?.supportedSignals ?? [];
}
export function isSignalSupported(
@@ -70,16 +62,9 @@ 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 getSupportedQueryModes(kind).filter(
(mode): mode is EQueryType => mode !== QueryMode.AI_QUERY_BUILDER,
);
return getQueryPanelDefinition(kind)?.supportedQueryTypes ?? [];
}
export function isQueryTypeSupportedByPanelKind(
@@ -116,15 +101,20 @@ export function isPanelCombinationValid({
return true;
}
/** `preferred` if the kind supports it, else its first supported mode (e.g. PromQL → List gives Query Builder). */
export function resolveQueryMode(
/**
* 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(
kind: PanelKind,
preferred: QueryMode,
): QueryMode {
const supported = getSupportedQueryModes(kind);
preferred: EQueryType,
): EQueryType {
const supported = getSupportedQueryTypes(kind);
if (supported.includes(preferred)) {
return preferred;
}
// A query-less kind has no supported modes; the builder is the neutral answer.
return supported[0] ?? DEFAULT_QUERY_MODE;
// A query-less kind has no supported types; the builder is the neutral answer.
return supported[0] ?? EQueryType.QUERY_BUILDER;
}

View File

@@ -1,7 +1,6 @@
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';
@@ -9,7 +8,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/AreaChartPanel'> = {
kind: 'signoz/AreaChartPanel',
@@ -19,22 +18,16 @@ export const definition: PanelDefinition<'signoz/AreaChartPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
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],
},
},
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,

View File

@@ -1,7 +1,6 @@
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';
@@ -9,7 +8,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
@@ -19,22 +18,16 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
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],
},
},
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
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).

View File

@@ -3,7 +3,6 @@ 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';
@@ -11,7 +10,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/HeatmapPanel'> = {
kind: 'signoz/HeatmapPanel',
@@ -21,15 +20,13 @@ export const definition: PanelDefinition<'signoz/HeatmapPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
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 },
},
// Only metrics carry a bucket axis; the request rejects the other signals.
supportedSignals: [TelemetrytypesSignalDTO.metrics],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
// The request rejects both: a point is a count per bucket, not a single value.
queryBuilderFields: {
[QueryBuilderField.Functions]: { state: 'hidden' },

View File

@@ -1,7 +1,6 @@
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';
@@ -9,7 +8,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
@@ -19,22 +18,16 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
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],
},
},
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
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.

View File

@@ -10,7 +10,6 @@ function ListEditorPane({
panelDefinition,
signal,
isLoadingQueries,
onChangeQueryMode,
onStageRunQuery,
onCancelQuery,
stickyHeader,
@@ -21,7 +20,6 @@ function ListEditorPane({
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
isLoadingQueries={isLoadingQueries}
onChangeQueryMode={onChangeQueryMode}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}

View File

@@ -1,7 +1,6 @@
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';
@@ -9,7 +8,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/ListPanel'> = {
kind: 'signoz/ListPanel',
@@ -19,16 +18,11 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
Renderer,
EditorPane: ListEditorPane,
// Raw records come from logs and traces; metrics don't produce row data.
supportedQueryModesKindMap: {
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.logs, TelemetrytypesSignalDTO.traces],
},
[QueryMode.AI_QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.traces],
},
},
supportedSignals: [
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER],
// No deviation from the baseline the raw request type below already implies.
queryBuilderFields: {},
sections,

View File

@@ -1,7 +1,6 @@
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';
@@ -9,7 +8,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
@@ -19,22 +18,16 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
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],
},
},
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,

View File

@@ -1,7 +1,6 @@
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';
@@ -9,7 +8,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
@@ -19,21 +18,12 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
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],
},
},
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,

View File

@@ -1,7 +1,6 @@
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';
@@ -9,7 +8,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
kind: 'signoz/TablePanel',
@@ -19,21 +18,12 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
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],
},
},
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
queryBuilderFields: {},
// The only kind that asks the server to transpose its scalar result into UI rows.
queryCapabilities: {

View File

@@ -1,7 +1,6 @@
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';
@@ -9,7 +8,7 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
@@ -19,22 +18,16 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
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],
},
},
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,

View File

@@ -25,7 +25,8 @@ export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections: [],
supportedQueryModesKindMap: {},
supportedSignals: [],
supportedQueryTypes: [],
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,

View File

@@ -1,8 +1,4 @@
import {
Querybuildertypesv5RequestTypeDTO,
type TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
export type { QueryBuilderFieldsConfig } from 'components/QueryBuilderV2/queryBuilderFields.types';
@@ -38,25 +34,3 @@ 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 : [];
}

View File

@@ -4,6 +4,7 @@ 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';
@@ -11,7 +12,6 @@ import type { PanelKind } from './panelKind';
import type {
PanelQueryCapabilities,
QueryBuilderFieldsConfig,
SupportedQueryModesKindMap,
} from './panelCapabilities';
import type {
BaseRendererProps,
@@ -94,8 +94,6 @@ 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. */
@@ -116,8 +114,10 @@ export interface QueryPanelDefinition<
Renderer: ComponentType<PanelRendererProps<K>>;
/** Lower editor pane — the shared query-builder pane, or a kind wrapper of it. */
EditorPane: ComponentType<QueryEditorPaneProps>;
/** Authoring modes (Query Builder / ClickHouse / PromQL / AI) and the signals each accepts. */
supportedQueryModesKindMap: SupportedQueryModesKindMap;
/** Signals this kind can visualize. */
supportedSignals: TelemetrytypesSignalDTO[];
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
supportedQueryTypes: EQueryType[];
queryBuilderFields: QueryBuilderFieldsConfig;
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
queryCapabilities: PanelQueryCapabilities;

View File

@@ -4,11 +4,6 @@ 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,
@@ -21,13 +16,13 @@ export function getBuilderQueries(
const flattened: BuilderQuery[] = [];
queries.forEach((envelope) => {
const plugin = envelope.spec.plugin;
if (isBuilderPluginKind(plugin.kind)) {
if (plugin.kind === 'signoz/BuilderQuery') {
flattened.push(plugin.spec as BuilderQuery);
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
(plugin.spec.queries || []).forEach((sub) => {
if (isBuilderEnvelope(sub)) {
if (sub.type === 'builder_query') {
flattened.push(sub.spec as BuilderQuery);
}
});

View File

@@ -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, getSupportedSignals } from '../capabilities';
import { getQueryPanelDefinition } 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] = getSupportedSignals(kind);
const [defaultSignal] = definition.supportedSignals;
// 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) {

View File

@@ -1,43 +0,0 @@
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;
}

View File

@@ -416,42 +416,6 @@ 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(

View File

@@ -28,7 +28,6 @@ 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
@@ -64,7 +63,6 @@ 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();
@@ -105,17 +103,13 @@ export function usePanelActionItems({
const row = (
text: string,
icon: ReactElement,
opts: {
checks?: BrandedPermission[];
destructive?: boolean;
disabledTooltip?: string;
} = {},
opts: { checks?: BrandedPermission[]; destructive?: boolean } = {},
): ReactNode => (
<MenuActionItem
label={text}
icon={icon}
checks={opts.checks ?? editChecks}
disabledTooltip={opts.disabledTooltip ?? editDisabledTooltip}
disabledTooltip={editDisabledTooltip}
destructive={opts.destructive}
/>
);
@@ -163,11 +157,7 @@ export function usePanelActionItems({
if (panelCapabilities.createAlert) {
dataGroup.push({
key: 'create-alert',
label: row('Create Alerts', <Bell size={14} />, {
checks: [],
disabledTooltip: blocked.createAlert,
}),
disabled: !!blocked.createAlert,
label: row('Create Alerts', <Bell size={14} />, { checks: [] }),
onClick: (): void => createAlert(panel, panelId),
});
}
@@ -207,7 +197,6 @@ export function usePanelActionItems({
editChecks,
editDisabledTooltip,
panelCapabilities,
blocked,
panel,
panelActions,
sections,

View File

@@ -12,7 +12,6 @@ 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 {
@@ -68,7 +67,7 @@ function PanelHeader(props: PanelHeaderProps): JSX.Element {
const { panelId, panel, panelActions, hideActions } = props;
const query = props.mode === 'query' ? props : null;
const name = usePanelTitle(panel);
const name = panel.spec.display.name;
const description = panel.spec.display.description;
const errorDetail = useMemo(
() => panelStatusFromError(query?.error),

View File

@@ -6,7 +6,6 @@ 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';
@@ -107,7 +106,6 @@ function PanelTypeSelectionModal({
direction="right"
width="wide"
testId="panel-type-drawer"
drawerContentProps={JS_DRIVEN_SLIDE}
drawerDescriptionProps={{ className: styles.body }}
footer={
<div className={styles.footer}>

View File

@@ -31,8 +31,6 @@ 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;
}
/**
@@ -47,7 +45,6 @@ function QueryViewModalBody({
onClose,
draftApi,
onChangePanelKind,
onChangeQueryMode,
}: QueryViewModalBodyProps): JSX.Element | null {
const {
timeOverride,
@@ -165,7 +162,6 @@ function QueryViewModalBody({
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isFetching}
onChangeQueryMode={onChangeQueryMode}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
stickyHeader={false}

View File

@@ -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,6 +31,8 @@ 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);
@@ -52,11 +54,11 @@ function ViewPanelModal({
>
<DialogHeader>
<DialogTitle>
{panel ? (
<ViewPanelModalTitle panel={panel} />
) : (
<Typography.Text className={styles.title}>View mode</Typography.Text>
)}
<TooltipSimple title={name} arrow>
<Typography.Text className={styles.title}>
{name ? `${name} - (View mode)` : 'View mode'}
</Typography.Text>
</TooltipSimple>
</DialogTitle>
</DialogHeader>
<DialogCloseButton />

View File

@@ -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 { usePanelKindAndQueryModeSwitch } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelKindAndQueryModeSwitch';
import { usePanelTypeSwitch } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch';
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,12 +73,11 @@ function ViewPanelModalContent({
const draftKind = draftApi.draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(draftKind);
const { onChangePanelKind, onChangeQueryMode } =
usePanelKindAndQueryModeSwitch({
spec: draftApi.draft.spec,
panelType: toPanelType(draftKind),
setSpec: draftApi.setSpec,
});
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: toPanelType(draftKind),
setSpec: draftApi.setSpec,
});
if (panelDefinition.mode === 'static') {
return (
@@ -98,7 +97,6 @@ function ViewPanelModalContent({
onClose={onClose}
draftApi={draftApi}
onChangePanelKind={onChangePanelKind}
onChangeQueryMode={onChangeQueryMode}
/>
);
}

View File

@@ -1,24 +0,0 @@
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;

View File

@@ -1,12 +1,8 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
type DashboardtypesPanelDTO,
Querybuildertypesv5VariableTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { DashboardtypesPanelDTO } 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';
@@ -104,30 +100,6 @@ 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

View File

@@ -40,11 +40,9 @@ jest.mock('hooks/useUrlQuery', () => ({
default: (): URLSearchParams => new URLSearchParams(),
}));
jest.mock(
'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelKindAndQueryModeSwitch',
'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch',
() => ({
usePanelKindAndQueryModeSwitch: (): unknown => ({
onChangePanelKind: jest.fn(),
}),
usePanelTypeSwitch: (): unknown => ({ onChangePanelKind: jest.fn() }),
}),
);

View File

@@ -89,7 +89,7 @@ jest.mock(
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1', resolvedVariables: {} }),
selector({ dashboardId: 'dash-1' }),
}),
);

View File

@@ -38,7 +38,7 @@ jest.mock(
'pages/DashboardPage/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (selector: (s: unknown) => unknown): unknown =>
selector({ dashboardId: 'dash-1', resolvedVariables: {} }),
selector({ dashboardId: 'dash-1' }),
}),
);

View File

@@ -8,8 +8,6 @@ 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;
@@ -31,7 +29,7 @@ export function useDownloadPanelCsv({
data,
canDownloadCsv,
}: UseDownloadPanelCsvArgs): () => void {
const fileName = usePanelTitle(panel);
const fileName = panel.spec.display.name;
return useCallback((): void => {
if (!canDownloadCsv) {

View File

@@ -10,7 +10,6 @@ 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;
@@ -29,7 +28,7 @@ export function useDownloadPanelMenuItem({
data,
actions,
}: UseDownloadPanelMenuItemArgs): MenuItem | null {
const panelName = usePanelTitle(panel);
const panelName = panel.spec.display.name;
const downloadPanelCsv = useDownloadPanelCsv({
panel,
data,

View File

@@ -1,13 +0,0 @@
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]);
}

View File

@@ -1,21 +0,0 @@
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 {};
}

View File

@@ -1,16 +0,0 @@
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],
);
}

View File

@@ -99,23 +99,6 @@ 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' } },
@@ -224,30 +207,6 @@ 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' }),

View File

@@ -4,11 +4,7 @@ import type {
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5BucketOptionsLogDTOKind } from 'api/generated/services/sigNoz.schemas';
import {
initialQueriesMap,
initialQueryAIWithType,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { initialQueriesMap, 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';
@@ -225,38 +221,6 @@ 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],

View File

@@ -5,21 +5,18 @@ 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.
@@ -61,15 +58,6 @@ 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 [
{
@@ -139,10 +127,13 @@ function withBucketedStepInterval(
): Querybuildertypesv5QueryEnvelopeDTO[] {
const stepInterval = getBucketedStepIntervalSeconds(startMs, endMs);
return envelopes.map((envelope) => {
if (!isBuilderEnvelope(envelope)) {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
return envelope;
}
if ((envelope.spec as QuerySpecView)?.stepInterval) {
if (envelope.spec?.stepInterval) {
return envelope;
}
return {
@@ -150,8 +141,8 @@ function withBucketedStepInterval(
spec: {
...envelope.spec,
stepInterval,
},
} as Querybuildertypesv5QueryEnvelopeDTO;
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
});
}
@@ -164,7 +155,10 @@ function withListOrderTiebreaker(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
): Querybuildertypesv5QueryEnvelopeDTO[] {
return envelopes.map((envelope) => {
if (!isBuilderEnvelope(envelope)) {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
return envelope;
}
const spec = envelope.spec as QuerySpecView;
@@ -189,8 +183,8 @@ function withListOrderTiebreaker(
...primary,
{ key: { name: 'id' }, direction: primary[0].direction },
],
},
} as Querybuildertypesv5QueryEnvelopeDTO;
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
});
}
@@ -203,7 +197,10 @@ function withPagination(
{ offset, limit }: { offset: number; limit: number },
): Querybuildertypesv5QueryEnvelopeDTO[] {
return envelopes.map((envelope) => {
if (!isBuilderEnvelope(envelope)) {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
return envelope;
}
return {
@@ -212,8 +209,8 @@ function withPagination(
...envelope.spec,
offset,
limit,
},
} as Querybuildertypesv5QueryEnvelopeDTO;
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
});
}
@@ -306,7 +303,11 @@ export function hasRunnableQueries(queries: DashboardtypesQueryDTO[]): boolean {
}
const metricsSpecs = envelopes
.filter(isBuilderEnvelope)
.filter(
(envelope) =>
envelope.type ===
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
)
.map((envelope) => envelope.spec as QuerySpecView)
.filter((spec) => spec.signal === 'metrics');

View File

@@ -1,14 +1,13 @@
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,
@@ -22,10 +21,6 @@ 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';
/**
@@ -49,6 +44,11 @@ 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,9 +158,8 @@ 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 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.
* `panel.queries.length === 1` invariant. Exception: List emits its one builder query
* as a bare `signoz/BuilderQuery` because the backend rejects a `signoz/CompositeQuery`.
*/
export function toPerses(
query: Query,
@@ -174,23 +173,21 @@ export function toPerses(
const envelopes = toGeneratedEnvelopes(composite.queries ?? []);
if (panelType === PANEL_TYPES.LIST) {
const builder = envelopes.find(isBuilderEnvelope);
const builder = envelopes.find(isBuilderQueryEnvelope);
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 },
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,
},
},
},
];
}

View File

@@ -3,9 +3,10 @@ import type {
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5ScalarDataDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType } from 'api/generated/services/sigNoz.schemas';
import { isBuilderEnvelope } from 'lib/query/builderEnvelope';
import {
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
} from 'api/generated/services/sigNoz.schemas';
import type { PanelTable, PanelTableColumn } from './types';
@@ -27,7 +28,10 @@ export function extractAggregationsPerQuery(
): AggregationsPerQuery {
const perQuery: AggregationsPerQuery = {};
(requestPayload?.compositeQuery?.queries ?? []).forEach((envelope) => {
if (!isBuilderEnvelope(envelope)) {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
return;
}
const spec = envelope.spec;

View File

@@ -1,10 +0,0 @@
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'];

View File

@@ -209,29 +209,4 @@ describe('mapQueryDataFromApi', (): void => {
id: 'b5f4b7db-799c-47d2-bf32-090340995e20',
});
});
it('keeps builder_ai_query on the mapped builder query', () => {
const compositeQuery: ICompositeMetricQuery = {
queryType: EQueryType.QUERY_BUILDER,
panelType: PANEL_TYPES.TIME_SERIES,
unit: undefined,
queries: [
{
type: 'builder_ai_query',
spec: {
name: 'A',
signal: 'traces',
filter: { expression: "service.name = 'adservice'" },
aggregations: [{ expression: 'count()' }],
},
} as QueryEnvelope,
],
};
const result = mapQueryDataFromApi(compositeQuery);
expect(result.builder.queryData[0].builderQueryType).toBe('builder_ai_query');
expect(result.builder.queryData[0].queryName).toBe('A');
expect(result.builder.queryData[0].dataSource).toBe(DataSource.TRACES);
});
});

View File

@@ -1,7 +1,4 @@
import {
Querybuildertypesv5BucketOptionsDTO,
Querybuildertypesv5QueryTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import { Format } from 'constants/formats/types';
import { EQueryType } from 'types/common/dashboard';
@@ -22,14 +19,6 @@ import {
} from '../v5/queryRange';
import { BaseAutocompleteData } from './queryAutocompleteResponse';
export const QueryMode = {
...EQueryType,
/** Not an `EQueryType`: AI queries are builder queries tagged `builderQueryType: builder_ai_query`. */
AI_QUERY_BUILDER: Querybuildertypesv5QueryTypeDTO.builder_ai_query,
} as const;
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode];
// Type for Formula
export interface IBuilderFormula {
expression: string;
@@ -152,17 +141,3 @@ export type MapQueryDataToApiResult<T> = {
data: T;
newLegendMap: Record<string, string>;
};
/** Query Builder and AI share `Query.builder`; the variant says which one it holds. */
export const BuilderVariant = {
MAIN: 'main',
AI: 'ai',
} as const;
export type BuilderVariant =
(typeof BuilderVariant)[keyof typeof BuilderVariant];
/** Builder queries per variant; `Query` has one `builder` slot, so the off-screen variant lives here. */
export type BuildersByVariant = Partial<
Record<BuilderVariant, Query['builder']>
>;

View File

@@ -90,7 +90,7 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
ID: "GetTraceThread",
Tags: []string{"tracedetail"},
Summary: "Get thread view for a trace",
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.",
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.",
RequestQuery: new(spantypes.GetTraceThreadParams),
Response: new(spantypes.GettableTraceThread),
ResponseContentType: "application/json",

View File

@@ -3,42 +3,51 @@ package contextlinks
import (
"encoding/json"
"net/url"
"slices"
"strconv"
"time"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// PrepareParamsForTracesV5 returns the traces explorer query params for the
// given range and filter; the traces explorer writes its time params in
// nanoseconds. queryType is builder_ai_query for the AI observability explorer.
func PrepareParamsForTracesV5(start, end time.Time, whereClause string, queryType qbtypes.QueryType) url.Values {
return prepareExplorerParams("traces", queryType, start.UnixNano(), end.UnixNano(), whereClause)
func PrepareParamsForTracesV5(start, end time.Time, labels map[string]string, queries []BuilderQuery, queryType qbtypes.QueryType) url.Values {
// the traces explorer takes its time params in nanoseconds
return prepareExplorerParams("traces", queryType, start.UnixNano(), end.UnixNano(), labels, queries)
}
// PrepareParamsForLogsV5 returns the logs explorer query params for the given
// range and filter; the logs explorer writes its time params in milliseconds.
func PrepareParamsForLogsV5(start, end time.Time, whereClause string) url.Values {
return prepareExplorerParams("logs", qbtypes.QueryTypeBuilder, start.UnixMilli(), end.UnixMilli(), whereClause)
func PrepareParamsForLogsV5(start, end time.Time, labels map[string]string, queries []BuilderQuery) url.Values {
// the logs explorer takes its time params in milliseconds
return prepareExplorerParams("logs", qbtypes.QueryTypeBuilder, start.UnixMilli(), end.UnixMilli(), labels, queries)
}
// The end link is double encoded because otherwise a filter expression with `%` somewhere in it breaks.
func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start, end int64, whereClause string) url.Values {
func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start, end int64, labels map[string]string, queries []BuilderQuery) url.Values {
// builder_query is the explorer default, so it is left out to keep existing links unchanged
builderQueryType := ""
if queryType != qbtypes.QueryTypeBuilder {
builderQueryType = queryType.StringValue()
}
if len(queries) == 0 {
queries = []BuilderQuery{{}}
}
queryData := make([]LinkQuery, 0, len(queries))
for _, query := range queries {
queryData = append(queryData, LinkQuery{
QueryName: query.Name,
DataSource: dataSource,
BuilderQueryType: builderQueryType,
Filter: &FilterExpression{Expression: PrepareFilterExpression(labels, query.Filter, query.GroupBy)},
})
}
urlData := URLShareableCompositeQuery{
QueryType: "builder",
Builder: URLShareableBuilderQuery{
QueryData: []LinkQuery{{
DataSource: dataSource,
BuilderQueryType: builderQueryType,
Filter: &FilterExpression{Expression: whereClause},
}},
QueryData: queryData,
QueryFormulas: make([]string, 0),
},
}
@@ -52,40 +61,56 @@ func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start
return params
}
// BuilderQueryForSignal returns the filter expression and group-by keys of the
// builder query for the given signal, or found=false when the composite query
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts). AI trace
// queries (builder_ai_query) count as trace builder queries.
// TODO(srikanthccv): re-visit this and support multiple queries.
func BuilderQueryForSignal(queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
func ExtractBuilderQueries(condition *ruletypes.RuleCondition, signal telemetrytypes.Signal) []BuilderQuery {
if condition == nil || condition.CompositeQuery == nil {
return nil
}
names := resolveSelectedQueryNames(condition)
switch signal {
case telemetrytypes.SignalLogs:
return builderQueryForSignal[qbtypes.LogAggregation](queries, signal)
return extractBuilderQueries[qbtypes.LogAggregation](condition.CompositeQuery.Queries, names, signal)
case telemetrytypes.SignalTraces:
return builderQueryForSignal[qbtypes.TraceAggregation](queries, signal)
return extractBuilderQueries[qbtypes.TraceAggregation](condition.CompositeQuery.Queries, names, signal)
}
return "", nil, false
return nil
}
func builderQueryForSignal[T any](queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
var q qbtypes.QueryBuilderQuery[T]
found := false
func resolveSelectedQueryNames(condition *ruletypes.RuleCondition) []string {
selected := condition.SelectedQuery
if selected == "" {
selected = condition.SelectedQueryName()
}
for _, query := range condition.CompositeQuery.Queries {
if query.GetQueryName() != selected {
continue
}
if formula, ok := query.Spec.(qbtypes.QueryBuilderFormula); ok {
return formula.ExtractReferencedQueryNames()
}
return []string{selected}
}
return nil
}
func extractBuilderQueries[T any](queries []qbtypes.QueryEnvelope, names []string, signal telemetrytypes.Signal) []BuilderQuery {
var matches []BuilderQuery
for _, query := range queries {
if query.Type != qbtypes.QueryTypeBuilder && query.Type != qbtypes.QueryTypeBuilderAI {
continue
}
if spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T]); ok {
q = spec
found = true
spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T])
// spec.Disabled is deliberately not checked: a formula's inputs are
// usually hidden in the UI but must still make it into the link
if !ok || spec.Signal != signal || !slices.Contains(names, spec.Name) {
continue
}
filterExpr := ""
if spec.Filter != nil {
filterExpr = spec.Filter.Expression
}
matches = append(matches, BuilderQuery{Name: spec.Name, Filter: filterExpr, GroupBy: spec.GroupBy})
}
if !found || q.Signal != signal {
return "", nil, false
}
filterExpr := ""
if q.Filter != nil {
filterExpr = q.Filter.Expression
}
return filterExpr, q.GroupBy, true
return matches
}

View File

@@ -4,12 +4,12 @@ import (
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuilderQueryForSignal(t *testing.T) {
func TestExtractBuilderQueries(t *testing.T) {
logQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
@@ -19,62 +19,145 @@ func TestBuilderQueryForSignal(t *testing.T) {
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
},
}
hiddenLogQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Name: "B",
Signal: telemetrytypes.SignalLogs,
Disabled: true,
Filter: &qbtypes.Filter{Expression: "body CONTAINS 'timeout'"},
},
}
traceQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "B",
Name: "C",
Signal: telemetrytypes.SignalTraces,
},
}
promQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{Name: "C"},
Spec: qbtypes.PromQuery{Name: "D"},
}
aiTraceQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "D",
Name: "E",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "trace.input_tokens > 1000"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "session.id"}}},
},
}
formula := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeFormula,
Spec: qbtypes.QueryBuilderFormula{Name: "F1", Expression: "B / A * 100"},
}
t.Run("logs query among mixed queries", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}, telemetrytypes.SignalLogs)
require.True(t, found)
assert.Equal(t, "severity_text = 'ERROR'", filterExpr)
require.Len(t, groupBy, 1)
assert.Equal(t, "service.name", groupBy[0].Name)
})
testCases := []struct {
name string
condition *ruletypes.RuleCondition
signal telemetrytypes.Signal
expectedBuilderQueries []BuilderQuery
}{
{
name: "SelectedLogsQuery_AmongMixedQueries",
condition: &ruletypes.RuleCondition{
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}},
},
signal: telemetrytypes.SignalLogs,
expectedBuilderQueries: []BuilderQuery{
{Name: "A", Filter: "severity_text = 'ERROR'", GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}}},
},
},
{
name: "SelectedLogsQuery_LeavesOutUnselectedQuery",
condition: &ruletypes.RuleCondition{
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, hiddenLogQuery}},
},
signal: telemetrytypes.SignalLogs,
expectedBuilderQueries: []BuilderQuery{
{Name: "A", Filter: "severity_text = 'ERROR'", GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}}},
},
},
{
name: "SelectedFormula_HiddenInputsInCompositeQueryOrder",
condition: &ruletypes.RuleCondition{
SelectedQuery: "F1",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{hiddenLogQuery, logQuery, formula}},
},
signal: telemetrytypes.SignalLogs,
expectedBuilderQueries: []BuilderQuery{
{Name: "B", Filter: "body CONTAINS 'timeout'"},
{Name: "A", Filter: "severity_text = 'ERROR'", GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}}},
},
},
{
name: "NoSelectedQuery_FallsBackToFormula",
condition: &ruletypes.RuleCondition{
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, hiddenLogQuery, formula}},
},
signal: telemetrytypes.SignalLogs,
expectedBuilderQueries: []BuilderQuery{
{Name: "A", Filter: "severity_text = 'ERROR'", GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}}},
{Name: "B", Filter: "body CONTAINS 'timeout'"},
},
},
{
name: "SelectedTracesQuery_WithoutFilter",
condition: &ruletypes.RuleCondition{
SelectedQuery: "C",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, traceQuery}},
},
signal: telemetrytypes.SignalTraces,
expectedBuilderQueries: []BuilderQuery{{Name: "C"}},
},
{
name: "SelectedAITraceQuery_CountsAsTraces",
condition: &ruletypes.RuleCondition{
SelectedQuery: "E",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, aiTraceQuery}},
},
signal: telemetrytypes.SignalTraces,
expectedBuilderQueries: []BuilderQuery{
{Name: "E", Filter: "trace.input_tokens > 1000", GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "session.id"}}}},
},
},
{
name: "SelectedQuery_OfAnotherSignal",
condition: &ruletypes.RuleCondition{
SelectedQuery: "C",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, traceQuery}},
},
signal: telemetrytypes.SignalLogs,
},
{
name: "SelectedQuery_NotABuilderQuery",
condition: &ruletypes.RuleCondition{
SelectedQuery: "D",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{promQuery}},
},
signal: telemetrytypes.SignalLogs,
},
{
name: "UnsupportedSignal",
condition: &ruletypes.RuleCondition{
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery}},
},
signal: telemetrytypes.SignalMetrics,
},
{
name: "NoCompositeQuery",
condition: &ruletypes.RuleCondition{SelectedQuery: "A"},
signal: telemetrytypes.SignalLogs,
},
}
t.Run("traces query without filter", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, traceQuery}, telemetrytypes.SignalTraces)
require.True(t, found)
assert.Empty(t, filterExpr)
assert.Empty(t, groupBy)
})
t.Run("ai trace query counts as traces", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, aiTraceQuery}, telemetrytypes.SignalTraces)
require.True(t, found)
assert.Equal(t, "trace.input_tokens > 1000", filterExpr)
require.Len(t, groupBy, 1)
assert.Equal(t, "session.id", groupBy[0].Name)
})
t.Run("no builder query for signal", func(t *testing.T) {
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{traceQuery}, telemetrytypes.SignalLogs)
assert.False(t, found)
})
t.Run("no builder queries at all", func(t *testing.T) {
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery}, telemetrytypes.SignalLogs)
assert.False(t, found)
})
t.Run("unsupported signal", func(t *testing.T) {
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery}, telemetrytypes.SignalMetrics)
assert.False(t, found)
})
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expectedBuilderQueries, ExtractBuilderQueries(testCase.condition, testCase.signal))
})
}
}

View File

@@ -1,6 +1,7 @@
package contextlinks
import (
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
)
@@ -10,9 +11,19 @@ type FilterExpression struct {
Expression string `json:"expression,omitempty"`
}
// BuilderQuery is the part of an alert's builder query that carries over into
// an explorer link.
type BuilderQuery struct {
Name string
Filter string
GroupBy []qbtypes.GroupByKey
}
// LinkQuery carries the only fields the explorer pages read from a shared
// link; the frontend fills in the rest of the query shape with defaults.
type LinkQuery struct {
// the explorer names an unnamed query A, so a link with several queries names each
QueryName string `json:"queryName,omitempty"`
DataSource string `json:"dataSource"`
BuilderQueryType string `json:"builderQueryType,omitempty"`
Filter *FilterExpression `json:"filter,omitempty"`

View File

@@ -9,6 +9,7 @@ var (
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureJSONBodyDualIngestion = featuretypes.MustNewName("json_body_dual_ingestion")
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
FeatureResolveSemconvFamilies = featuretypes.MustNewName("resolve_semconv_families")
FeatureUseTraceAttributesJSON = featuretypes.MustNewName("use_trace_attributes_json")
@@ -64,6 +65,14 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureJSONBodyDualIngestion,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether the collector's normalize operator keeps the original log body so it is ingested into both the legacy body and the JSON body columns",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureEnableMetricsReduction,
Kind: featuretypes.KindBoolean,

View File

@@ -17,10 +17,9 @@ import (
// filter and the entry's labels, so a history entry can be opened in the
// explorer with the context that produced it.
type relatedLinkBuilder struct {
alertType ruletypes.AlertType
evaluation ruletypes.Evaluation
filterExpr string
groupBy []qbtypes.GroupByKey
alertType ruletypes.AlertType
evaluation ruletypes.Evaluation
builderQueries []contextlinks.BuilderQuery
}
// relatedLinkBuilderForRule returns nil when the rule cannot be loaded or is
@@ -65,7 +64,7 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
// links are still built from the labels alone when the rule has no builder
// query for the signal (e.g. ClickHouse SQL alerts)
builder.filterExpr, builder.groupBy, _ = contextlinks.BuilderQueryForSignal(rule.RuleCondition.CompositeQuery.Queries, signal)
builder.builderQueries = contextlinks.ExtractBuilderQueries(rule.RuleCondition, signal)
return builder
}
@@ -89,15 +88,13 @@ func (b *relatedLinkBuilder) links(labels rulestatehistorytypes.LabelsString, st
return rulestatehistorytypes.RelatedLinks{}
}
whereClause := contextlinks.PrepareFilterExpression(lbls, b.filterExpr, b.groupBy)
switch b.alertType {
case ruletypes.AlertTypeLogs:
return rulestatehistorytypes.RelatedLinks{RelatedLogsLink: contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode()}
return rulestatehistorytypes.RelatedLinks{RelatedLogsLink: contextlinks.PrepareParamsForLogsV5(start, end, lbls, b.builderQueries).Encode()}
case ruletypes.AlertTypeTraces:
return rulestatehistorytypes.RelatedLinks{RelatedTracesLink: contextlinks.PrepareParamsForTracesV5(start, end, whereClause, qbtypes.QueryTypeBuilder).Encode()}
return rulestatehistorytypes.RelatedLinks{RelatedTracesLink: contextlinks.PrepareParamsForTracesV5(start, end, lbls, b.builderQueries, qbtypes.QueryTypeBuilder).Encode()}
case ruletypes.AlertTypeAITraces:
return rulestatehistorytypes.RelatedLinks{RelatedAITracesLink: contextlinks.PrepareParamsForTracesV5(start, end, whereClause, qbtypes.QueryTypeBuilderAI).Encode()}
return rulestatehistorytypes.RelatedLinks{RelatedAITracesLink: contextlinks.PrepareParamsForTracesV5(start, end, lbls, b.builderQueries, qbtypes.QueryTypeBuilderAI).Encode()}
}
return rulestatehistorytypes.RelatedLinks{}
}

View File

@@ -158,6 +158,10 @@ func (s *traceStore) GetTraceStats(ctx context.Context, orgID valuer.UUID, trace
// genAISpanColumns returns the gen_ai columns aggregated per span, resolved across attribute evolutions.
func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bounds *spantypes.TraceBounds, sb *sqlbuilder.SelectBuilder) ([]string, error) {
attributeKey := func(name string, dataType telemetrytypes.FieldDataType) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: dataType}
}
values := []struct{ key, alias string }{
{aiobservabilitytypes.GenAIUsageInputTokens, "input_tokens_value"},
{aiobservabilitytypes.GenAIUsageOutputTokens, "output_tokens_value"},
@@ -171,17 +175,26 @@ func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bo
for _, value := range values {
names = append(names, value.key)
}
keys, err := s.genAIFieldKeys(ctx, orgID, names)
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(names))
for _, name := range names {
selectors = append(selectors, &telemetrytypes.FieldKeySelector{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact})
}
keys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, s.flagger, selectors))
if err != nil {
return nil, err
}
q := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano()))
isGenAI, err := s.anyExistsCondition(ctx, q, aiobservabilitytypes.GenAISpanGateKeys, keys, sb)
if err != nil {
return nil, err
gate := make([]string, 0, len(aiobservabilitytypes.GenAISpanGateKeys))
for _, name := range aiobservabilitytypes.GenAISpanGateKeys {
conds, _, err := querybuilder.Conditions(ctx, q, s.storage, attributeKey(name, telemetrytypes.FieldDataTypeString), qbtypes.FilterOperatorExists, nil, keys, false, sb)
if err != nil {
return nil, err
}
gate = append(gate, conds...)
}
columns := []string{isGenAI + " AS is_gen_ai"}
columns := []string{sb.Or(gate...) + " AS is_gen_ai"}
for _, value := range values {
// lookup by number, the type metadata stores numeric attributes under; float64 is only the output cast
@@ -297,11 +310,7 @@ func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, trac
sb.SelectMore("attributes")
}
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
keys, err := s.genAIFieldKeys(ctx, orgID, aiobservabilitytypes.GenAIThreadKeys)
if err != nil {
return nil, err
}
isThreadSpan, err := s.anyExistsCondition(ctx, q, aiobservabilitytypes.GenAIThreadKeys, keys, sb)
hasMessages, err := s.messagesExistCondition(ctx, q, orgID, bounds, sb)
if err != nil {
return nil, err
}
@@ -309,7 +318,7 @@ func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, trac
sb.E("trace_id", traceID),
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
sb.LE("ts_bucket_start", bounds.End.Unix()),
isThreadSpan,
hasMessages,
)
if cursor := page.Cursor; cursor != nil {
// ClickHouse can't use an index for a tuple comparison, so the separate timestamp and
@@ -346,6 +355,38 @@ func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, trac
return spans, nil
}
// messagesExistCondition resolves the gen_ai message keys through the attribute evolution metadata
// and the use_trace_attributes_json flag, so the filter reads the same columns the query builder does.
func (s *traceStore) messagesExistCondition(ctx context.Context, q qbtypes.QueryInfo, orgID valuer.UUID, bounds *spantypes.TraceBounds, sb *sqlbuilder.SelectBuilder) (string, error) {
names := []string{aiobservabilitytypes.GenAIInputMessages, aiobservabilitytypes.GenAIOutputMessages}
selectors := make([]*telemetrytypes.FieldKeySelector, len(names))
for i, name := range names {
selectors[i] = &telemetrytypes.FieldKeySelector{
StartUnixMilli: bounds.Start.UnixMilli(),
EndUnixMilli: bounds.End.UnixMilli(),
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute,
Name: name,
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
}
}
fieldKeys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, selectors)
if err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "error fetching thread field keys")
}
conds := make([]string, 0, len(names))
for _, name := range names {
key := &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute}
keyConds, _, err := querybuilder.Conditions(ctx, q, s.storage, key, qbtypes.FilterOperatorExists, nil, fieldKeys, false, sb)
if err != nil {
return "", err
}
conds = append(conds, keyConds...)
}
return sb.Or(conds...), nil
}
func (s *traceStore) GetThreadCursor(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, spanID string) (*spantypes.ThreadCursor, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("toUnixTimestamp64Nano(timestamp)")
@@ -491,34 +532,3 @@ func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string,
}
return result, nil
}
func attributeKey(name string, dataType telemetrytypes.FieldDataType) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: dataType}
}
// genAIFieldKeys fetches the metadata keys for the attribute names, expanded across semconv families.
func (s *traceStore) genAIFieldKeys(ctx context.Context, orgID valuer.UUID, names []string) (map[string][]*telemetrytypes.TelemetryFieldKey, error) {
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(names))
for _, name := range names {
selectors = append(selectors, &telemetrytypes.FieldKeySelector{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact})
}
keys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, s.flagger, selectors))
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error fetching gen_ai field keys")
}
return keys, nil
}
// anyExistsCondition ORs an EXISTS test per attribute name, resolved across attribute evolutions
// and the use_trace_attributes_json flag so the filter reads the same columns the query builder does.
func (s *traceStore) anyExistsCondition(ctx context.Context, q qbtypes.QueryInfo, names []string, keys map[string][]*telemetrytypes.TelemetryFieldKey, sb *sqlbuilder.SelectBuilder) (string, error) {
conds := make([]string, 0, len(names))
for _, name := range names {
keyConds, _, err := querybuilder.Conditions(ctx, q, s.storage, attributeKey(name, telemetrytypes.FieldDataTypeString), qbtypes.FilterOperatorExists, nil, keys, false, sb)
if err != nil {
return "", err
}
conds = append(conds, keyConds...)
}
return sb.Or(conds...), nil
}

View File

@@ -220,14 +220,14 @@ func TestGetThreadSpans(t *testing.T) {
name: "FlagOff_ReadsAndFiltersLegacyMaps",
jsonRelease: &jsonInsideTrace,
selectSQL: selectSQL,
whereSQL: "(mapContains(attributes_string, 'gen_ai.input.messages') OR mapContains(attributes_string, 'gen_ai.output.messages') OR mapContains(attributes_string, 'gen_ai.tool.name'))",
whereSQL: "(mapContains(attributes_string, 'gen_ai.input.messages') OR mapContains(attributes_string, 'gen_ai.output.messages'))",
},
{
name: "FlagOn_ReleasedDuringTrace_ReadsJSONFiltersJSONThenMaps",
jsonOn: true,
jsonRelease: &jsonInsideTrace,
selectSQL: selectSQL + ", attributes",
whereSQL: "(multiIf(attributes.`gen_ai.input.messages` IS NOT NULL, attributes.`gen_ai.input.messages`::String, mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.output.messages` IS NOT NULL, attributes.`gen_ai.output.messages`::String, mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.tool.name` IS NOT NULL, attributes.`gen_ai.tool.name`::String, mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL) IS NOT NULL)",
whereSQL: "(multiIf(attributes.`gen_ai.input.messages` IS NOT NULL, attributes.`gen_ai.input.messages`::String, mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.output.messages` IS NOT NULL, attributes.`gen_ai.output.messages`::String, mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL) IS NOT NULL)",
},
}

View File

@@ -65,6 +65,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations/messagingQueues/kafka"
@@ -802,47 +803,13 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
// alerts have 2 minutes delay built in, so we need to subtract that from the start time
// to get the correct query range
start := end.Add(-rule.EvalWindow.Duration() - 3*time.Minute)
if rule.AlertType == ruletypes.AlertTypeLogs {
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
q = spec
}
}
}
filterExpr := ""
if q.Filter != nil && q.Filter.Expression != "" {
filterExpr = q.Filter.Expression
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedLogsLink = contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode()
} else if rule.AlertType == ruletypes.AlertTypeTraces || rule.AlertType == ruletypes.AlertTypeAITraces {
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder || query.Type == qbtypes.QueryTypeBuilderAI {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
q = spec
}
}
}
filterExpr := ""
if q.Filter != nil && q.Filter.Expression != "" {
filterExpr = q.Filter.Expression
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, whereClause, rule.AlertType.BuilderQueryType()).Encode()
switch rule.AlertType {
case ruletypes.AlertTypeLogs:
builderQueries := contextlinks.ExtractBuilderQueries(rule.RuleCondition, telemetrytypes.SignalLogs)
res.Items[idx].RelatedLogsLink = contextlinks.PrepareParamsForLogsV5(start, end, lbls, builderQueries).Encode()
case ruletypes.AlertTypeTraces, ruletypes.AlertTypeAITraces:
builderQueries := contextlinks.ExtractBuilderQueries(rule.RuleCondition, telemetrytypes.SignalTraces)
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, lbls, builderQueries, rule.AlertType.BuilderQueryType()).Encode()
}
}
}

View File

@@ -2,7 +2,6 @@ package logparsingpipeline
import (
"encoding/json"
"slices"
"strings"
"sync"
@@ -25,13 +24,10 @@ var (
)
const (
memoryLimiterProcessor = "memory_limiter"
memoryLimiterProcessorPrefix = "memory_limiter/"
batchProcessor = "batch"
batchProcessorPrefix = "batch/"
normalizerProcessor = "signozlogsnormalizer"
normalizerProcessorPrefix = "signozlogsnormalizer/"
normalizerBeforeUserPipelines = "before"
memoryLimiterProcessor = "memory_limiter"
memoryLimiterProcessorPrefix = "memory_limiter/"
batchProcessor = "batch"
batchProcessorPrefix = "batch/"
)
// check if the processors already exist
@@ -94,14 +90,9 @@ func getOtelPipelineFromConfig(config map[string]interface{}) (*otelPipeline, er
// 2. signoz user-pipeline processors (in the order given by signozPipelineProcessorNames)
// 3. custom processors (non-signoz, non-memory_limiter, non-batch processors from the current config)
// 4. batch processors (any processor named "batch" or "batch/<id>") and anything after them
//
// If a signozlogsnormalizer processor is present, the order above is skipped: the first one
// runs right after user-pipeline processors, or right before them when its config sets
// `opamp.position: before`, and the other processors keep their places.
func buildCollectorPipelineProcessorsList(
currentCollectorProcessors []string,
signozPipelineProcessorNames []string,
processorConfigs map[string]interface{},
) ([]string, error) {
lockLogsPipelineSpec.Lock()
defer lockLogsPipelineSpec.Unlock()
@@ -112,23 +103,6 @@ func buildCollectorPipelineProcessorsList(
userPipelineSet[p] = struct{}{}
}
if normalizerIdx := slices.IndexFunc(currentCollectorProcessors, isNormalizerProcessor); normalizerIdx >= 0 {
normalizerFirst := opampPosition(processorConfigs, currentCollectorProcessors[normalizerIdx]) == normalizerBeforeUserPipelines
result := make([]string, 0, len(currentCollectorProcessors)+len(signozPipelineProcessorNames))
for idx, p := range currentCollectorProcessors {
if idx == normalizerIdx && !normalizerFirst {
result = append(result, signozPipelineProcessorNames...)
}
if _, inUserPipelines := userPipelineSet[p]; !inUserPipelines && !hasSignozPipelineProcessorPrefix(p) {
result = append(result, p)
}
if idx == normalizerIdx && normalizerFirst {
result = append(result, signozPipelineProcessorNames...)
}
}
return result, nil
}
var memoryLimiters []string
var customProcessors []string
batchIdx := -1
@@ -161,51 +135,13 @@ func buildCollectorPipelineProcessorsList(
return result, nil
}
func isNormalizerProcessor(name string) bool {
return name == normalizerProcessor || strings.HasPrefix(name, normalizerProcessorPrefix)
}
// collectorNormalizesFirst reports whether the logs pipeline of the collector config runs
// signozlogsnormalizer ahead of user pipelines, judged by the first one as in buildCollectorPipelineProcessorsList.
// Malformed configs report false; generateCollectorConfigWithPipelines surfaces their errors.
func collectorNormalizesFirst(collectorConf map[string]interface{}) bool {
p, err := getOtelPipelineFromConfig(collectorConf)
if err != nil || p.Pipelines.Logs == nil {
return false
}
normalizerIdx := slices.IndexFunc(p.Pipelines.Logs.Processors, isNormalizerProcessor)
if normalizerIdx < 0 {
return false
}
processorConfigs, _ := collectorConf["processors"].(map[string]interface{})
return opampPosition(processorConfigs, p.Pipelines.Logs.Processors[normalizerIdx]) == normalizerBeforeUserPipelines
}
func opampPosition(processorConfigs map[string]interface{}, name string) string {
config, _ := processorConfigs[name].(map[string]interface{})
opamp, _ := config["opamp"].(map[string]interface{})
position, _ := opamp["position"].(string)
return position
}
func unmarshalCollectorConfig(config []byte) (map[string]interface{}, error) {
func GenerateCollectorConfigWithPipelines(config []byte, pipelines []pipelinetypes.GettablePipeline) ([]byte, error) {
var collectorConf map[string]interface{}
if err := yaml.Unmarshal(config, &collectorConf); err != nil {
err := yaml.Unmarshal([]byte(config), &collectorConf)
if err != nil {
return nil, errors.WrapInvalidInputf(err, CodeCollectorConfigUnmarshalFailed, "could not unmarshal collector config")
}
return collectorConf, nil
}
func GenerateCollectorConfigWithPipelines(config []byte, pipelines []pipelinetypes.GettablePipeline) ([]byte, error) {
collectorConf, err := unmarshalCollectorConfig(config)
if err != nil {
return nil, err
}
return generateCollectorConfigWithPipelines(collectorConf, pipelines)
}
// generateCollectorConfigWithPipelines modifies collectorConf in place.
func generateCollectorConfigWithPipelines(collectorConf map[string]any, pipelines []pipelinetypes.GettablePipeline) ([]byte, error) {
signozPipelineProcessors, signozPipelineProcNames, err := PreparePipelineProcessor(pipelines)
if err != nil {
return nil, err
@@ -244,11 +180,11 @@ func generateCollectorConfigWithPipelines(collectorConf map[string]any, pipeline
return nil, errors.NewInternalf(CodeCollectorConfigLogsPipelineNotFound, "logs pipeline doesn't exist")
}
updatedProcessorList, _ := buildCollectorPipelineProcessorsList(p.Pipelines.Logs.Processors, signozPipelineProcNames, collectorConf["processors"].(map[string]any))
updatedProcessorList, _ := buildCollectorPipelineProcessorsList(p.Pipelines.Logs.Processors, signozPipelineProcNames)
p.Pipelines.Logs.Processors = updatedProcessorList
// add the new processor to the data ( no checks required as the keys will exists)
collectorConf["service"].(map[string]any)["pipelines"].(map[string]any)["logs"] = p.Pipelines.Logs
collectorConf["service"].(map[string]interface{})["pipelines"].(map[string]interface{})["logs"] = p.Pipelines.Logs
updatedConf, err := yaml.Marshal(collectorConf)
if err != nil {

View File

@@ -5,14 +5,11 @@ import (
"fmt"
"testing"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
"github.com/SigNoz/signoz/pkg/valuer"
. "github.com/smartystreets/goconvey/convey"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
@@ -209,74 +206,13 @@ var BuildLogsPipelineTestData = []struct {
func TestBuildLogsPipeline(t *testing.T) {
for _, test := range BuildLogsPipelineTestData {
Convey(test.Name, t, func() {
v, err := buildCollectorPipelineProcessorsList(test.fromCollector, test.userPipelines, nil)
v, err := buildCollectorPipelineProcessorsList(test.fromCollector, test.userPipelines)
So(err, ShouldBeNil)
So(v, ShouldResemble, test.finalOutput)
})
}
}
func TestBuildLogsPipelineWithNormalizer(t *testing.T) {
a := constants.LogsPPLPfx + "a"
b := constants.LogsPPLPfx + "b"
before := map[string]any{"opamp": map[string]any{"position": "before"}}
after := map[string]any{"opamp": map[string]any{"position": "after"}}
testCases := []struct {
name string
fromCollector []string
userPipelines []string
configs map[string]any
expected []string
}{
{
name: "ExistingAndStaleUserProcessorsReplaced",
fromCollector: []string{"memory_limiter", a, constants.LogsPPLPfx + "stale", constants.OldLogsPPLPfx + "old", "signozlogsnormalizer", "custom", "batch"},
userPipelines: []string{a, b},
expected: []string{"memory_limiter", a, b, "signozlogsnormalizer", "custom", "batch"},
},
{
name: "NamedNormalizerAfter_CustomProcessorsKeepPlace",
fromCollector: []string{"memory_limiter", "custom_a", "signozlogsnormalizer/x", a, "custom_b", "batch"},
userPipelines: []string{a},
configs: map[string]any{"signozlogsnormalizer/x": after},
expected: []string{"memory_limiter", "custom_a", a, "signozlogsnormalizer/x", "custom_b", "batch"},
},
{
name: "FirstNormalizerAfter_SecondIgnored",
fromCollector: []string{"memory_limiter", "signozlogsnormalizer/first", "signozlogsnormalizer/second", "batch"},
userPipelines: []string{a},
configs: map[string]any{"signozlogsnormalizer/first": after, "signozlogsnormalizer/second": before},
expected: []string{"memory_limiter", a, "signozlogsnormalizer/first", "signozlogsnormalizer/second", "batch"},
},
{
name: "FirstNormalizerBefore_SecondIgnored",
fromCollector: []string{"memory_limiter", "signozlogsnormalizer/first", "signozlogsnormalizer/second", "batch"},
userPipelines: []string{a},
configs: map[string]any{"signozlogsnormalizer/first": before, "signozlogsnormalizer/second": after},
expected: []string{"memory_limiter", "signozlogsnormalizer/first", a, "signozlogsnormalizer/second", "batch"},
},
{
name: "NormalizerAfterBatch",
fromCollector: []string{"memory_limiter", "batch", "signozlogsnormalizer"},
userPipelines: []string{a},
expected: []string{"memory_limiter", "batch", a, "signozlogsnormalizer"},
},
{
name: "NoUserPipelines_StaleRemoved",
fromCollector: []string{"memory_limiter", a, "signozlogsnormalizer", "batch"},
expected: []string{"memory_limiter", "signozlogsnormalizer", "batch"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
processors, err := buildCollectorPipelineProcessorsList(testCase.fromCollector, testCase.userPipelines, testCase.configs)
require.NoError(t, err)
assert.Equal(t, testCase.expected, processors)
})
}
}
func TestPipelineAliasCollisionsDontResultInDuplicateCollectorProcessors(t *testing.T) {
require := require.New(t)
@@ -501,61 +437,3 @@ func TestPipeCharInAliasDoesntBreakCollectorConfig(t *testing.T) {
}, result[0].Attributes_string,
)
}
func TestRecommendAgentConfigWithNormalizerProcessorAndJSONBody(t *testing.T) {
userPipeline := makeTestAddAttributePipeline()
controller := &LogParsingPipelineController{
fl: flaggertest.WithUseJSONBody(t, true),
GetIntegrationPipelines: func(context.Context, string) ([]pipelinetypes.GettablePipeline, error) {
return []pipelinetypes.GettablePipeline{userPipeline}, nil
},
}
normalizeOperator := CollectorConfProcessorName(getNormalizePipeline())
user := CollectorConfProcessorName(userPipeline)
testCases := []struct {
name string
processors string
logs string
expected []any
}{
{
name: "NoNormalizer_OperatorPrepended",
logs: "[memory_limiter, batch]",
expected: []any{"memory_limiter", normalizeOperator, user, "batch"},
},
{
name: "NormalizerDefault_OperatorKept",
logs: "[memory_limiter, signozlogsnormalizer, batch]",
expected: []any{"memory_limiter", normalizeOperator, user, "signozlogsnormalizer", "batch"},
},
{
name: "NormalizerBefore_OperatorDropped",
processors: "signozlogsnormalizer:\n opamp:\n position: before\n",
logs: "[memory_limiter, signozlogsnormalizer, batch]",
expected: []any{"memory_limiter", "signozlogsnormalizer", user, "batch"},
},
{
name: "NormalizerAfter_OperatorKept",
processors: "signozlogsnormalizer:\n opamp:\n position: after\n",
logs: "[memory_limiter, signozlogsnormalizer, batch]",
expected: []any{"memory_limiter", normalizeOperator, user, "signozlogsnormalizer", "batch"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
conf := fmt.Sprintf(
"processors:\n memory_limiter: {}\n batch: {}\n %s\nservice:\n pipelines:\n logs:\n receivers: [otlp]\n processors: %s\n exporters: [clickhouselogsexporter]\n",
testCase.processors, testCase.logs,
)
recommended, _, err := controller.RecommendAgentConfig(valuer.GenerateUUID(), []byte(conf), nil)
require.NoError(t, err)
var recommendedConf map[string]any
require.NoError(t, yaml.Unmarshal(recommended, &recommendedConf))
logs := recommendedConf["service"].(map[string]any)["pipelines"].(map[string]any)["logs"].(map[string]any)
assert.Equal(t, testCase.expected, logs["processors"])
})
}
}

View File

@@ -220,17 +220,26 @@ func (ic *LogParsingPipelineController) ValidatePipelines(ctx context.Context,
return err
}
// withNormalizePipeline places normalize ahead of user pipelines when queries run on body_v2
// (use_json_body), so operators see the body the explorer shows. Absent when the flag is off
// or collectorNormalizesFirst.
func (ic *LogParsingPipelineController) withNormalizePipeline(ctx context.Context, orgID valuer.UUID, pipelines []pipelinetypes.GettablePipeline, collectorNormalizesFirst bool) []pipelinetypes.GettablePipeline {
if collectorNormalizesFirst || !ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
// withNormalizePipeline places normalize where the read path dictates. Ahead of user pipelines
// when queries run on body_v2 (use_json_body), so operators see the body the explorer shows.
// After them when dual ingestion alone writes body_v2, so operators keep seeing the raw body
// users still query. Absent when neither flag is on.
func (ic *LogParsingPipelineController) withNormalizePipeline(ctx context.Context, orgID valuer.UUID, pipelines []pipelinetypes.GettablePipeline) []pipelinetypes.GettablePipeline {
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
dualIngestion := ic.fl.BooleanOrEmpty(ctx, flagger.FeatureJSONBodyDualIngestion, evalCtx)
switch {
case ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, evalCtx):
return append([]pipelinetypes.GettablePipeline{getNormalizePipeline(dualIngestion)}, pipelines...)
case dualIngestion:
return append(slices.Clone(pipelines), getNormalizePipeline(true))
default:
return pipelines
}
return append([]pipelinetypes.GettablePipeline{getNormalizePipeline()}, pipelines...)
}
func getNormalizePipeline() pipelinetypes.GettablePipeline {
// stashOriginalBody makes normalize carry the pre-normalization body in an internal attribute
// for the exporter to restore into the legacy body column.
func getNormalizePipeline(stashOriginalBody bool) pipelinetypes.GettablePipeline {
return pipelinetypes.GettablePipeline{
StoreablePipeline: pipelinetypes.StoreablePipeline{
Name: "Default Pipeline - PreProcessing Body",
@@ -249,10 +258,11 @@ func getNormalizePipeline() pipelinetypes.GettablePipeline {
},
Config: []pipelinetypes.PipelineOperator{
{
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",
JSONBodyDualIngestion: stashOriginalBody,
},
},
}
@@ -361,8 +371,11 @@ func (ic *LogParsingPipelineController) PreviewLogsPipelines(
}
// The collector gets the same pipeline prepended over opamp; see RecommendAgentConfig.
// Under dual ingestion alone it runs after user operators and only feeds body_v2, which
// the explorer does not show yet, so the preview leaves it out. The original-body stash
// is left off: the preview has no exporter to restore and strip it.
if ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
pipelines = append([]pipelinetypes.GettablePipeline{getNormalizePipeline()}, pipelines...)
pipelines = append([]pipelinetypes.GettablePipeline{getNormalizePipeline(false)}, pipelines...)
}
result, collectorLogs, err := SimulatePipelinesProcessing(ctx, pipelines, request.Logs)
@@ -383,14 +396,14 @@ func (pc *LogParsingPipelineController) AgentFeatureType() agentConf.AgentFeatur
// Implements agentConf.AgentFeature interface.
// RecommendAgentConfig generates the collector config to be sent to agents.
// The normalize pipeline (when use_json_body is on) is placed
// The normalize pipeline (when use_json_body or json_body_dual_ingestion is on) is placed
// here, after rawPipelineData is serialized. So it is only present in the config sent to
// the collector and never persisted to the database as part of the user's pipeline list.
//
// NOTE: The configId sent to agents is derived from the pipeline version number
// (e.g. "LogPipelines:5"), not the YAML content. If server-side logic changes
// the generated YAML without bumping the version (e.g. toggling the use_json_body
// flag or updating operator IfExpressions), agents that already
// the generated YAML without bumping the version (e.g. toggling the use_json_body or
// json_body_dual_ingestion flags or updating operator IfExpressions), agents that already
// applied that version will not re-apply the new config. In such cases, users must save a
// new pipeline version via the API to force agents to pick up the change.
func (pc *LogParsingPipelineController) RecommendAgentConfig(
@@ -418,15 +431,10 @@ func (pc *LogParsingPipelineController) RecommendAgentConfig(
return nil, "", err
}
collectorConf, err := unmarshalCollectorConfig(currentConfYaml)
if err != nil {
return nil, "", err
}
// normalize is only for sending to the collector, never persisted
enrichedPipelines = pc.withNormalizePipeline(ctx, orgId, enrichedPipelines, collectorNormalizesFirst(collectorConf))
enrichedPipelines = pc.withNormalizePipeline(ctx, orgId, enrichedPipelines)
updatedConf, err := generateCollectorConfigWithPipelines(collectorConf, enrichedPipelines)
updatedConf, err := GenerateCollectorConfigWithPipelines(currentConfYaml, enrichedPipelines)
if err != nil {
return nil, "", err
}

View File

@@ -86,8 +86,6 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*q
}
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
selectedQuery := r.SelectedQuery(ctx)
qr, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return nil
@@ -95,24 +93,15 @@ func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time,
start := time.UnixMilli(int64(qr.Start))
end := time.UnixMilli(int64(qr.End))
// TODO(srikanthccv): handle formula queries
if selectedQuery < "A" || selectedQuery > "Z" {
builderQueries := contextlinks.ExtractBuilderQueries(r.ruleCondition, telemetrytypes.SignalLogs)
if len(builderQueries) == 0 {
return nil
}
filterExpr, groupBy, found := contextlinks.BuilderQueryForSignal(r.ruleCondition.CompositeQuery.Queries, telemetrytypes.SignalLogs)
if !found {
return nil
}
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause)
return contextlinks.PrepareParamsForLogsV5(start, end, lbls.Map(), builderQueries)
}
func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
selectedQuery := r.SelectedQuery(ctx)
qr, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return nil
@@ -120,19 +109,12 @@ func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time
start := time.UnixMilli(int64(qr.Start))
end := time.UnixMilli(int64(qr.End))
// TODO(srikanthccv): handle formula queries
if selectedQuery < "A" || selectedQuery > "Z" {
builderQueries := contextlinks.ExtractBuilderQueries(r.ruleCondition, telemetrytypes.SignalTraces)
if len(builderQueries) == 0 {
return nil
}
filterExpr, groupBy, found := contextlinks.BuilderQueryForSignal(r.ruleCondition.CompositeQuery.Queries, telemetrytypes.SignalTraces)
if !found {
return nil
}
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause, r.typ.BuilderQueryType())
return contextlinks.PrepareParamsForTracesV5(start, end, lbls.Map(), builderQueries, r.typ.BuilderQueryType())
}
func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {

View File

@@ -205,7 +205,7 @@ func TestPrepareParamsForLogs(t *testing.T) {
assert.Contains(t, params.Encode(), "compositeQuery=%257B")
compositeQuery, err := url.QueryUnescape(params.Get("compositeQuery"))
assert.NoError(t, err)
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"dataSource":"logs","filter":{}}],"queryFormulas":[]}}`, compositeQuery)
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"queryName":"A","dataSource":"logs","filter":{}}],"queryFormulas":[]}}`, compositeQuery)
}
func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
@@ -270,7 +270,7 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
compositeQuery, err := url.QueryUnescape(params.Get("compositeQuery"))
assert.NoError(t, err)
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"dataSource":"logs","filter":{"expression":"service.name EXISTS"}}],"queryFormulas":[]}}`, compositeQuery)
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"queryName":"A","dataSource":"logs","filter":{"expression":"service.name EXISTS"}}],"queryFormulas":[]}}`, compositeQuery)
}
func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
@@ -335,7 +335,7 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
compositeQuery, err := url.QueryUnescape(params.Get("compositeQuery"))
assert.NoError(t, err)
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"dataSource":"traces","filter":{"expression":"service.name EXISTS"}}],"queryFormulas":[]}}`, compositeQuery)
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"queryName":"A","dataSource":"traces","filter":{"expression":"service.name EXISTS"}}],"queryFormulas":[]}}`, compositeQuery)
}
func TestPrepareParamsForTraces(t *testing.T) {
@@ -401,7 +401,7 @@ func TestPrepareParamsForTraces(t *testing.T) {
assert.Contains(t, params.Encode(), "compositeQuery=%257B")
compositeQuery, err := url.QueryUnescape(params.Get("compositeQuery"))
assert.NoError(t, err)
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"dataSource":"traces","filter":{}}],"queryFormulas":[]}}`, compositeQuery)
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"queryName":"A","dataSource":"traces","filter":{}}],"queryFormulas":[]}}`, compositeQuery)
}
func TestThresholdRuleLabelNormalization(t *testing.T) {

View File

@@ -261,7 +261,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddUserTuplesFactory(sqlstore),
sqlmigration.NewAddRuleViewFactory(sqlstore, sqlschema),
sqlmigration.NewAddFieldTuplesFactory(sqlstore),
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
)
}

View File

@@ -1,116 +0,0 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"regexp"
"strings"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/factory"
)
// Mirrors the zeus LLMPriceFilter and its default model authors.
// https://github.com/SigNoz/zeus/pull/591/changes
var (
llmPricingRuleProviders = map[string]struct{}{
"openai": {}, "anthropic": {}, "google": {}, "mistralai": {}, "deepseek": {},
"qwen": {}, "x-ai": {}, "meta-llama": {}, "cohere": {}, "amazon": {},
}
qwenHostedModelPattern = regexp.MustCompile(`max|plus|flash|turbo`)
)
type llmPricingRuleSyncedRow struct {
bun.BaseModel `bun:"table:llm_pricing_rule"`
ID string `bun:"id,pk"`
Provider string `bun:"provider"`
Model string `bun:"model"`
Pricing string `bun:"pricing"`
}
type llmPricingRulePrices struct {
Input float64 `json:"input"`
Output float64 `json:"output"`
}
type keepRelevantLLMPricingRules struct {
settings factory.ProviderSettings
}
func NewKeepRelevantLLMPricingRulesFactory() factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("keep_relevant_llm_pricing_rules"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &keepRelevantLLMPricingRules{settings: ps}, nil
})
}
func (migration *keepRelevantLLMPricingRules) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *keepRelevantLLMPricingRules) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var rows []*llmPricingRuleSyncedRow
if err := tx.NewSelect().
Model(&rows).
Where("source_id IS NOT NULL").
Where("NOT is_override").
Scan(ctx); err != nil {
return err
}
ids := make([]string, 0)
for _, row := range rows {
var prices llmPricingRulePrices
if err := json.Unmarshal([]byte(row.Pricing), &prices); err != nil {
migration.settings.Logger.WarnContext(ctx, "llm pricing rule has unparseable pricing, leaving it untouched", slog.String("rule_id", row.ID), slog.String("raw_pricing", row.Pricing))
continue
}
if !llmPricingRuleRelevant(row.Provider, row.Model, prices) {
ids = append(ids, row.ID)
}
}
if len(ids) > 0 {
if _, err := tx.NewDelete().
Model((*llmPricingRuleSyncedRow)(nil)).
Where("id IN (?)", bun.In(ids)).
Exec(ctx); err != nil {
return err
}
}
migration.settings.Logger.InfoContext(ctx, "deleted irrelevant llm pricing rules", slog.Int("total", len(rows)), slog.Int("deleted", len(ids)))
return tx.Commit()
}
func (migration *keepRelevantLLMPricingRules) Down(context.Context, *bun.DB) error {
return nil
}
// The provider allowlist also rejects "~" alias ids, whose provider segment
// starts with "~", and ids without a "/" that were stored as "unknown".
func llmPricingRuleRelevant(provider, model string, prices llmPricingRulePrices) bool {
if _, ok := llmPricingRuleProviders[provider]; !ok {
return false
}
if strings.Contains(model, ":") {
return false
}
if prices.Input <= 0 || prices.Output <= 0 {
return false
}
if provider == "qwen" && !qwenHostedModelPattern.MatchString(model) {
return false
}
return true
}

View File

@@ -35,9 +35,6 @@ const (
// agent span. A trace belongs to the AI explorer when any span carries one.
var GenAISpanGateKeys = []string{GenAIRequestModel, GenAIToolName, GenAIAgentName}
// GenAIThreadKeys mark a span as a thread row: a model call with its messages, or a tool execution.
var GenAIThreadKeys = []string{GenAIInputMessages, GenAIOutputMessages, GenAIToolName}
// GenAISpanFilterExpression renders the gate as a query-builder filter
// expression: each gate key ORed on EXISTS.
func GenAISpanFilterExpression() string {

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