Compare commits

...

14 Commits

Author SHA1 Message Date
Gaurav Tewari
8f057b3709 feat(dashboards): persist AI queries as signoz/AIBuilderQuery
List panels serialize to a bare query plugin, which carries no envelope
tag — so their AI queries need the dedicated plugin kind to survive a
save/reload. With the kind allowed on every panel, List no longer has to
opt out of the AI query builder.

Assisted-by: Claude Opus 5
2026-09-16 20:45:58 +05:30
Gaurav Tewari
33c3d94bee Merge branch 'nv/dashboard-ai-builder-query' into nv/dashboard-ai-builder-query-plugin 2026-09-16 20:33:17 +05:30
Naman Verma
67878a01b2 feat: add ai builder query plugin kind 2026-09-10 12:08:52 +05:30
Gaurav Tewari
7e728824f5 Merge remote-tracking branch 'origin/main' into feat/ai-query-dashboard-editor
# Conflicts:
#	frontend/src/api/v5/queryRange/convertV5Response.ts
#	frontend/src/container/LLMObservability/Explorer/Explorer.tsx
#	frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder.tsx
#	frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/__tests__/utils.test.ts
#	frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/utils.ts
#	frontend/src/pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch.ts
#	frontend/src/pages/DashboardPage/DashboardContainer/Panels/utils/panelHasAIQuery.ts
#	frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelActionsMenu/usePanelActionItems.tsx
#	frontend/src/pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/ViewPanelModal/useViewPanelMode.ts
#	frontend/src/pages/DashboardPage/DashboardContainer/queryV5/builderEnvelope.ts
2026-09-09 16:37:11 +05:30
Gaurav Tewari
d2063281e2 feat(dashboards): render, page and drill into AI queries
Widens "builder query" to mean builder_query | builder_ai_query across the
V2 dashboard data layer, so a saved AI panel behaves like any other.

- add isBuilderEnvelope/withBuilderSpec and use them at every site that
  previously compared `type` to the builder_query literal
- fix the AI Bar/Histogram step interval, which was skipped and rendered
  per-minute points
- fix scalar column naming for AI Table/Pie/Value panels
- make dashboard variables see AI panels: apply, rename and delete now
  rewrite their filter expressions
- gate the panel-type switcher, so kinds that cannot carry an AI query
  are disabled with a reason
- set ListPanel supportsAIQuery: false — the backend accepts List only as
  a bare signoz/BuilderQuery, which carries no envelope tag, so an AI
  query saved there came back as a plain trace query
- gate the AI tab on enable_ai_observability for authoring only; a panel
  that already holds an AI query keeps its tab when the flag is off
- disable Create Alert on AI panels, which the alert builder cannot seed

Assisted-by: Claude Opus 5
2026-09-09 16:32:37 +05:30
Gaurav Tewari
75e6ceb4bb Merge remote-tracking branch 'refs/remotes/origin/main' into feat/ai-query-dashboard-editor 2026-09-01 16:58:23 +05:30
Gaurav Tewari
7d3273c423 feat(dashboards): support AI queries in the V2 panel editor
Adds an "AI Query Builder" tab to the V2 panel editor and makes
builder_ai_query survive a save/load round trip.

The tab is not an EQueryType: AI-ness stays on the per-query
builderQueryType tag, so the wire query type remains `builder` and the
existing serialization paths are untouched.

Assisted-by: Claude Opus 4.5
2026-09-01 12:42:00 +05:30
Gaurav Tewari
a88cc79ef9 chore: remove extra comments 2026-08-27 17:33:36 +05:30
Gaurav Tewari
e424082835 Merge remote-tracking branch 'origin/main' into feat/qb-changes-for-ai-explorer 2026-08-27 15:01:58 +05:30
Gaurav Tewari
2afed07b5b Merge remote-tracking branch 'refs/remotes/origin/main' into feat/qb-changes-for-ai-explorer 2026-08-27 12:31:02 +05:30
Gaurav Tewari
1c0dc018e0 chore: remove feildcontext 2026-08-27 08:58:28 +05:30
Gaurav Tewari
bb2511ce53 chore: update feild value changes 2026-08-27 08:39:35 +05:30
Gaurav Tewari
33d22c8b59 fix: keep the shared key-suggestion call site on the response envelope
fetchFieldKeysForQuery returned the unwrapped keys map, so QuerySearch guarded
on `keys` instead of `response.data.data`. That silently changed behaviour for
every explorer: a response carrying an envelope with no keys used to throw, and
had started skipping instead.

The fetcher now returns the response envelope, so the call site in the shared
component is unchanged from before apart from the fetcher swap. The generic
branch passes its response straight through; only the ai_observability branch
adapts, normalizing its nullable keys map.
2026-08-27 00:05:24 +05:30
Gaurav Tewari
c1b9de0c8a feat(llm-observability): wire the ai explorer to builder_ai_query
Adds the builder_ai_query envelope type, which the backend routes to the
gen_ai-scoped trace builder. Queries without the field still serialize as
builder_query, so nothing outside the AI explorer changes.

- filter bar keys and values come from the ai_observability endpoints; the
  per-trace aggregates are computed in SQL and never reach the metadata
  store, so the generic endpoints cannot serve them
- values forward fieldContext, which is what lets the endpoint reach those
  aggregates instead of scanning for attributes that were never ingested
- the span scope selector is hidden for AI queries: root and entrypoint
  spans AND badly with the gate, since GenAI attributes sit on nested spans
- newly added builder queries inherit builderQueryType from the first, the
  way they already inherit source
- response conversion reads aggregation metadata from both builder envelopes
- Trace replaces List as the explorer's default view, and the builder no
  longer renders its own order by for the list and trace panels

Assisted-by: Claude Opus 4.5
2026-08-26 20:37:00 +05:30
60 changed files with 1515 additions and 265 deletions

View File

@@ -3137,6 +3137,67 @@ components:
repeatVariable:
type: string
type: object
DashboardtypesAIBuilderQuerySpec:
properties:
aggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
nullable: true
type: array
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
DashboardtypesAxes:
properties:
isLogScale:
@@ -4040,6 +4101,7 @@ components:
DashboardtypesQueryPlugin:
discriminator:
mapping:
signoz/AIBuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
signoz/BuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
signoz/ClickHouseSQL: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
signoz/CompositeQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
@@ -4049,6 +4111,7 @@ components:
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
@@ -4058,12 +4121,25 @@ components:
DashboardtypesQueryPluginKind:
enum:
- signoz/BuilderQuery
- signoz/AIBuilderQuery
- signoz/CompositeQuery
- signoz/Formula
- signoz/PromQLQuery
- signoz/ClickHouseSQL
- signoz/TraceOperator
type: string
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec:
properties:
kind:
enum:
- signoz/AIBuilderQuery
type: string
spec:
$ref: '#/components/schemas/DashboardtypesAIBuilderQuerySpec'
required:
- kind
- spec
type: object
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec:
properties:
kind:

View File

@@ -3952,123 +3952,10 @@ export interface DashboardGridLayoutSpecDTO {
repeatVariable?: string;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
*/
isLogScale?: boolean;
/**
* @type number,null
*/
softMax?: number | null;
/**
* @type number,null
*/
softMin?: number | null;
export enum DashboardtypesAIBuilderQuerySpecDTOSignal {
traces = 'traces',
}
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 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 DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
*/
fillSpans?: boolean;
/**
* @type boolean
*/
stackedBarChart?: boolean;
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface DashboardtypesBarChartPanelSpecDTO {
axes?: DashboardtypesAxesDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesBarChartVisualizationDTO;
}
export interface DashboardtypesBasicVisualizationDTO {
timePreference?: DashboardtypesTimePreferenceDTO;
}
export interface Querybuildertypesv5LogAggregationDTO {
export interface Querybuildertypesv5TraceAggregationDTO {
/**
* @type string
*/
@@ -4260,13 +4147,201 @@ export interface TelemetrytypesTelemetryFieldKeyDTO {
unit?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal {
logs = 'logs',
}
export enum TelemetrytypesSourceDTO {
meter = 'meter',
'' = '',
}
export interface DashboardtypesAIBuilderQuerySpecDTO {
/**
* @type array,null
*/
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
/**
* @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 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 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 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
@@ -4455,17 +4530,6 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
stepInterval?: Querybuildertypesv5StepDTO;
}
export interface Querybuildertypesv5TraceAggregationDTO {
/**
* @type string
*/
alias?: string;
/**
* @type string
*/
expression?: string;
}
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal {
traces = 'traces',
}
@@ -4942,6 +5006,18 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesBuilderQuerySpecDTO;
}
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind {
'signoz/AIBuilderQuery' = 'signoz/AIBuilderQuery',
}
export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTO {
/**
* @enum signoz/AIBuilderQuery
* @type string
*/
kind: DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind;
spec: DashboardtypesAIBuilderQuerySpecDTO;
}
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind {
'signoz/CompositeQuery' = 'signoz/CompositeQuery',
}
@@ -5232,6 +5308,7 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQu
export type DashboardtypesQueryPluginDTO =
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormulaDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQueryDTO
@@ -5907,6 +5984,7 @@ export interface DashboardtypesPostablePublicDashboardDTO {
export enum DashboardtypesQueryPluginKindDTO {
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
'signoz/AIBuilderQuery' = 'signoz/AIBuilderQuery',
'signoz/CompositeQuery' = 'signoz/CompositeQuery',
'signoz/Formula' = 'signoz/Formula',
'signoz/PromQLQuery' = 'signoz/PromQLQuery',

View File

@@ -5,8 +5,10 @@ import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interface
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
import { DEFAULT_PANEL_TYPE } from '../constants';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const panelTypes = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
// Only reaches the builder for timeseries/table; list/trace panels use QueryBuilderV2's listViewTracesFilterConfigs.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(

View File

@@ -1,3 +1,7 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
export const TOOLBAR_VIEWS = {
list: {
name: 'list',

View File

@@ -37,11 +37,13 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
compositeQuery.queries?.forEach((q) => {
const spec = q.spec as BuilderQuery | PromQuery | ClickHouseQuery;
if (q.type === 'builder_query') {
if (q.type === 'builder_query' || q.type === 'builder_ai_query') {
if (spec.name) {
builderQueries[spec.name] = convertBuilderQueryToIBuilderQuery(
spec as BuilderQuery,
);
builderQueries[spec.name] = {
...convertBuilderQueryToIBuilderQuery(spec as BuilderQuery),
builderQueryType: q.type,
};
// Both share the builder bucket; the AI variant rides on the query itself.
builderQueryTypes[spec.name] = 'builder_query';
}
} else if (q.type === 'builder_formula') {

View File

@@ -34,6 +34,32 @@ function builderPanel(name: string, expression: string): unknown {
};
}
/** An AI panel: a CompositeQuery whose one envelope is tagged `builder_ai_query`. */
function aiPanel(name: string, expression: string): unknown {
return {
spec: {
display: { name },
queries: [
{
spec: {
plugin: {
kind: 'signoz/CompositeQuery',
spec: {
queries: [
{
type: 'builder_ai_query',
spec: { name: 'A', signal: 'traces', filter: { expression } },
},
],
},
},
},
},
],
},
};
}
function promqlPanel(name: string, query: string): unknown {
return {
spec: {
@@ -60,6 +86,7 @@ describe('findVariableUsages', () => {
p1: builderPanel('Panel One', "service IN $svc AND env = 'prod'"),
p2: promqlPanel('Panel Two', 'up{s="$svc"}'),
p3: builderPanel('Unrelated', "env = 'prod'"),
p4: aiPanel('AI Panel', "service IN $svc AND kind = 'llm'"),
},
[
variable({ name: 'svc', type: 'QUERY' }),
@@ -75,7 +102,12 @@ describe('findVariableUsages', () => {
it('finds panel (builder + promql) and variable usages, skipping unrelated ones', () => {
const usages = findVariableUsages(dash, 'svc', 'rename', 'zone');
const ids = usages.map((u) => u.id).sort();
expect(ids).toStrictEqual(['panel:p1:0', 'panel:p2:0', 'variable:other:0']);
expect(ids).toStrictEqual([
'panel:p1:0',
'panel:p2:0',
'panel:p4:0',
'variable:other:0',
]);
});
it('rewrites references for a rename across all kinds', () => {
@@ -83,6 +115,7 @@ describe('findVariableUsages', () => {
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
expect(byId['panel:p1:0']).toBe("service IN $zone AND env = 'prod'");
expect(byId['panel:p2:0']).toBe('up{s="$zone"}');
expect(byId['panel:p4:0']).toBe("service IN $zone AND kind = 'llm'");
expect(byId['variable:other:0']).toBe('SELECT x WHERE s = $zone');
});
@@ -91,6 +124,8 @@ describe('findVariableUsages', () => {
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
// Builder: the clause referencing $svc is dropped.
expect(byId['panel:p1:0']).toBe("env = 'prod'");
// The AI variant is a builder query too, so its clause is dropped the same way.
expect(byId['panel:p4:0']).toBe("kind = 'llm'");
// Raw PromQL + variable query: unchanged (user edits).
expect(byId['panel:p2:0']).toBe('up{s="$svc"}');
expect(byId['variable:other:0']).toBe('SELECT x WHERE s = $svc');
@@ -109,6 +144,7 @@ describe('findApplyUsages', () => {
has: builderPanel('Has it', 'k8s.pod.name IN $pod'),
prom: promqlPanel('Prom', 'up'),
promRef: promqlPanel('Prom Ref', 'up{pod="$pod"}'),
ai: aiPanel('AI', ''),
},
[],
);
@@ -125,6 +161,13 @@ describe('findApplyUsages', () => {
);
});
it('appends the clause to a selected AI panel', () => {
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', ['ai']);
const ai = usages.find((u) => u.id === 'panel:ai:0');
expect(ai?.kind).toBe('builder');
expect(ai?.resultingText).toBe('k8s.pod.name IN $pod');
});
it('skips a selected panel that already carries the clause (idempotent)', () => {
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', ['has']);
expect(usages).toStrictEqual([]);
@@ -182,6 +225,19 @@ describe('isVariableAppliedToAllPanels', () => {
);
});
it('is false when an AI panel is missing the reference', () => {
const missing = dashboard(
{
b: builderPanel('B', 'k8s.pod.name IN $pod'),
ai: aiPanel('AI', "kind = 'llm'"),
},
[],
);
expect(isVariableAppliedToAllPanels(missing, 'k8s.pod.name', 'pod')).toBe(
false,
);
});
it('is false when any panel query is missing the reference', () => {
const missing = dashboard(
{

View File

@@ -3,9 +3,10 @@ import type {
DashboardtypesQueryDTO,
Querybuildertypesv5BuilderQuerySpecDTO,
Querybuildertypesv5CompositeQueryDTO,
Querybuildertypesv5QueryEnvelopeBuilderDTO,
} from 'api/generated/services/sigNoz.schemas';
import { isBuilderEnvelope } from '../../../queryV5/builderEnvelope';
function clauseFor(attribute: string, variableName: string): string {
return `${attribute} IN $${variableName}`;
}
@@ -20,15 +21,15 @@ function forEachBuilderSpec(
}
if (plugin.kind === 'signoz/CompositeQuery') {
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
(composite.queries ?? [])
.filter((envelope) => envelope.type === 'builder_query')
.forEach((envelope) => {
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
if (spec) {
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
});
} else if (plugin.kind === 'signoz/BuilderQuery') {
(composite.queries ?? []).filter(isBuilderEnvelope).forEach(({ spec }) => {
if (spec) {
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
});
} else if (
plugin.kind === 'signoz/BuilderQuery' ||
plugin.kind === 'signoz/AIBuilderQuery'
) {
fn(plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
}

View File

@@ -11,6 +11,7 @@ import {
textContainsVariableReference,
} from 'lib/dashboardVariables/variableReference';
import { isBuilderEnvelope } from '../../../queryV5/builderEnvelope';
import { toQueryEnvelopes } from '../../../queryV5/buildQueryRangeRequest';
import { dtoToFormModel } from '../variableAdapters';
@@ -52,7 +53,7 @@ function envelopeReferenceText(
const spec = envelope.spec as
| { query?: string; filter?: { expression?: string } }
| undefined;
if (envelope.type === 'builder_query') {
if (isBuilderEnvelope(envelope)) {
const text = spec?.filter?.expression;
return typeof text === 'string' ? { kind: 'builder', text } : null;
}
@@ -205,7 +206,7 @@ export function findApplyUsages(
});
};
if (envelope.type === 'builder_query') {
if (isBuilderEnvelope(envelope)) {
const spec = envelope.spec as
| { filter?: { expression?: string } }
| undefined;
@@ -267,7 +268,7 @@ export function isVariableAppliedToAllPanels(
return true;
}
return toQueryEnvelopes(queries).every((envelope) => {
if (envelope.type === 'builder_query') {
if (isBuilderEnvelope(envelope)) {
const spec = envelope.spec as
| { filter?: { expression?: string } }
| undefined;

View File

@@ -10,6 +10,8 @@ interface ConfigActionsProps {
/** The draft panel — its current query seeds the actions (e.g. Create alert). */
panel: DashboardtypesPanelDTO;
panelId: string;
/** Whether the builder holds an AI query — the alert builder can't seed from one. */
isAIQuery: boolean;
}
/**
@@ -20,13 +22,15 @@ interface ConfigActionsProps {
function ConfigActions({
panel,
panelId,
isAIQuery,
}: ConfigActionsProps): JSX.Element | null {
const createAlert = useCreateAlertFromPanel();
const { actions } = getPanelDefinition(panel.spec.plugin.kind);
// Only kinds whose query can seed an alert offer this today; mirror the panel
// menu's create-alert capability.
if (!actions.createAlert) {
// menu's create-alert capability. AI queries are excluded on top of that — the
// alert builder has no AI tab, so it would seed a plain trace query instead.
if (!actions.createAlert || isAIQuery) {
return null;
}

View File

@@ -29,7 +29,7 @@ describe('ConfigActions', () => {
it('offers "Create alert rule" for a create-alert-capable kind and seeds from the panel', async () => {
const user = userEvent.setup();
const panel = makePanel('signoz/TimeSeriesPanel');
render(<ConfigActions panel={panel} panelId="panel-1" />);
render(<ConfigActions panel={panel} panelId="panel-1" isAIQuery={false} />);
const row = screen.getByTestId('panel-editor-v2-create-alert');
expect(row).toHaveTextContent('Create alert');
@@ -38,9 +38,28 @@ describe('ConfigActions', () => {
expect(mockCreateAlert).toHaveBeenCalledWith(panel, 'panel-1');
});
it('renders nothing for an AI query, even on a create-alert-capable kind', () => {
const { container } = render(
<ConfigActions
panel={makePanel('signoz/TimeSeriesPanel')}
panelId="panel-1"
isAIQuery
/>,
);
expect(
screen.queryByTestId('panel-editor-v2-create-alert'),
).not.toBeInTheDocument();
expect(container).toBeEmptyDOMElement();
});
it('renders nothing for a kind that cannot seed an alert', () => {
const { container } = render(
<ConfigActions panel={makePanel('signoz/TablePanel')} panelId="panel-1" />,
<ConfigActions
panel={makePanel('signoz/TablePanel')}
panelId="panel-1"
isAIQuery={false}
/>,
);
expect(

View File

@@ -29,6 +29,12 @@ interface ConfigPaneProps {
* spec, because a new panel's spec has no query until staged.
*/
queryType: EQueryType;
/**
* Whether the builder holds an AI query. Read from the provider alongside `queryType`
* for the same reason, and separate from it because AI-ness is a per-query tag rather
* than a query type of its own.
*/
isAIQuery: boolean;
/** Panel's resolved series, provided to sections that need them (legend colors). */
legendSeries: LegendSeries[];
/** Table panel's resolved value columns, for the table-only editors. */
@@ -56,6 +62,7 @@ function ConfigPane({
onChangeSpec,
onChangePanelKind,
queryType,
isAIQuery,
legendSeries,
tableColumns,
stepInterval,
@@ -124,6 +131,7 @@ function ConfigPane({
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
isAIQuery={isAIQuery}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
@@ -133,7 +141,7 @@ function ConfigPane({
</>
)}
<ConfigActions panel={panel} panelId={panelId} />
<ConfigActions panel={panel} panelId={panelId} isAIQuery={isAIQuery} />
</div>
);
}

View File

@@ -15,6 +15,8 @@ interface PanelTypeSwitcherProps {
queryType: EQueryType;
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
/** Whether the panel holds an AI query — disables kinds that can't carry one (List). */
isAIQuery?: boolean;
onChange: (kind: PanelKind) => void;
}
@@ -28,9 +30,10 @@ function PanelTypeSwitcher({
panelKind,
queryType,
signal,
isAIQuery,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = usePanelTypeSelectItems({ queryType, signal });
const items = usePanelTypeSelectItems({ queryType, signal, isAIQuery });
return (
<div className={styles.field}>

View File

@@ -1,10 +1,20 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { supportsAIQuery } from '../../../../Panels/capabilities';
import { getPanelTypeDisabledReason } from '../utils';
// Every kind currently declares `supportsAIQuery: true`, so the AI gate is exercised
// through the capability rather than a stand-in kind that happens to opt out.
jest.mock('../../../../Panels/capabilities', () => ({
...jest.requireActual('../../../../Panels/capabilities'),
supportsAIQuery: jest.fn(),
}));
const mockSupportsAIQuery = supportsAIQuery as jest.Mock;
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
const { logs, metrics } = TelemetrytypesSignalDTO;
const { logs, metrics, traces } = TelemetrytypesSignalDTO;
describe('getPanelTypeDisabledReason', () => {
it('returns undefined for a supported combination', () => {
@@ -70,4 +80,59 @@ describe('getPanelTypeDisabledReason', () => {
}),
).toBe("List isn't available for PromQL queries");
});
describe('AI queries', () => {
it('explains a kind that cannot carry an AI query', () => {
mockSupportsAIQuery.mockReturnValue(false);
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
signal: traces,
label: 'List',
isAIQuery: true,
}),
).toBe("List isn't available for AI queries");
});
it('allows a kind that supports AI queries', () => {
mockSupportsAIQuery.mockReturnValue(true);
expect(
getPanelTypeDisabledReason({
kind: 'signoz/TimeSeriesPanel',
queryType: QUERY_BUILDER,
signal: traces,
label: 'Time Series',
isAIQuery: true,
}),
).toBeUndefined();
});
it('takes precedence over the query-type and signal reasons', () => {
// List is otherwise valid for builder+traces, so only the AI gate can disable it.
mockSupportsAIQuery.mockReturnValue(false);
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: PROM,
signal: metrics,
label: 'List',
isAIQuery: true,
}),
).toBe("List isn't available for AI queries");
});
it('leaves the existing reasons untouched when not an AI query', () => {
mockSupportsAIQuery.mockReturnValue(false);
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
signal: traces,
label: 'List',
isAIQuery: false,
}),
).toBeUndefined();
});
});
});

View File

@@ -13,6 +13,8 @@ interface UsePanelTypeSelectItemsArgs {
queryType: EQueryType;
/** Current datasource — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
/** Whether the panel holds an AI query — kinds that can't carry one are disabled. */
isAIQuery?: boolean;
}
/**
@@ -24,6 +26,7 @@ interface UsePanelTypeSelectItemsArgs {
export function usePanelTypeSelectItems({
queryType,
signal,
isAIQuery = false,
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
return useMemo(
() =>
@@ -34,6 +37,7 @@ export function usePanelTypeSelectItems({
queryType,
signal,
label,
isAIQuery,
});
return {
value: panelKind,
@@ -43,6 +47,6 @@ export function usePanelTypeSelectItems({
tooltip: disabledReason,
};
}),
[queryType, signal],
[queryType, signal, isAIQuery],
);
}

View File

@@ -4,6 +4,7 @@ import { EQueryType } from 'types/common/dashboard';
import {
isQueryTypeSupportedByPanelKind,
isSignalSupported,
supportsAIQuery,
} from '../../../Panels/capabilities';
import type { PanelKind } from '../../../Panels/types/panelKind';
@@ -25,18 +26,27 @@ const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
* `undefined` when it can. Drives both the type switcher's disabled state and its
* tooltip, so the two never disagree. The query-type reason takes precedence (it's the
* outer choice): query types carry no signal, so the signal only matters in builder.
*
* AI is checked first of all: an AI query is a builder query on traces, so it clears
* both of the other gates on kinds that can't carry one (see `supportsAIQuery`).
*/
export function getPanelTypeDisabledReason({
kind,
queryType,
signal,
label,
isAIQuery = false,
}: {
kind: PanelKind;
queryType: EQueryType;
signal?: TelemetrytypesSignalDTO;
label: string;
/** Whether the panel currently holds an AI query. */
isAIQuery?: boolean;
}): string | undefined {
if (isAIQuery && !supportsAIQuery(kind)) {
return `${label} isn't available for AI queries`;
}
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
return `${label} isn't available for ${QUERY_TYPE_LABEL[queryType]} queries`;
}

View File

@@ -42,6 +42,7 @@ function renderConfigPane(
onChangeSpec: jest.fn(),
onChangePanelKind: jest.fn(),
queryType: EQueryType.QUERY_BUILDER,
isAIQuery: false,
legendSeries: [],
tableColumns: [],
panel: { kind: 'Panel', spec: spec() } as DashboardtypesPanelDTO,

View File

@@ -18,6 +18,8 @@ export interface SectionEditorContext {
onChangePanelKind?: (kind: PanelKind) => void;
yAxisUnit?: string;
queryType?: EQueryType;
/** Whether the panel holds an AI query — gates the panel-type switcher. */
isAIQuery?: boolean;
stepInterval?: number;
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
metricUnit?: string;

View File

@@ -16,7 +16,7 @@ import styles from './VisualizationSection.module.scss';
type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
Pick<
SectionEditorContext,
'panelKind' | 'onChangePanelKind' | 'signal' | 'queryType'
'panelKind' | 'onChangePanelKind' | 'signal' | 'queryType' | 'isAIQuery'
>;
/**
@@ -33,6 +33,7 @@ function VisualizationSection({
onChangePanelKind,
queryType,
signal,
isAIQuery,
}: VisualizationSectionProps): JSX.Element {
return (
<>
@@ -43,6 +44,7 @@ function VisualizationSection({
// supplied in practice; default to Query Builder at this boundary.
queryType={queryType ?? EQueryType.QUERY_BUILDER}
signal={signal}
isAIQuery={isAIQuery}
onChange={onChangePanelKind}
/>
)}

View File

@@ -5,7 +5,7 @@ import {
useMemo,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Atom, Terminal } from '@signozhq/icons';
import { Atom, Sparkles, Terminal } from '@signozhq/icons';
import { Tabs } from 'antd';
import cx from 'classnames';
import { Typography } from '@signozhq/ui/typography';
@@ -19,16 +19,27 @@ import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryB
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useIsAIObservabilityEnabled } from 'hooks/useIsAIObservabilityEnabled';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import {
getHiddenQueryBuilderFields,
getSupportedQueryTypes,
supportsAIQuery,
} from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../../Panels/types/panelKind';
import {
AI_QUERY_TAB,
isAIQuery,
type QueryTabKey,
resolveActiveQueryTab,
toAIQuery,
withAIQueryType,
} from './utils';
import styles from './PanelEditorQueryBuilder.module.scss';
@@ -71,12 +82,22 @@ function PanelEditorQueryBuilder({
const isListViewPanel = panelKind === 'signoz/ListPanel';
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
// The AI tab is not a query type — it stamps `builderQueryType` onto the builder
// queries (and pins them to traces, the only signal AI queries support).
const handleQueryCategoryChange = useCallback(
(queryType: string): void => {
(nextTab: string): void => {
if (nextTab === AI_QUERY_TAB) {
redirectWithQueryBuilderData({
...toAIQuery(currentQuery),
queryType: EQueryType.QUERY_BUILDER,
});
return;
}
redirectWithQueryBuilderData({
...currentQuery,
queryType: queryType as EQueryType,
...withAIQueryType(currentQuery, false),
queryType: nextTab as EQueryType,
});
},
[currentQuery, redirectWithQueryBuilderData],
@@ -103,10 +124,43 @@ function PanelEditorQueryBuilder({
[panelKind, signal],
);
// Derived to a boolean before the memo below: `currentQuery` gets a fresh identity on
// every query edit, so depending on it there would rebuild every tab's element tree
// (QueryBuilderV2 included) on each keystroke.
const hasAIQuery = isAIQuery(currentQuery);
const items = useMemo(() => {
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
const supportedQueryTypes: QueryTabKey[] = getSupportedQueryTypes(panelKind);
// The flag gates authoring, not reading: a panel that already holds an AI query
// keeps its tab when the flag is off, so an existing panel stays editable (and
// `activeKey` never points at a tab that isn't rendered) instead of opening blank.
const showAITab =
supportsAIQuery(panelKind) && (isAIObservabilityEnabled || hasAIQuery);
const supportedTabs = showAITab
? [...supportedQueryTypes, AI_QUERY_TAB]
: supportedQueryTypes;
const queryTypeComponents = {
[AI_QUERY_TAB]: {
icon: <Sparkles size={14} />,
label: 'AI Query Builder',
component: (
<div className="query-builder-v2-container">
<QueryBuilderV2
panelType={panelType}
filterConfigs={filterConfigs}
config={{
initialDataSource: DataSource.TRACES,
queryVariant: 'static',
}}
version="v3"
isListViewPanel={isListViewPanel}
queryComponents={{}}
savePreviousQuery
/>
</div>
),
},
[EQueryType.QUERY_BUILDER]: {
icon: <Atom size={14} />,
label: 'Query Builder',
@@ -141,17 +195,25 @@ function PanelEditorQueryBuilder({
},
};
return supportedQueryTypes.map((queryType) => ({
key: queryType,
return supportedTabs.map((tabKey) => ({
key: tabKey,
label: (
<div className={styles.queryTypeTab}>
{queryTypeComponents[queryType].icon}
<Typography>{queryTypeComponents[queryType].label}</Typography>
{queryTypeComponents[tabKey].icon}
<Typography>{queryTypeComponents[tabKey].label}</Typography>
</div>
),
children: queryTypeComponents[queryType].component,
children: queryTypeComponents[tabKey].component,
}));
}, [panelKind, panelType, filterConfigs, isDarkMode, isListViewPanel]);
}, [
panelKind,
panelType,
filterConfigs,
isDarkMode,
isListViewPanel,
isAIObservabilityEnabled,
hasAIQuery,
]);
return (
<div
@@ -166,7 +228,7 @@ function PanelEditorQueryBuilder({
className={cx(styles.tabsContainer, {
[styles.stickyNav]: stickyHeader,
})}
activeKey={currentQuery.queryType}
activeKey={resolveActiveQueryTab(currentQuery)}
onChange={handleQueryCategoryChange}
tabBarExtraContent={
<span className={styles.runQueryBtnContainer}>

View File

@@ -3,6 +3,7 @@ import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
@@ -13,6 +14,10 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
let mockAIObservabilityEnabled = true;
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
useIsAIObservabilityEnabled: (): boolean => mockAIObservabilityEnabled,
}));
jest.mock('components/QueryBuilderV2/QueryBuilderV2', () => ({
QueryBuilderV2: (props: unknown): null => {
mockQueryBuilderV2(props);
@@ -62,6 +67,7 @@ function lastQueryBuilderProps(): {
isListViewPanel: boolean;
showTraceOperator: boolean;
filterConfigs: unknown;
config?: unknown;
} {
const calls = mockQueryBuilderV2.mock.calls;
return calls[calls.length - 1][0];
@@ -70,20 +76,54 @@ function lastQueryBuilderProps(): {
describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities guard)', () => {
beforeEach(() => {
jest.clearAllMocks();
mockAIObservabilityEnabled = true;
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
currentQuery: {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [] },
},
redirectWithQueryBuilderData: jest.fn(),
updateAllQueriesOperators: jest.fn(),
});
});
it('shows only the Query Builder tab for the List kind', () => {
it('shows the builder tabs but no raw-query tabs for the List kind', () => {
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
expect(screen.getByText('Query Builder')).toBeInTheDocument();
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
expect(screen.queryByText('ClickHouse Query')).not.toBeInTheDocument();
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
});
it('hides the AI tab when the feature flag is off', () => {
mockAIObservabilityEnabled = false;
renderBuilder('signoz/TimeSeriesPanel');
expect(screen.getByText('Query Builder')).toBeInTheDocument();
expect(screen.queryByText('AI Query Builder')).not.toBeInTheDocument();
});
// The flag gates authoring, not reading: an existing AI panel stays editable, and
// its derived active tab always has a tab to point at.
it('keeps the AI tab with the flag off when the query already is an AI query', () => {
mockAIObservabilityEnabled = false;
mockUseQueryBuilder.mockReturnValue({
currentQuery: {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
},
redirectWithQueryBuilderData: jest.fn(),
updateAllQueriesOperators: jest.fn(),
});
renderBuilder('signoz/TimeSeriesPanel');
expect(
screen.getByRole('tab', { name: 'AI Query Builder', selected: true }),
).toBeInTheDocument();
});
it('shows Query Builder + ClickHouse but not PromQL for the Table kind', () => {
renderBuilder('signoz/TablePanel');
@@ -92,21 +132,62 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
});
it('shows all three tabs for the Time Series kind', () => {
it('shows all four tabs for the Time Series kind', () => {
renderBuilder('signoz/TimeSeriesPanel');
expect(screen.getByText('Query Builder')).toBeInTheDocument();
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
expect(screen.getByText('ClickHouse Query')).toBeInTheDocument();
expect(screen.getByText('PromQL')).toBeInTheDocument();
});
// The AI tab is derived from `builderQueryType`, not from a stored tab key.
it('activates the AI tab when the builder query carries the AI envelope tag', () => {
mockUseQueryBuilder.mockReturnValue({
currentQuery: {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
},
redirectWithQueryBuilderData: jest.fn(),
updateAllQueriesOperators: jest.fn(),
});
renderBuilder('signoz/TimeSeriesPanel');
expect(
screen.getByRole('tab', { name: 'AI Query Builder', selected: true }),
).toBeInTheDocument();
});
it('pins the AI tab builder to traces so the signal cannot be changed', () => {
mockUseQueryBuilder.mockReturnValue({
currentQuery: {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
},
redirectWithQueryBuilderData: jest.fn(),
updateAllQueriesOperators: jest.fn(),
});
renderBuilder('signoz/TimeSeriesPanel');
expect(lastQueryBuilderProps().config).toStrictEqual({
initialDataSource: DataSource.TRACES,
queryVariant: 'static',
});
});
});
describe('PanelEditorQueryBuilder field visibility (driven by the capabilities guard)', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
currentQuery: {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [] },
},
redirectWithQueryBuilderData: jest.fn(),
updateAllQueriesOperators: jest.fn(),
});
});

View File

@@ -0,0 +1,140 @@
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import {
AI_QUERY_TAB,
isAIQuery,
resolveActiveQueryTab,
toAIQuery,
withAIQueryType,
} from '../utils';
function makeQuery(
queryData: Record<string, unknown>[],
queryType: EQueryType = EQueryType.QUERY_BUILDER,
): Query {
return {
queryType,
builder: { queryData, queryFormulas: [], queryTraceOperator: [] },
promql: [],
clickhouse_sql: [],
id: 'test',
} as unknown as Query;
}
describe('isAIQuery', () => {
it('is true when any builder query carries the AI envelope tag', () => {
expect(
isAIQuery(
makeQuery([{ queryName: 'A' }, { builderQueryType: 'builder_ai_query' }]),
),
).toBe(true);
});
it('is false for plain builder queries and for an empty builder', () => {
expect(isAIQuery(makeQuery([{ queryName: 'A' }]))).toBe(false);
expect(isAIQuery(makeQuery([]))).toBe(false);
});
});
describe('resolveActiveQueryTab', () => {
it('selects the AI tab for a tagged builder query', () => {
expect(
resolveActiveQueryTab(makeQuery([{ builderQueryType: 'builder_ai_query' }])),
).toBe(AI_QUERY_TAB);
});
it('selects the query type for an untagged query', () => {
expect(resolveActiveQueryTab(makeQuery([{ queryName: 'A' }]))).toBe(
EQueryType.QUERY_BUILDER,
);
});
// A PromQL panel reads its queries from a different bucket, so a stale tag on the
// builder bucket must not steal the active tab.
it('keeps PromQL selected even if the builder bucket carries a tag', () => {
expect(
resolveActiveQueryTab(
makeQuery([{ builderQueryType: 'builder_ai_query' }], EQueryType.PROM),
),
).toBe(EQueryType.PROM);
});
});
describe('toAIQuery', () => {
// The backend decodes a builder_ai_query spec as QueryBuilderQuery[TraceAggregation],
// which has no `metricName` — a carried-over metrics aggregation fails the request.
it('re-seeds a metrics query onto traces, dropping the metric aggregation', () => {
const result = toAIQuery(
makeQuery([
{
queryName: 'A',
dataSource: DataSource.METRICS,
aggregations: [{ metricName: 'signoz_latency_bucket' }],
},
]),
);
const [queryData] = result.builder.queryData;
expect(queryData.dataSource).toBe(DataSource.TRACES);
expect(queryData.aggregations).toStrictEqual([{ expression: 'count() ' }]);
expect(queryData.builderQueryType).toBe('builder_ai_query');
});
it('keeps the filter on a query already using traces', () => {
const result = toAIQuery(
makeQuery([
{
queryName: 'A',
dataSource: DataSource.TRACES,
filter: { expression: "service.name = 'checkout'" },
},
]),
);
expect(result.builder.queryData[0].filter).toStrictEqual({
expression: "service.name = 'checkout'",
});
expect(result.builder.queryData[0].builderQueryType).toBe('builder_ai_query');
});
it('preserves the query name when re-seeding', () => {
const result = toAIQuery(
makeQuery([{ queryName: 'B', dataSource: DataSource.LOGS }]),
);
expect(result.builder.queryData[0].queryName).toBe('B');
});
});
describe('withAIQueryType', () => {
it('stamps the tag onto every builder query', () => {
const result = withAIQueryType(
makeQuery([{ queryName: 'A' }, { queryName: 'B' }]),
true,
);
expect(
result.builder.queryData.map((item) => item.builderQueryType),
).toStrictEqual(['builder_ai_query', 'builder_ai_query']);
});
it('deletes the key when clearing, rather than setting undefined', () => {
const result = withAIQueryType(
makeQuery([{ queryName: 'A', builderQueryType: 'builder_ai_query' }]),
false,
);
expect(result.builder.queryData[0]).not.toHaveProperty('builderQueryType');
expect(result.builder.queryData[0]).toStrictEqual({ queryName: 'A' });
});
it('returns the query untouched when it already matches', () => {
const tagged = makeQuery([{ builderQueryType: 'builder_ai_query' }]);
const plain = makeQuery([{ queryName: 'A' }]);
expect(withAIQueryType(tagged, true)).toBe(tagged);
expect(withAIQueryType(plain, false)).toBe(plain);
});
});

View File

@@ -0,0 +1,91 @@
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
/**
* Tab key for the AI query builder. Deliberately not an `EQueryType`: an AI query is
* a builder query carrying `builderQueryType: 'builder_ai_query'`, so the query type
* on the wire stays `builder` and only the per-query envelope tag differs. Keeping the
* tab out of the enum leaves that tag the single source of truth.
*/
export const AI_QUERY_TAB = 'ai_builder' as const;
export type QueryTabKey = EQueryType | typeof AI_QUERY_TAB;
export function isAIQuery(query: Query): boolean {
return query.builder.queryData.some(
(item) => item.builderQueryType === 'builder_ai_query',
);
}
/** The tab to highlight — derived from the queries, never stored separately. */
export function resolveActiveQueryTab(query: Query): QueryTabKey {
return query.queryType === EQueryType.QUERY_BUILDER && isAIQuery(query)
? AI_QUERY_TAB
: query.queryType;
}
/** Carried across a signal switch, mirroring the builder's own datasource selector. */
const PRESERVED_ON_SIGNAL_SWITCH = ['queryName', 'expression'];
/**
* Re-seed a non-traces query with the traces defaults, the way `handleChangeDataSource`
* does. AI queries are traces-only, and a leftover metrics aggregation carries
* `metricName` — a field the backend rejects on a trace spec. A query already on traces
* keeps its filters, so switching tabs on a trace query is non-destructive.
*/
function toTracesQueryData(item: IBuilderQuery): IBuilderQuery {
if (item.dataSource === DataSource.TRACES) {
return item;
}
const tracesDefaults = Object.fromEntries(
Object.entries(initialQueryBuilderFormValuesMap[DataSource.TRACES]).filter(
([key]) => !PRESERVED_ON_SIGNAL_SWITCH.includes(key),
),
);
return { ...item, ...tracesDefaults, dataSource: DataSource.TRACES };
}
/** Move a query onto the AI builder: pin every query to traces and tag it. */
export function toAIQuery(query: Query): Query {
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((item) => ({
...toTracesQueryData(item),
builderQueryType: 'builder_ai_query' as const,
})),
},
};
}
/**
* Stamp or clear `builderQueryType` across every builder query. Returns the query
* untouched when it already matches, and deletes the key rather than setting it to
* `undefined` — the dirty checks compare by value, so a stray key reads as an edit.
*/
export function withAIQueryType(query: Query, enabled: boolean): Query {
const needsUpdate = query.builder.queryData.some(
(item) => (item.builderQueryType === 'builder_ai_query') !== enabled,
);
if (!needsUpdate) {
return query;
}
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((item): IBuilderQuery => {
const { builderQueryType: _dropped, ...rest } = item;
return enabled ? { ...rest, builderQueryType: 'builder_ai_query' } : rest;
}),
},
};
}

View File

@@ -24,6 +24,10 @@ const mockOnChangePanelKind = jest.fn();
const mockSave = jest.fn().mockResolvedValue('panel-1');
const mockUseDraft = jest.fn();
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
useIsAIObservabilityEnabled: (): boolean => false,
}));
jest.mock('../hooks/usePanelEditorDraft', () => ({
usePanelEditorDraft: (panel: unknown): unknown => mockUseDraft(panel),
}));
@@ -70,7 +74,9 @@ jest.mock('../hooks/useSeedMetricUnit', () => ({
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({ currentQuery: { queryType: 'builder' } }),
useQueryBuilder: (): unknown => ({
currentQuery: { queryType: 'builder', builder: { queryData: [] } },
}),
}));
jest.mock(
'../../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions',

View File

@@ -21,6 +21,10 @@ import { usePanelEditorQuerySync } from '../hooks/usePanelEditorQuerySync';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
useIsAIObservabilityEnabled: (): boolean => false,
}));
jest.mock('hooks/useSafeNavigate', () => {
const { useHistory: useRouterHistory } =
jest.requireActual('react-router-dom');

View File

@@ -5,7 +5,10 @@ import { handleQueryChange } from 'lib/query/panelQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../../Panels/capabilities';
import {
resolveQueryType,
supportsAIQuery,
} from '../../../Panels/capabilities';
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
@@ -19,6 +22,7 @@ jest.mock('lib/query/panelQuery', () => ({
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryType: jest.fn(),
supportsAIQuery: jest.fn(),
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
toPerses: jest.fn(),
@@ -33,6 +37,7 @@ jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
const mockResolveQueryType = resolveQueryType as unknown as jest.Mock;
const mockSupportsAIQuery = supportsAIQuery 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;
@@ -96,7 +101,11 @@ describe('usePanelTypeSwitch', () => {
it('does nothing when switching to the current kind', () => {
const setSpec = jest.fn();
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
const state = builderState({
id: 'q',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
@@ -114,7 +123,11 @@ describe('usePanelTypeSwitch', () => {
it('on first visit: transforms the query and resets the spec to the new kind', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const tableQuery = {
id: 'table-current',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
const state = builderState(tableQuery);
mockUseQueryBuilder.mockReturnValue(state);
@@ -142,7 +155,11 @@ describe('usePanelTypeSwitch', () => {
it('seeds timestamp-desc Order By on every query when switching to a List panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
builderState({
id: 'ts-current',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -169,7 +186,11 @@ describe('usePanelTypeSwitch', () => {
it('coerces the query type when the new kind disallows it (promql → List)', () => {
const setSpec = jest.fn();
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
const promQuery = {
id: 'prom',
queryType: 'promql',
builder: { queryData: [] },
} as unknown as Query;
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
const { result } = renderHook(() =>
@@ -191,10 +212,88 @@ describe('usePanelTypeSwitch', () => {
expect((queryArg as Query).queryType).toBe('builder');
});
// `handleQueryChange` rebuilds from a field allow-list that omits `builderQueryType`,
// so the tag has to be re-applied after the rebuild or the AI tab silently reverts.
it('re-applies the AI envelope tag when the new kind supports AI queries', () => {
const setSpec = jest.fn();
mockSupportsAIQuery.mockReturnValue(true);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
queryType: 'builder',
builder: { queryData: [{ orderBy: [] }] },
} as unknown as Query);
const aiQuery = {
id: 'ai-current',
queryType: 'builder',
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
} as unknown as Query;
const state = builderState(aiQuery);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
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',
);
});
it('drops the AI envelope tag when the new kind has no AI tab', () => {
const setSpec = jest.fn();
mockSupportsAIQuery.mockReturnValue(false);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
queryType: 'builder',
builder: { queryData: [{ orderBy: [] }] },
} as unknown as Query);
const aiQuery = {
id: 'ai-current',
queryType: 'builder',
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
} as unknown as Query;
const state = builderState(aiQuery);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// The rebuild receives an untagged query…
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
expect((queryArg as Query).builder.queryData[0]).not.toHaveProperty(
'builderQueryType',
);
// …and nothing re-applies it afterwards.
const redirected = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(redirected.builder.queryData[0].builderQueryType).toBeUndefined();
});
it('restores the original kind verbatim on switch-back (reversibility)', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
const tableQuery = {
id: 'table-current',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
const listQuery = {
id: 'list-current',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
let state = builderState(tableQuery);
mockUseQueryBuilder.mockImplementation(() => state);

View File

@@ -18,7 +18,7 @@ import type {
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../Panels/capabilities';
import { resolveQueryType, supportsAIQuery } from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
@@ -29,6 +29,7 @@ import {
getSwitchedPluginSpec,
type SwitchedPluginSpec,
} from '../getSwitchedPluginSpec';
import { isAIQuery, withAIQueryType } from '../PanelEditorQueryBuilder/utils';
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
@@ -139,16 +140,24 @@ export function usePanelTypeSwitch({
// 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);
// AI-ness rides on the query, not on `queryType`, so `resolveQueryType` can't
// see it: carry it across only when the new kind has an AI tab to surface it.
const keepAIQueryType = supportsAIQuery(newKind) && isAIQuery(query);
const transformed = handleQueryChange(
newPanelType as keyof PartialPanelTypes,
{ ...query, queryType },
{ ...withAIQueryType(query, false), queryType },
panelTypeRef.current,
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
const nextQuery =
const reordered =
newKind === 'signoz/ListPanel'
? withDefaultListOrder(transformed)
: transformed;
// `handleQueryChange` rebuilds each query from an allow-list of fields that
// doesn't include `builderQueryType`, so re-stamp it after the rebuild.
const nextQuery = keepAIQueryType
? withAIQueryType(reordered, true)
: reordered;
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;

View File

@@ -28,6 +28,7 @@ import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import layoutStorage from './layoutStorage';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import { isAIQuery } from './PanelEditorQueryBuilder/utils';
import PreviewPane from './PreviewPane/PreviewPane';
import { useLegendSeries } from './hooks/useLegendSeries';
import { usePanelEditSession } from './hooks/usePanelEditSession';
@@ -353,6 +354,7 @@ function PanelEditorContainer({
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={currentQuery.queryType}
isAIQuery={isAIQuery(currentQuery)}
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}

View File

@@ -39,6 +39,15 @@ export function isQueryTypeSupportedByPanelKind(
return getSupportedQueryTypes(kind).includes(queryType);
}
/**
* Whether a kind offers the AI query builder. Separate from `supportedQueryTypes`
* because an AI query is a builder query carrying `builderQueryType`, not its own
* `EQueryType` — the tab is UI state, the wire type stays `builder`.
*/
export function supportsAIQuery(kind: PanelKind): boolean {
return getPanelDefinition(kind).supportsAIQuery === true;
}
/**
* Master guard: is this panel kind renderable with this query type (and, in builder
* mode, this signal)? ClickHouse/PromQL queries carry no signal, so the signal is

View File

@@ -22,6 +22,7 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportsAIQuery: true,
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

@@ -22,6 +22,7 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportsAIQuery: true,
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

@@ -22,6 +22,9 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
// hide `limit` (the server paginates raw spans). Mirrors QueryBuilderV2's internal
// list configs — the capabilities guard is the single source for both.
supportedQueryTypes: [EQueryType.QUERY_BUILDER],
// List is the one kind the backend accepts only as a bare query plugin, so its AI
// queries carry the envelope tag in the plugin kind (`signoz/AIBuilderQuery`).
supportsAIQuery: true,
queryBuilderFields: {
default: {
stepInterval: { isHidden: true, isDisabled: true },

View File

@@ -22,6 +22,7 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportsAIQuery: true,
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,

View File

@@ -18,6 +18,7 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
supportsAIQuery: true,
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,

View File

@@ -18,6 +18,7 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
supportsAIQuery: true,
queryBuilderFields: {},
// The only kind that asks the server to transpose its scalar result into UI rows.
queryCapabilities: {

View File

@@ -22,6 +22,7 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportsAIQuery: true,
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,

View File

@@ -69,6 +69,8 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
supportedSignals: TelemetrytypesSignalDTO[];
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
supportedQueryTypes: EQueryType[];
/** Kind offers the AI query builder — a traces-only builder variant, not its own query language. */
supportsAIQuery?: boolean;
/** Query-builder fields this kind hides/disables, optionally per signal (`{}` hides none). */
queryBuilderFields: QueryBuilderFieldRule;
/** How this kind's query-range request is shaped (request type, paging, result formatting). */

View File

@@ -4,9 +4,11 @@ import type {
} from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import { isBuilderEnvelope } from '../../queryV5/builderEnvelope';
/**
* Flattens a panel's queries into its builder queries, unwrapping
* `CompositeQuery` envelopes. Non-builder kinds (PromQL, ClickHouseSQL, Formula,
* Flattens a panel's queries into its builder queries (`builder_query` and its AI
* variant), unwrapping `CompositeQuery` envelopes. Non-builder kinds (PromQL, ClickHouseSQL, Formula,
* TraceOperator) are dropped — they lack the legend/groupBy/aggregation context
* downstream code needs. Returns the generated v5 `BuilderQuery` shape directly.
*/
@@ -16,13 +18,16 @@ export function getBuilderQueries(
const flattened: BuilderQuery[] = [];
queries.forEach((envelope) => {
const plugin = envelope.spec.plugin;
if (plugin.kind === 'signoz/BuilderQuery') {
if (
plugin.kind === 'signoz/BuilderQuery' ||
plugin.kind === 'signoz/AIBuilderQuery'
) {
flattened.push(plugin.spec as BuilderQuery);
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
(plugin.spec.queries || []).forEach((sub) => {
if (sub.type === 'builder_query') {
if (isBuilderEnvelope(sub)) {
flattened.push(sub.spec as BuilderQuery);
}
});

View File

@@ -0,0 +1,12 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { isAIBuilderEnvelope } from '../../queryV5/builderEnvelope';
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
/**
* Whether a saved panel holds an AI query. Read from the panel spec rather than the
* query-builder provider, for the dashboard-grid actions that run outside the editor.
*/
export function panelHasAIQuery(panel: DashboardtypesPanelDTO): boolean {
return toQueryEnvelopes(panel.spec.queries).some(isAIBuilderEnvelope);
}

View File

@@ -410,4 +410,37 @@ describe('usePanelActionItems', () => {
(createAlert as { onClick: () => void }).onClick();
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
});
// The alert builder has no AI tab, so seeding it would hand back a plain trace query.
it('disables create-alert on a panel holding an AI query', () => {
const aiPanel = {
kind: 'Panel',
spec: {
display: { name: 'AI' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [
{
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 createAlert = result.current.items.find(
(i) => 'key' in i && i.key === 'create-alert',
);
expect((createAlert as { disabled?: boolean }).disabled).toBe(true);
});
});

View File

@@ -15,6 +15,7 @@ import {
useConfirmableAction,
} from 'hooks/useConfirmableAction';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { panelHasAIQuery } from 'pages/DashboardPage/DashboardContainer/Panels/utils/panelHasAIQuery';
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
@@ -33,6 +34,9 @@ import { PANEL_ACTION_META } from './panelActionMeta';
import DisabledMenuItemLabel from '../../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
import { DASHBOARD_LOCKED_REASON } from '../../../hooks/useDashboardEditGuard';
const ALERT_FROM_AI_PANEL_REASON =
'Alerts can\u2019t be created from AI queries yet';
// Stable fallback so renders without layout context don't churn the mutation
// hooks' deps (a fresh [] each render would re-create their callbacks).
const EMPTY_SECTIONS: DashboardSection[] = [];
@@ -89,6 +93,7 @@ export function usePanelActionItems({
const clonePanel = useClonePanel({ sections });
const panelCapabilities = getPanelDefinition(panelKind).actions;
const isAIPanel = panelHasAIQuery(panel);
const downloadItem = useDownloadPanelMenuItem({
panelId,
panel,
@@ -166,12 +171,21 @@ export function usePanelActionItems({
}
// Create Alerts opens a new tab and never mutates the dashboard, so —
// unlike edit/clone — it isn't gated on editability (V1 parity).
// unlike edit/clone — it isn't gated on editability (V1 parity). AI queries are
// the exception: the alert builder has no AI tab, so seeding it from one would
// silently hand back a plain trace query.
if (panelCapabilities.createAlert) {
dataGroup.push({
key: 'create-alert',
label: 'Create Alerts',
label: isAIPanel ? (
<DisabledMenuItemLabel reason={ALERT_FROM_AI_PANEL_REASON}>
Create Alerts
</DisabledMenuItemLabel>
) : (
'Create Alerts'
),
icon: <Bell size={14} />,
disabled: isAIPanel,
onClick: (): void => createAlert(panel, panelId),
});
}
@@ -227,6 +241,7 @@ export function usePanelActionItems({
sections,
panelId,
downloadItem,
isAIPanel,
openView,
openPanelEditor,
createAlert,

View File

@@ -54,6 +54,7 @@ function ViewPanelModalContent({
panelDefinition,
signal,
queryType,
isAIQuery,
query,
runQuery,
onChangePanelKind,
@@ -147,6 +148,7 @@ function ViewPanelModalContent({
onSwitchToEdit={onSwitchToEdit}
panelKind={draft.spec.plugin.kind}
queryType={queryType}
isAIQuery={isAIQuery}
signal={signal}
onChangePanelKind={onChangePanelKind}
onResetQuery={resetQuery}

View File

@@ -38,6 +38,8 @@ interface ViewPanelModalHeaderProps {
queryType: EQueryType;
/** Current builder datasource — greys out kinds that don't support it (e.g. List needs logs/traces, not metrics). */
signal: TelemetrytypesSignalDTO;
/** Whether the panel holds an AI query — disables kinds that can't carry one. */
isAIQuery: boolean;
onChangePanelKind: (kind: PanelKind) => void;
/** Restore the saved query + kind (drilldown reset). */
onResetQuery: () => void;
@@ -59,12 +61,17 @@ function ViewPanelModalHeader({
panelKind,
queryType,
signal,
isAIQuery,
onChangePanelKind,
onResetQuery,
}: ViewPanelModalHeaderProps): JSX.Element {
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
const panelTypeItems = usePanelTypeSelectItems({ queryType, signal });
const panelTypeItems = usePanelTypeSelectItems({
queryType,
signal,
isAIQuery,
});
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
const isLocked = useDashboardStore((s) => s.isLocked);

View File

@@ -18,6 +18,7 @@ import {
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { buildViewPanelSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
import { isAIQuery } from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/utils';
import { fromPerses } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
import {
type PanelQueryTimeOverride,
@@ -50,6 +51,8 @@ export interface UseViewPanelModeReturn {
signal: TelemetrytypesSignalDTO;
/** Active query type (selected builder tab) — drives the panel-type selector's disabled rule. */
queryType: EQueryType;
/** Whether the panel holds an AI query — gates the header's kind selector. */
isAIQuery: boolean;
/** Query result for the draft over the per-view window. */
query: UsePanelQueryResult;
/** Stage & run the live builder query into the draft (drilldown; not persisted). */
@@ -176,6 +179,7 @@ export function useViewPanelMode({
panelDefinition,
signal,
queryType: currentQuery.queryType,
isAIQuery: isAIQuery(currentQuery),
query,
runQuery,
onChangePanelKind,

View File

@@ -46,6 +46,10 @@ beforeAll(() => {
});
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
useIsAIObservabilityEnabled: (): boolean => false,
}));
jest.mock('hooks/useSafeNavigate', () =>
jest
.requireActual('tests/browser-history-safe-navigate')

View File

@@ -16,6 +16,10 @@ import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
import { useViewPanel } from '../hooks/useViewPanel';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
useIsAIObservabilityEnabled: (): boolean => false,
}));
jest.mock('hooks/useSafeNavigate', () =>
jest
.requireActual('tests/browser-history-safe-navigate')

View File

@@ -25,6 +25,17 @@ function bareBuilderQuery(
] as unknown as DashboardtypesQueryDTO[];
}
function bareAIBuilderQuery(
spec: Record<string, unknown>,
): DashboardtypesQueryDTO[] {
return [
{
kind: 'raw',
spec: { plugin: { kind: 'signoz/AIBuilderQuery', spec } },
},
] as unknown as DashboardtypesQueryDTO[];
}
function compositeQuery(
envelopes: Record<string, unknown>[],
): DashboardtypesQueryDTO[] {
@@ -94,6 +105,15 @@ describe('toQueryEnvelopes', () => {
]);
});
it('wraps a bare AIBuilderQuery into a single builder_ai_query envelope', () => {
const envelopes = toQueryEnvelopes(
bareAIBuilderQuery({ name: 'A', signal: 'traces' }),
);
expect(envelopes).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' } },
@@ -304,6 +324,23 @@ describe('buildQueryRangeRequest', () => {
);
});
it('injects the BAR stepInterval into builder_ai_query envelopes too', () => {
const request = buildQueryRangeRequest({
queries: compositeQuery([
{ type: 'builder_ai_query', spec: { name: 'A', signal: 'traces' } },
]),
queryCapabilities: BAR_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
const envelope = request.compositeQuery?.queries?.[0];
const spec = (envelope?.spec ?? {}) as { stepInterval?: number };
expect(spec.stepInterval).toBe(
getBarStepIntervalSeconds(START_MS, START_MS + HOUR_MS),
);
expect(envelope?.type).toBe('builder_ai_query');
});
it('preserves a user-set stepInterval on BAR builder queries', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', stepInterval: 300 }),

View File

@@ -2,7 +2,11 @@ import type {
DashboardtypesQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import {
initialQueriesMap,
initialQueryAIWithType,
PANEL_TYPES,
} from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
@@ -193,6 +197,57 @@ describe('persesQueryAdapters', () => {
);
});
it('preserves an AI builder query through toPerses → fromPerses', () => {
const original: Query = initialQueryAIWithType;
const perses = toPerses(original, PANEL_TYPES.TIME_SERIES);
const { queries } = perses[0].spec.plugin.spec as {
queries: Querybuildertypesv5QueryEnvelopeDTO[];
};
expect(queries[0].type).toBe('builder_ai_query');
const restored = fromPerses(perses, PANEL_TYPES.TIME_SERIES);
expect(restored.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(restored.builder.queryData[0].builderQueryType).toBe(
'builder_ai_query',
);
});
it('keeps builder_ai_query on a second save with no edits', () => {
// The silent-downgrade catcher: an untouched AI panel that reloads and saves
// again must not come back as a plain trace query.
const saved = toPerses(initialQueryAIWithType, PANEL_TYPES.TIME_SERIES);
const resaved = toPerses(
fromPerses(saved, PANEL_TYPES.TIME_SERIES),
PANEL_TYPES.TIME_SERIES,
);
const { queries } = resaved[0].spec.plugin.spec as {
queries: Querybuildertypesv5QueryEnvelopeDTO[];
};
expect(queries[0].type).toBe('builder_ai_query');
});
it('emits a bare signoz/AIBuilderQuery for a List panel holding an AI query', () => {
const result = toPerses(initialQueryAIWithType, PANEL_TYPES.LIST);
expect(result).toHaveLength(1);
expect(result[0].kind).toBe('raw');
expect(result[0].spec.plugin.kind).toBe('signoz/AIBuilderQuery');
});
it('preserves a List AI query through toPerses → fromPerses', () => {
// The bare plugin has no envelope tag, so the kind is the only thing carrying
// it — a downgrade to signoz/BuilderQuery would reload as a plain trace query.
const perses = toPerses(initialQueryAIWithType, PANEL_TYPES.LIST);
const restored = fromPerses(perses, PANEL_TYPES.LIST);
expect(restored.builder.queryData[0].builderQueryType).toBe(
'builder_ai_query',
);
});
it('preserves a List builder query through toPerses → fromPerses', () => {
const original: Query = initialQueriesMap[DataSource.LOGS];

View File

@@ -5,12 +5,14 @@ import type {
Querybuildertypesv5CompositeQueryDTO,
Querybuildertypesv5OrderByDTO,
Querybuildertypesv5PromQueryDTO,
Querybuildertypesv5QueryEnvelopeBuilderAIDTO,
Querybuildertypesv5QueryEnvelopeDTO,
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5QueryRangeRequestDTOVariables,
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5OrderDirectionDTO,
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
@@ -18,6 +20,8 @@ import {
import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities';
import { isBuilderEnvelope, withBuilderSpec } from './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.
interface QuerySpecView {
@@ -58,6 +62,19 @@ export function toQueryEnvelopes(
spec: plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO,
},
];
case 'signoz/AIBuilderQuery':
// The bare AI plugin carries its envelope tag in the plugin kind rather than
// the spec — the only way an AI query survives a round trip through a panel
// that can't wrap it in a CompositeQuery (List). Orval mints the plugin and
// envelope spec as separate types with their own single-member `signal` enum,
// so the (identical) shapes cross over through `unknown`.
return [
{
type: Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query,
spec:
plugin.spec as unknown as Querybuildertypesv5QueryEnvelopeBuilderAIDTO['spec'],
},
];
case 'signoz/PromQLQuery':
return [
{
@@ -125,22 +142,13 @@ function withBarStepInterval(
): Querybuildertypesv5QueryEnvelopeDTO[] {
const stepInterval = getBarStepIntervalSeconds(startMs, endMs);
return envelopes.map((envelope) => {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
if (!isBuilderEnvelope(envelope)) {
return envelope;
}
if (envelope.spec?.stepInterval) {
return envelope;
}
return {
...envelope,
spec: {
...envelope.spec,
stepInterval,
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
return withBuilderSpec(envelope, { stepInterval });
});
}
@@ -153,10 +161,7 @@ function withListOrderTiebreaker(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
): Querybuildertypesv5QueryEnvelopeDTO[] {
return envelopes.map((envelope) => {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
if (!isBuilderEnvelope(envelope)) {
return envelope;
}
const spec = envelope.spec as QuerySpecView;
@@ -173,16 +178,12 @@ function withListOrderTiebreaker(
direction: Querybuildertypesv5OrderDirectionDTO.desc,
},
];
return {
...envelope,
spec: {
...envelope.spec,
order: [
...primary,
{ key: { name: 'id' }, direction: primary[0].direction },
],
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
return withBuilderSpec(envelope, {
order: [
...primary,
{ key: { name: 'id' }, direction: primary[0].direction },
],
});
});
}
@@ -195,20 +196,10 @@ function withPagination(
{ offset, limit }: { offset: number; limit: number },
): Querybuildertypesv5QueryEnvelopeDTO[] {
return envelopes.map((envelope) => {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
if (!isBuilderEnvelope(envelope)) {
return envelope;
}
return {
...envelope,
spec: {
...envelope.spec,
offset,
limit,
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
return withBuilderSpec(envelope, { offset, limit });
});
}
@@ -301,11 +292,7 @@ export function hasRunnableQueries(queries: DashboardtypesQueryDTO[]): boolean {
}
const metricsSpecs = envelopes
.filter(
(envelope) =>
envelope.type ===
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
)
.filter(isBuilderEnvelope)
.map((envelope) => envelope.spec as QuerySpecView)
.filter((spec) => spec.signal === 'metrics');

View File

@@ -0,0 +1,72 @@
import type {
Querybuildertypesv5OrderByDTO,
Querybuildertypesv5QueryEnvelopeBuilderAIDTO,
Querybuildertypesv5QueryEnvelopeBuilderDTO,
Querybuildertypesv5QueryEnvelopeDTO,
Querybuildertypesv5StepDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
} from 'api/generated/services/sigNoz.schemas';
/**
* A builder query on the wire, in either flavour. `builder_ai_query` is the same
* builder spec pinned to traces — it differs from `builder_query` only in the
* envelope tag, so every "is this a builder query?" decision must accept both.
*/
export type BuilderEnvelope =
| Querybuildertypesv5QueryEnvelopeBuilderDTO
| Querybuildertypesv5QueryEnvelopeBuilderAIDTO;
export const BUILDER_ENVELOPE_TYPES: readonly Querybuildertypesv5QueryEnvelopeDTO['type'][] =
[
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query,
];
/**
* The single predicate for "this envelope carries a builder query". Use it instead of
* comparing `type` to the `builder_query` literal: a bare literal silently excludes AI
* queries, which is how an AI panel ends up with no legends, no signal and an
* un-widened bar step interval.
*/
export function isBuilderEnvelope(
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): envelope is BuilderEnvelope {
return BUILDER_ENVELOPE_TYPES.includes(envelope.type);
}
/** Whether an envelope is specifically the AI flavour (traces-only, AI-authored). */
export function isAIBuilderEnvelope(
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): envelope is Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
return (
envelope.type ===
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query
);
}
/**
* The request-time fields the query-range builder stamps onto a builder spec. Orval
* types the AI envelope's `spec` as the traces aggregation alone and the plain one as
* the three-signal union, so a spec rebuilt in place fits neither without narrowing —
* this is the shared shape of what those rewrites actually write.
*/
export interface BuilderSpecPatch {
stepInterval?: Querybuildertypesv5StepDTO;
order?: Querybuildertypesv5OrderByDTO[] | null;
offset?: number;
limit?: number;
}
/**
* Merges `patch` into a builder envelope's spec, preserving the envelope's own type —
* so a `builder_ai_query` stays one instead of being flattened back to `builder_query`.
*/
export function withBuilderSpec<T extends BuilderEnvelope>(
envelope: T,
patch: BuilderSpecPatch,
): T {
return { ...envelope, spec: { ...envelope.spec, ...patch } } as T;
}

View File

@@ -1,13 +1,15 @@
import type {
DashboardtypesAIBuilderQuerySpecDTO,
DashboardtypesBuilderQuerySpecDTO,
DashboardtypesQueryDTO,
DashboardtypesQueryPluginDTO,
Querybuildertypesv5CompositeQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind as AIBuilderQueryPluginKind,
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind as BuilderQueryPluginKind,
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind as CompositeQueryPluginKind,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
Querybuildertypesv5RequestTypeDTO,
@@ -21,6 +23,11 @@ import type { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import {
type BuilderEnvelope,
isAIBuilderEnvelope,
isBuilderEnvelope,
} from './builderEnvelope';
import { toQueryEnvelopes } from './buildQueryRangeRequest';
/**
@@ -44,10 +51,27 @@ const toGeneratedEnvelopes = (
): Querybuildertypesv5QueryEnvelopeDTO[] =>
envelopes as unknown as Querybuildertypesv5QueryEnvelopeDTO[];
const isBuilderQueryEnvelope = (
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): boolean =>
envelope.type === Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query;
/**
* The bare plugin a builder envelope serializes to. The AI flavour gets its own kind
* because a bare plugin carries no envelope tag — dropped to `signoz/BuilderQuery` it
* would reload as a plain trace query. Orval mints the plugin and envelope spec as
* separate types with their own single-member `signal` enum, so the (identical) AI
* shapes cross over through `unknown`.
*/
const toBareBuilderPlugin = (
envelope: BuilderEnvelope,
): DashboardtypesQueryPluginDTO =>
isAIBuilderEnvelope(envelope)
? {
kind: AIBuilderQueryPluginKind['signoz/AIBuilderQuery'],
spec: envelope.spec as unknown as DashboardtypesAIBuilderQuerySpecDTO,
}
: {
kind: BuilderQueryPluginKind['signoz/BuilderQuery'],
// The generated envelope union doesn't discriminate `spec` by `type`, so
// narrow the builder query to the dashboard builder spec.
spec: envelope.spec as DashboardtypesBuilderQuerySpecDTO,
};
/**
* Clears the V1 explorer's `pageSize`/`offset` before conversion — the shared mapper folds
@@ -170,21 +194,14 @@ export function toPerses(
const envelopes = toGeneratedEnvelopes(composite.queries ?? []);
if (panelType === PANEL_TYPES.LIST) {
const builder = envelopes.find(isBuilderQueryEnvelope);
const builder = envelopes.find(isBuilderEnvelope);
if (!builder) {
return [];
}
return [
{
kind: panelTypeToRequestType(panelType),
spec: {
plugin: {
kind: BuilderQueryPluginKind['signoz/BuilderQuery'],
// The generated envelope union doesn't discriminate `spec` by `type`, so
// narrow the filtered builder query to the dashboard builder spec.
spec: builder.spec as DashboardtypesBuilderQuerySpecDTO,
},
},
spec: { plugin: toBareBuilderPlugin(builder) },
},
];
}

View File

@@ -3,11 +3,9 @@ import type {
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5ScalarDataDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
} from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType } from 'api/generated/services/sigNoz.schemas';
import { isBuilderEnvelope } from './builderEnvelope';
import type { PanelTable, PanelTableColumn } from './types';
// Narrow view over a builder-query aggregation; envelope spec is `unknown`, so naming reads
@@ -28,10 +26,7 @@ export function extractAggregationsPerQuery(
): AggregationsPerQuery {
const perQuery: AggregationsPerQuery = {};
(requestPayload?.compositeQuery?.queries ?? []).forEach((envelope) => {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
if (!isBuilderEnvelope(envelope)) {
return;
}
const spec = envelope.spec;

View File

@@ -141,6 +141,7 @@ func (d *DashboardSpec) validateQuery(qi int, q Query, panelKind PanelPluginKind
func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
compositeSubQueryTypeToPluginKind := map[qb.QueryType]QueryPluginKind{
qb.QueryTypeBuilder: QueryKindBuilder,
qb.QueryTypeBuilderAI: QueryKindAIBuilder,
qb.QueryTypeFormula: QueryKindFormula,
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
qb.QueryTypePromQL: QueryKindPromQL,

View File

@@ -117,6 +117,22 @@ func TestNewStatsFromStorableDashboardsCountsCompositeSubQueries(t *testing.T) {
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
}
// An AI builder query is always a traces query, so it counts towards traces.
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
aiBuilder := `{
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/AIBuilderQuery", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}}}
}`
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(aiBuilder))
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
assert.Equal(t, int64(1), stats[statKeyPanelCount])
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
}
// promql and clickhouse queries carry no signal, so they land in the panel total
// and nowhere else.
func TestNewStatsFromStorableDashboardsIgnoresSignallessQueries(t *testing.T) {

View File

@@ -1638,6 +1638,8 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
{"TimeSeries+PromQL", mkQuery("signoz/TimeSeriesPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), false},
{"Table+ClickHouse", mkQuery("signoz/TablePanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
{"List+Builder", mkQuery("signoz/ListPanel", "signoz/BuilderQuery", `{"name":"A","signal":"logs"}`), false},
{"TimeSeries+AIBuilder", mkQuery("signoz/TimeSeriesPanel", "signoz/AIBuilderQuery", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
{"List+AIBuilder", mkQuery("signoz/ListPanel", "signoz/AIBuilderQuery", `{"name":"A"}`), false},
// Top-level: rejected
{"Table+PromQL", mkQuery("signoz/TablePanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
{"List+ClickHouse", mkQuery("signoz/ListPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), true},
@@ -1647,6 +1649,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
// Composite sub-queries
{"Table+Composite(promql)", mkComposite("signoz/TablePanel", "promql", `{"name":"A","query":"up"}`), true},
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
{"Table+Composite(builder_ai)", mkComposite("signoz/TablePanel", "builder_ai_query", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
}
for _, tc := range cases {

View File

@@ -89,6 +89,7 @@ type QueryPlugin struct {
func (QueryPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(QueryKindBuilder): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec"),
string(QueryKindAIBuilder): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec"),
string(QueryKindComposite): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery"),
string(QueryKindFormula): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula"),
string(QueryKindPromQL): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery"),
@@ -118,6 +119,7 @@ func (p *QueryPlugin) UnmarshalJSON(data []byte) error {
func (QueryPlugin) JSONSchemaOneOf() []any {
return []any{
QueryPluginVariant[BuilderQuerySpec]{Kind: string(QueryKindBuilder)},
QueryPluginVariant[AIBuilderQuerySpec]{Kind: string(QueryKindAIBuilder)},
QueryPluginVariant[CompositeQuerySpec]{Kind: string(QueryKindComposite)},
QueryPluginVariant[FormulaSpec]{Kind: string(QueryKindFormula)},
QueryPluginVariant[PromQLQuerySpec]{Kind: string(QueryKindPromQL)},
@@ -138,6 +140,11 @@ func (plugin QueryPlugin) buildV5CompositeQueryFromPlugin() (qb.CompositeQuery,
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "builder query is empty")
}
return wrapEnvelope(qb.QueryTypeBuilder, spec.Spec), nil
case *AIBuilderQuerySpec:
if spec == nil {
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "AI builder query is empty")
}
return wrapEnvelope(qb.QueryTypeBuilderAI, qb.QueryBuilderQuery[qb.TraceAggregation](*spec)), nil
case *qb.PromQuery:
return wrapEnvelope(qb.QueryTypePromQL, *spec), nil
case *qb.ClickHouseQuery:
@@ -231,6 +238,7 @@ var (
}
queryPluginSpecs = map[QueryPluginKind]func() any{
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
QueryKindAIBuilder: func() any { return new(AIBuilderQuerySpec) },
QueryKindComposite: func() any { return new(CompositeQuerySpec) },
QueryKindFormula: func() any { return new(FormulaSpec) },
QueryKindPromQL: func() any { return new(PromQLQuerySpec) },
@@ -243,13 +251,13 @@ var (
VariableKindCustom: func() any { return new(CustomVariableSpec) },
}
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder},
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
}
)

View File

@@ -106,6 +106,13 @@ func redactQuery(spec any) any {
return spec
}
return &BuilderQuerySpec{Spec: redactLeafQuery(s.Spec)}
case *AIBuilderQuerySpec:
if s == nil {
return spec
}
redacted := redactLeafQuery(qb.QueryBuilderQuery[qb.TraceAggregation](*s)).(qb.QueryBuilderQuery[qb.TraceAggregation])
out := AIBuilderQuerySpec(redacted)
return &out
case *qb.PromQuery:
return redactQueryPtr(s)
case *qb.ClickHouseQuery:

View File

@@ -159,6 +159,11 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
plugin QueryPlugin
expectedType qb.QueryType
}{
{
description: "AI builder query",
plugin: QueryPlugin{Kind: QueryKindAIBuilder, Spec: &AIBuilderQuerySpec{Name: "A"}},
expectedType: qb.QueryTypeBuilderAI,
},
{
description: "promql",
plugin: QueryPlugin{Kind: QueryKindPromQL, Spec: &qb.PromQuery{Name: "A", Query: "up"}},

View File

@@ -133,6 +133,19 @@ func TestRedactQueryPluginWrappers(t *testing.T) {
assert.Equal(t, "A", builder.Name)
})
t.Run("AI builder plugin pointer is redacted and stays a pointer", func(t *testing.T) {
plugin := &AIBuilderQuerySpec{
Name: "A",
Filter: &qb.Filter{Expression: "body contains 'secret'"},
}
result, ok := redactQuery(plugin).(*AIBuilderQuerySpec)
require.True(t, ok)
assert.Nil(t, result.Filter)
assert.Equal(t, "A", result.Name)
})
t.Run("composite plugin redacts every sub-query envelope", func(t *testing.T) {
composite := &qb.CompositeQuery{Queries: []qb.QueryEnvelope{
{Type: qb.QueryTypeBuilder, Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A", Filter: &qb.Filter{Expression: "x = 1"}}},

View File

@@ -93,6 +93,7 @@ type QueryPluginKind string
const (
QueryKindBuilder QueryPluginKind = "signoz/BuilderQuery"
QueryKindAIBuilder QueryPluginKind = "signoz/AIBuilderQuery"
QueryKindComposite QueryPluginKind = "signoz/CompositeQuery"
QueryKindFormula QueryPluginKind = "signoz/Formula"
QueryKindPromQL QueryPluginKind = "signoz/PromQLQuery"
@@ -101,7 +102,7 @@ const (
)
func (QueryPluginKind) Enum() []any {
return []any{QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL, QueryKindTraceOperator}
return []any{QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL, QueryKindTraceOperator}
}
type (
@@ -159,6 +160,26 @@ func (BuilderQuerySpec) JSONSchemaOneOf() []any {
}
}
// AIBuilderQuerySpec is the spec of a signoz/AIBuilderQuery plugin: a gen_ai-scoped
// (AI observability) traces builder query, executed as qb.QueryTypeBuilderAI. The
// signal is implied by the kind and pinned to traces, mirroring the builder_ai_query
// QueryEnvelope decode.
type AIBuilderQuerySpec qb.QueryBuilderQuery[qb.TraceAggregation]
func (b *AIBuilderQuerySpec) UnmarshalJSON(data []byte) error {
var spec qb.QueryBuilderQuery[qb.TraceAggregation]
if err := json.Unmarshal(data, &spec); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid AI builder query spec")
}
spec.Signal = telemetrytypes.SignalTraces
*b = AIBuilderQuerySpec(spec)
return nil
}
func (AIBuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
return (qb.QueryBuilderQuery[qb.TraceAggregation]{}).PrepareJSONSchema(s)
}
// ══════════════════════════════════════════════
// SigNoz panel plugin specs
// ══════════════════════════════════════════════

View File

@@ -0,0 +1,75 @@
package dashboardtypes
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// An AI builder query is a gen_ai-scoped traces builder query: the signal is
// implied by the plugin kind and pinned to traces on decode, mirroring the
// builder_ai_query QueryEnvelope.
func TestAIBuilderQueryPluginRoundTrip(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/AIBuilderQuery", "spec": {
"name": "A", "aggregations": [{"expression": "count()"}]
}}}}]
}}},
"links": [],
"layouts": []
}`)
spec, err := unmarshalDashboard(data)
require.NoError(t, err)
plugin := spec.Panels["p1"].Spec.Queries[0].Spec.Plugin
assert.Equal(t, QueryKindAIBuilder, plugin.Kind)
aiSpec, ok := plugin.Spec.(*AIBuilderQuerySpec)
require.True(t, ok, "expected *AIBuilderQuerySpec, got %T", plugin.Spec)
assert.Equal(t, "A", aiSpec.Name)
assert.Equal(t, telemetrytypes.SignalTraces, aiSpec.Signal)
// Marshal emits the pinned signal and decodes back to the same plugin.
out, err := json.Marshal(plugin)
require.NoError(t, err)
assert.Contains(t, string(out), `"kind":"signoz/AIBuilderQuery"`)
assert.Contains(t, string(out), `"signal":"traces"`)
var roundTripped QueryPlugin
require.NoError(t, json.Unmarshal(out, &roundTripped))
assert.Equal(t, plugin, roundTripped)
}
// At query-range time an AI builder query wraps into a single builder_ai_query
// envelope so the querier routes it to the gen_ai statement builder.
func TestAIBuilderQueryPluginBuildsBuilderAIEnvelope(t *testing.T) {
plugin := QueryPlugin{Kind: QueryKindAIBuilder, Spec: &AIBuilderQuerySpec{Name: "A", Signal: telemetrytypes.SignalTraces}}
composite, err := plugin.buildV5CompositeQueryFromPlugin()
require.NoError(t, err)
require.Len(t, composite.Queries, 1)
assert.Equal(t, qb.QueryTypeBuilderAI, composite.Queries[0].Type)
spec, ok := composite.Queries[0].Spec.(qb.QueryBuilderQuery[qb.TraceAggregation])
require.True(t, ok, "expected traces builder query, got %T", composite.Queries[0].Spec)
assert.Equal(t, "A", spec.Name)
assert.Equal(t, telemetrytypes.SignalTraces, spec.Signal)
}
func TestAIBuilderQueryPluginNilSpec(t *testing.T) {
plugin := QueryPlugin{Kind: QueryKindAIBuilder, Spec: (*AIBuilderQuerySpec)(nil)}
_, err := plugin.buildV5CompositeQueryFromPlugin()
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
}