Compare commits

...

9 Commits

Author SHA1 Message Date
Gaurav Tewari
ef1cf7cd14 Merge branch 'feat/ai-query-support' into feat/ai-edit-support 2026-09-20 22:31:23 +05:30
Gaurav Tewari
49164911f4 chore: add support for list as well and self review changes 2026-09-20 22:28:07 +05:30
Gaurav Tewari
98b8e223b6 chore: add support for signoz/signoz/AIBuilderQuery 2026-09-20 19:57:56 +05:30
Gaurav Tewari
9cfd5b3421 feat(dashboards): author AI queries in the V2 panel editor
Replaces the panel registry's two independent lists (supportedSignals,
supportedQueryTypes) with a single supportedQueryModes map. Two lists
cannot express that AI is traces-only on a kind whose builder mode also
takes logs and metrics; declaring modes and their signals together can.

The AI mode is not an EQueryType — it is a per-query tag, so the active
tab is derived from the queries rather than stored.

Assisted-by: Claude Opus 5
2026-09-20 18:17:35 +05:30
Gaurav Tewari
7b3a0e038b feat(dashboards): render AI query panels
Treats `builder_ai_query` as a builder envelope everywhere the read path
already handled `builder_query`, so an AI query exported from the AI
explorer survives the round trip and renders. Authoring it in the panel
editor is a follow-up.

Assisted-by: Claude Opus 5
2026-09-20 18:14:28 +05:30
Naman Verma
2d48967690 fix: regen api spec 2026-09-17 11:30:32 +05:30
Naman Verma
c29b7b492f test: unit test cleanup 2026-09-17 11:26:16 +05:30
Naman Verma
d49919053f Merge branch 'main' into nv/dashboard-ai-builder-query 2026-09-17 11:20:35 +05:30
Naman Verma
67878a01b2 feat: add ai builder query plugin kind 2026-09-10 12:08:52 +05:30
63 changed files with 1189 additions and 273 deletions

View File

@@ -3210,6 +3210,69 @@ components:
repeatVariable:
type: string
type: object
DashboardtypesAIBuilderQuerySpec:
properties:
aggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
nullable: true
type: array
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
cursor:
type: string
disabled:
type: boolean
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
functions:
items:
$ref: '#/components/schemas/Querybuildertypesv5Function'
nullable: true
type: array
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
having:
$ref: '#/components/schemas/Querybuildertypesv5Having'
legend:
type: string
limit:
type: integer
limitBy:
$ref: '#/components/schemas/Querybuildertypesv5LimitBy'
name:
type: string
offset:
type: integer
order:
items:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
nullable: true
type: array
secondaryAggregations:
items:
$ref: '#/components/schemas/Querybuildertypesv5SecondaryAggregation'
nullable: true
type: array
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
nullable: true
type: array
signal:
enum:
- traces
type: string
source:
$ref: '#/components/schemas/TelemetrytypesSource'
stepInterval:
$ref: '#/components/schemas/Querybuildertypesv5Step'
required:
- signal
type: object
DashboardtypesAxes:
properties:
isLogScale:
@@ -4133,6 +4196,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'
@@ -4142,6 +4206,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'
@@ -4151,12 +4216,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

@@ -4009,6 +4009,71 @@ export interface DashboardGridLayoutSpecDTO {
repeatVariable?: string;
}
export enum DashboardtypesAIBuilderQuerySpecDTOSignal {
traces = 'traces',
}
export interface DashboardtypesAIBuilderQuerySpecDTO {
/**
* @type array,null
*/
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
*/
cursor?: string;
/**
* @type boolean
*/
disabled?: boolean;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
*/
functions?: Querybuildertypesv5FunctionDTO[] | null;
/**
* @type array,null
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
having?: Querybuildertypesv5HavingDTO;
/**
* @type string
*/
legend?: string;
/**
* @type integer
*/
limit?: number;
limitBy?: Querybuildertypesv5LimitByDTO;
/**
* @type string
*/
name?: string;
/**
* @type integer
*/
offset?: number;
/**
* @type array,null
*/
order?: Querybuildertypesv5OrderByDTO[] | null;
/**
* @type array,null
*/
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[] | null;
/**
* @type array,null
*/
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
/**
* @enum traces
* @type string
*/
signal: DashboardtypesAIBuilderQuerySpecDTOSignal;
source?: TelemetrytypesSourceDTO;
stepInterval?: Querybuildertypesv5StepDTO;
}
export interface DashboardtypesAxesDTO {
/**
* @type boolean
@@ -5102,6 +5167,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',
}
@@ -5393,6 +5470,7 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQu
export type DashboardtypesQueryPluginDTO =
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormulaDTO
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQueryDTO
@@ -6069,6 +6147,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

@@ -30,7 +30,10 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
> = {};
const builderQueryTypes: Record<
string,
'builder_query' | 'builder_formula' | 'builder_trace_operator'
| 'builder_query'
| 'builder_ai_query'
| 'builder_formula'
| 'builder_trace_operator'
> = {};
const promQueries: IPromQLQuery[] = [];
const clickhouseQueries: IClickHouseQuery[] = [];
@@ -44,6 +47,14 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
);
builderQueryTypes[spec.name] = 'builder_query';
}
} else if (q.type === 'builder_ai_query') {
if (spec.name) {
builderQueries[spec.name] = {
...convertBuilderQueryToIBuilderQuery(spec as BuilderQuery),
builderQueryType: 'builder_ai_query',
};
builderQueryTypes[spec.name] = 'builder_ai_query';
}
} else if (q.type === 'builder_formula') {
if (spec.name) {
builderQueries[spec.name] = convertQueryBuilderFormulaToIBuilderFormula(

View File

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

View File

@@ -1,3 +1,7 @@
import {
isBuilderEnvelope,
isBuilderPluginKind,
} from '../../../queryV5/builderEnvelope';
import type {
DashboardtypesDashboardSpecDTOPanels,
DashboardtypesQueryDTO,
@@ -20,15 +24,13 @@ function forEachBuilderSpec(
}
if (plugin.kind === 'signoz/CompositeQuery') {
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
(composite.queries ?? [])
.filter((envelope) => envelope.type === 'builder_query')
.forEach((envelope) => {
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
if (spec) {
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
});
} else if (plugin.kind === 'signoz/BuilderQuery') {
(composite.queries ?? []).filter(isBuilderEnvelope).forEach((envelope) => {
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
if (spec) {
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
});
} else if (isBuilderPluginKind(plugin.kind)) {
fn(plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO);
}
}

View File

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

View File

@@ -7,8 +7,8 @@ import type {
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import type { PanelQueryMode } from 'pages/DashboardPage/DashboardContainer/Panels/types/queryModes';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
@@ -30,7 +30,7 @@ interface ConfigPaneProps {
* panel types the visualization switcher disables — read from the provider, not the
* spec, because a new panel's spec has no query until staged.
*/
queryType: EQueryType;
mode: PanelQueryMode;
/** 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. */
@@ -57,7 +57,7 @@ function ConfigPane({
spec,
onChangeSpec,
onChangePanelKind,
queryType,
mode,
legendSeries,
tableColumns,
stepInterval,
@@ -125,7 +125,7 @@ function ConfigPane({
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
mode={mode}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
@@ -149,7 +149,7 @@ function ConfigPane({
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
mode={mode}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>

View File

@@ -1,8 +1,8 @@
import { Typography } from '@signozhq/ui/typography';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import type { PanelKind } from '../../../Panels/types/panelKind';
import type { PanelQueryMode } from '../../../Panels/types/queryModes';
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
import styles from './PanelTypeSwitcher.module.scss';
@@ -11,8 +11,8 @@ import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
interface PanelTypeSwitcherProps {
/** The current panel kind (selected value). */
panelKind: PanelKind;
/** Active query type — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse disable it). */
queryType: EQueryType;
/** Active query mode — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse/AI disable it). */
mode: PanelQueryMode;
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
onChange: (kind: PanelKind) => void;
@@ -26,11 +26,11 @@ interface PanelTypeSwitcherProps {
*/
function PanelTypeSwitcher({
panelKind,
queryType,
mode,
signal,
onChange,
}: PanelTypeSwitcherProps): JSX.Element {
const items = usePanelTypeSelectItems({ queryType, signal });
const items = usePanelTypeSelectItems({ mode, signal });
return (
<div className={styles.field}>

View File

@@ -43,18 +43,28 @@ describe('PanelTypeSwitcher', () => {
jest.clearAllMocks();
// List supports only logs/traces; every other kind also supports metrics.
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
mockGetPanelDefinition.mockImplementation((kind: string) => ({
mode: 'query',
supportedSignals:
mockGetPanelDefinition.mockImplementation((kind: string) => {
const signals =
kind === 'signoz/ListPanel'
? ['logs', 'traces']
: ['metrics', 'logs', 'traces'],
supportedQueryTypes: SUPPORTED_QUERY_TYPES[kind] ?? [
: ['metrics', 'logs', 'traces'];
const queryTypes = SUPPORTED_QUERY_TYPES[kind] ?? [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
}));
];
return {
mode: 'query',
supportedQueryModes: Object.fromEntries(
queryTypes.map((queryType: EQueryType) => [
queryType,
queryType === EQueryType.QUERY_BUILDER
? { kind: 'signal', signals }
: { kind: 'signal-less' },
]),
),
};
});
});
it('fires onChange with the chosen plugin kind', () => {
@@ -62,7 +72,7 @@ describe('PanelTypeSwitcher', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
mode={EQueryType.QUERY_BUILDER}
onChange={onChange}
/>,
);
@@ -77,7 +87,7 @@ describe('PanelTypeSwitcher', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
mode={EQueryType.QUERY_BUILDER}
signal={TelemetrytypesSignalDTO.metrics}
onChange={jest.fn()}
/>,
@@ -93,7 +103,7 @@ describe('PanelTypeSwitcher', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.QUERY_BUILDER}
mode={EQueryType.QUERY_BUILDER}
onChange={jest.fn()}
/>,
);
@@ -108,7 +118,7 @@ describe('PanelTypeSwitcher', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TimeSeriesPanel"
queryType={EQueryType.PROM}
mode={EQueryType.PROM}
onChange={jest.fn()}
/>,
);
@@ -125,7 +135,7 @@ describe('PanelTypeSwitcher', () => {
render(
<PanelTypeSwitcher
panelKind="signoz/TablePanel"
queryType={EQueryType.CLICKHOUSE}
mode={EQueryType.CLICKHOUSE}
onChange={jest.fn()}
/>,
);

View File

@@ -11,14 +11,14 @@ describe('getPanelTypeDisabledReason', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/TimeSeriesPanel',
queryType: PROM,
mode: PROM,
label: 'Time Series',
}),
).toBeUndefined();
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
mode: QUERY_BUILDER,
signal: logs,
label: 'List',
}),
@@ -29,21 +29,21 @@ describe('getPanelTypeDisabledReason', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: PROM,
mode: PROM,
label: 'List',
}),
).toBe("List isn't available for PromQL queries");
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: CLICKHOUSE,
mode: CLICKHOUSE,
label: 'List',
}),
).toBe("List isn't available for ClickHouse queries");
expect(
getPanelTypeDisabledReason({
kind: 'signoz/TablePanel',
queryType: PROM,
mode: PROM,
label: 'Table',
}),
).toBe("Table isn't available for PromQL queries");
@@ -53,7 +53,7 @@ describe('getPanelTypeDisabledReason', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
mode: QUERY_BUILDER,
signal: metrics,
label: 'List',
}),
@@ -64,7 +64,7 @@ describe('getPanelTypeDisabledReason', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/ListPanel',
queryType: PROM,
mode: PROM,
signal: metrics,
label: 'List',
}),

View File

@@ -1,16 +1,16 @@
import { useMemo } from 'react';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import { PANEL_OPTIONS } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import type { PanelQueryMode } from '../../../Panels/types/queryModes';
import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
import { getPanelTypeDisabledReason } from './utils';
interface UsePanelTypeSelectItemsArgs {
/** Active query type — a kind that can't be authored in it is disabled. */
queryType: EQueryType;
/** Active query mode — a kind that can't be authored in it is disabled. */
mode: PanelQueryMode;
/** Current datasource — also gates the disabled rule (List needs logs/traces, not metrics). */
signal?: TelemetrytypesSignalDTO;
}
@@ -22,7 +22,7 @@ interface UsePanelTypeSelectItemsArgs {
* modal's header so the two selectors apply the same rule and can't drift.
*/
export function usePanelTypeSelectItems({
queryType,
mode,
signal,
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
return useMemo(
@@ -31,7 +31,7 @@ export function usePanelTypeSelectItems({
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
const disabledReason = getPanelTypeDisabledReason({
kind,
queryType,
mode,
signal,
label: displayName,
});
@@ -43,6 +43,6 @@ export function usePanelTypeSelectItems({
tooltip: disabledReason,
};
}),
[queryType, signal],
[mode, signal],
);
}

View File

@@ -3,10 +3,14 @@ import { EQueryType } from 'types/common/dashboard';
import {
isStaticPanelKind,
isQueryTypeSupportedByPanelKind,
isQueryModeSupportedByPanelKind,
isSignalSupported,
} from '../../../Panels/capabilities';
import type { PanelKind } from '../../../Panels/types/panelKind';
import {
AI_QUERY_MODE,
type PanelQueryMode,
} from '../../../Panels/types/queryModes';
const QUERY_TYPE_LABEL: Record<EQueryType, string> = {
[EQueryType.QUERY_BUILDER]: 'Query Builder',
@@ -14,6 +18,11 @@ const QUERY_TYPE_LABEL: Record<EQueryType, string> = {
[EQueryType.PROM]: 'PromQL',
};
const MODE_LABEL: Record<PanelQueryMode, string> = {
...QUERY_TYPE_LABEL,
[AI_QUERY_MODE]: 'AI Query Builder',
};
const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
[TelemetrytypesSignalDTO.logs]: 'logs',
[TelemetrytypesSignalDTO.traces]: 'traces',
@@ -29,12 +38,12 @@ const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
*/
export function getPanelTypeDisabledReason({
kind,
queryType,
mode,
signal,
label,
}: {
kind: PanelKind;
queryType: EQueryType;
mode: PanelQueryMode;
signal?: TelemetrytypesSignalDTO;
label: string;
}): string | undefined {
@@ -44,10 +53,10 @@ export function getPanelTypeDisabledReason({
if (isStaticPanelKind(kind)) {
return undefined;
}
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
return `${label} isn't available for ${QUERY_TYPE_LABEL[queryType]} queries`;
if (!isQueryModeSupportedByPanelKind(kind, mode)) {
return `${label} isn't available for ${MODE_LABEL[mode]} queries`;
}
if (signal !== undefined && !isSignalSupported(kind, signal)) {
if (signal !== undefined && !isSignalSupported(kind, signal, mode)) {
return `${label} doesn't support ${SIGNAL_LABEL[signal]} data`;
}
return undefined;

View File

@@ -58,7 +58,7 @@ function SectionSlot({
signal,
panelKind,
onChangePanelKind,
queryType,
mode,
stepInterval,
metricUnit,
}: SectionSlotProps): JSX.Element | null {
@@ -124,7 +124,7 @@ function SectionSlot({
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
mode={mode}
stepInterval={stepInterval}
metricUnit={metricUnit}
registerHeaderAction={registerHeaderAction}

View File

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

View File

@@ -1,9 +1,9 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelQueryMode } from '../../Panels/types/queryModes';
import type { PanelKind } from '../../Panels/types/panelKind';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import { EQueryType } from 'types/common/dashboard';
/**
* Context `SectionSlot` forwards to every section editor (not spec-slice fields — those
@@ -17,7 +17,7 @@ export interface SectionEditorContext {
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;
yAxisUnit?: string;
queryType?: EQueryType;
mode?: PanelQueryMode;
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' | 'mode'
>;
/**
@@ -31,7 +31,7 @@ function VisualizationSection({
onChange,
panelKind,
onChangePanelKind,
queryType,
mode,
signal,
}: VisualizationSectionProps): JSX.Element {
return (
@@ -39,9 +39,9 @@ function VisualizationSection({
{controls.switchPanelKind && panelKind && onChangePanelKind && (
<PanelTypeSwitcher
panelKind={panelKind}
// queryType is optional on the kind-erased section context, but always
// mode is optional on the kind-erased section context, but always
// supplied in practice; default to Query Builder at this boundary.
queryType={queryType ?? EQueryType.QUERY_BUILDER}
mode={mode ?? EQueryType.QUERY_BUILDER}
signal={signal}
onChange={onChangePanelKind}
/>

View File

@@ -9,8 +9,11 @@ import VisualizationSection from '../VisualizationSection';
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(() => ({
mode: 'query',
supportedSignals: ['metrics', 'logs', 'traces'],
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
supportedQueryModes: {
builder: { kind: 'signal', signals: ['metrics', 'logs', 'traces'] },
clickhouse_sql: { kind: 'signal-less' },
promql: { kind: 'signal-less' },
},
})),
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },

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';
@@ -18,12 +18,20 @@ import PromQLQueryContainer from 'container/QueryBuilder/rawQueryEditors/PromQL'
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsAIObservabilityEnabled } from 'hooks/useIsAIObservabilityEnabled';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
import {
AI_QUERY_MODE,
listQueryModes,
type PanelQueryMode,
} from '../../Panels/types/queryModes';
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
import { toPanelType } from '../../Panels/types/panelKind';
import { getQueryMode, withQueryMode } from '../../Panels/utils/queryMode';
import styles from './PanelEditorQueryBuilder.module.scss';
@@ -66,13 +74,18 @@ function PanelEditorQueryBuilder({
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
// Derived, not stored: the AI mode is a per-query tag, so the active tab is whatever the
// queries currently say. A mode the kind no longer offers (after a kind switch) falls back
// to the builder rather than selecting a tab that isn't rendered.
const activeMode = getQueryMode(currentQuery);
const handleQueryCategoryChange = useCallback(
(queryType: string): void => {
redirectWithQueryBuilderData({
...currentQuery,
queryType: queryType as EQueryType,
});
(mode: string): void => {
redirectWithQueryBuilderData(
withQueryMode(currentQuery, mode as PanelQueryMode),
);
},
[currentQuery, redirectWithQueryBuilderData],
);
@@ -99,7 +112,9 @@ function PanelEditorQueryBuilder({
);
const items = useMemo(() => {
const { supportedQueryTypes } = panelDefinition;
const supportedModes = listQueryModes(
panelDefinition.supportedQueryModes,
).filter((mode) => mode !== AI_QUERY_MODE || isAIObservabilityEnabled);
const queryTypeComponents = {
[EQueryType.QUERY_BUILDER]: {
@@ -134,19 +149,48 @@ function PanelEditorQueryBuilder({
label: 'PromQL',
component: <PromQLQueryContainer />,
},
// Traces only, and the source selector is hidden with it: an AI query that moved
// off traces is no longer an AI query. `queryVariant: 'static'` matches the AI
// explorer's builder.
[AI_QUERY_MODE]: {
icon: <Sparkles size={14} />,
label: 'AI Query Builder',
component: (
<div className="query-builder-v2-container">
<QueryBuilderV2
panelType={panelType}
filterConfigs={filterConfigs}
showTraceOperator={false}
version="v3"
queryComponents={{}}
config={{
initialDataSource: DataSource.TRACES,
queryVariant: 'static',
}}
/>
</div>
),
},
};
return supportedQueryTypes.map((queryType) => ({
key: queryType,
return supportedModes.map((mode) => ({
key: mode,
label: (
<div className={styles.queryTypeTab}>
{queryTypeComponents[queryType].icon}
<Typography>{queryTypeComponents[queryType].label}</Typography>
{queryTypeComponents[mode].icon}
<Typography>{queryTypeComponents[mode].label}</Typography>
</div>
),
children: queryTypeComponents[queryType].component,
children: queryTypeComponents[mode].component,
}));
}, [panelDefinition, panelType, filterConfigs, isDarkMode, isListViewPanel]);
}, [
panelDefinition,
panelType,
filterConfigs,
isDarkMode,
isListViewPanel,
isAIObservabilityEnabled,
]);
return (
<div
@@ -161,7 +205,7 @@ function PanelEditorQueryBuilder({
className={cx(styles.tabsContainer, {
[styles.stickyNav]: stickyHeader,
})}
activeKey={currentQuery.queryType}
activeKey={activeMode}
onChange={handleQueryCategoryChange}
tabBarExtraContent={
<span className={styles.runQueryBtnContainer}>

View File

@@ -12,6 +12,10 @@ import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
const mockQueryBuilderV2 = jest.fn();
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
useIsAIObservabilityEnabled: (): boolean => false,
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));

View File

@@ -21,6 +21,7 @@ import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import { getQueryMode } from '../Panels/utils/queryMode';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import PanelEditorLayout, {
@@ -319,7 +320,7 @@ function QueryEditorBody({
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={currentQuery.queryType}
mode={getQueryMode(currentQuery)}
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}

View File

@@ -122,7 +122,7 @@ function StaticEditorBody({
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={EQueryType.QUERY_BUILDER}
mode={EQueryType.QUERY_BUILDER}
legendSeries={[]}
tableColumns={[]}
/>

View File

@@ -23,6 +23,10 @@ import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContai
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,7 @@ import { handleQueryChange } from 'lib/query/panelQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../../Panels/capabilities';
import { resolveQueryMode } from '../../../Panels/capabilities';
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
@@ -18,7 +18,7 @@ jest.mock('lib/query/panelQuery', () => ({
handleQueryChange: jest.fn(),
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryType: jest.fn(),
resolveQueryMode: jest.fn(),
// Real predicate: these specs use real (query) kinds and the static path is
// exercised through its own cases below.
isStaticPanelKind: jest.requireActual('../../../Panels/capabilities')
@@ -36,7 +36,7 @@ jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
const mockResolveQueryType = resolveQueryType as unknown as jest.Mock;
const mockResolveQueryMode = resolveQueryMode as unknown as jest.Mock;
const mockToPerses = toPerses as unknown as jest.Mock;
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
@@ -95,7 +95,7 @@ describe('usePanelTypeSwitch', () => {
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
// The guard owns coercion (tested in capabilities.test.ts); here it always
// resolves to Query Builder so the coerced type flows into handleQueryChange.
mockResolveQueryType.mockReturnValue('builder');
mockResolveQueryMode.mockReturnValue('builder');
});
it('does nothing when switching to the current kind', () => {
@@ -185,16 +185,54 @@ describe('usePanelTypeSwitch', () => {
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// The hook asks the guard to resolve the active query type against the new kind
expect(mockResolveQueryType).toHaveBeenCalledWith(
// The hook asks the guard to resolve the active mode against the new kind, passing
// the current signal — the AI mode is only valid for traces.
expect(mockResolveQueryMode).toHaveBeenCalledWith(
'signoz/ListPanel',
'promql',
'logs',
);
// …and the resolved type ('builder') flows into the query rebuild.
// …and the resolved mode ('builder') flows into the query rebuild.
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
expect((queryArg as Query).queryType).toBe('builder');
});
it('strips the AI tag when the new kind has no AI mode', () => {
const setSpec = jest.fn();
const aiQuery = {
id: 'ai',
queryType: 'builder',
builder: {
queryData: [{ dataSource: 'traces', builderQueryType: 'builder_ai_query' }],
queryFormulas: [],
queryTraceOperator: [],
},
} as unknown as Query;
mockUseQueryBuilder.mockReturnValue(builderState(aiQuery));
// List declares no AI mode, so the guard coerces back to the builder.
mockResolveQueryMode.mockReturnValue('builder');
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
expect(mockResolveQueryMode).toHaveBeenCalledWith(
'signoz/ListPanel',
'builder_ai_query',
'logs',
);
// The tag has to come off the queries themselves — queryType alone can't say it.
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
(queryArg as Query).builder.queryData.forEach((qd) => {
expect(qd.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;

View File

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

View File

@@ -18,7 +18,8 @@ import type {
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { isStaticPanelKind, resolveQueryType } from '../../Panels/capabilities';
import { isStaticPanelKind, resolveQueryMode } from '../../Panels/capabilities';
import { getQueryMode, withQueryMode } from '../../Panels/utils/queryMode';
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../queryV5/persesQueryAdapters';
@@ -147,12 +148,16 @@ export function usePanelTypeSwitch({
return;
}
// First visit → coerce the query type if the new kind disallows it, then
// rebuild the builder query for the new type.
const queryType = resolveQueryType(newKind, query.queryType);
// First visit → coerce the mode if the new kind disallows it, then rebuild the
// builder query for it. Going through the mode (not `queryType`) is what lets an
// AI query lose its per-query tag when the new kind has no AI mode: `queryType`
// alone cannot express that.
const currentSignal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
const mode = resolveQueryMode(newKind, getQueryMode(query), currentSignal);
const transformed = handleQueryChange(
newPanelType as keyof PartialPanelTypes,
{ ...query, queryType },
withQueryMode(query, mode),
panelTypeRef.current,
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
@@ -160,12 +165,9 @@ export function usePanelTypeSwitch({
newKind === 'signoz/ListPanel'
? withDefaultListOrder(transformed)
: transformed;
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(
buildSpec(
getSwitchedPluginSpec(currentSpec, newKind, signal),
getSwitchedPluginSpec(currentSpec, newKind, currentSignal),
toPerses(nextQuery, newPanelType),
),
);

View File

@@ -17,9 +17,12 @@ import {
getSupportedSignals,
isPanelCombinationValid,
isQueryTypeSupportedByPanelKind,
isQueryModeSupportedByPanelKind,
isSignalSupported,
resolveQueryMode,
resolveQueryType,
} from '../capabilities';
import { AI_QUERY_MODE } from '../types/queryModes';
import type { PanelKind } from '../types/panelKind';
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
@@ -142,7 +145,7 @@ describe('panel capabilities guard', () => {
expect(getSupportedQueryTypes(unknownKind)).toStrictEqual([]);
expect(isSignalSupported(unknownKind, logs)).toBe(false);
expect(
isPanelCombinationValid({ kind: unknownKind, queryType: QUERY_BUILDER }),
isPanelCombinationValid({ kind: unknownKind, mode: QUERY_BUILDER }),
).toBe(false);
expect(getHiddenQueryBuilderFields(unknownKind, logs)).toStrictEqual({});
expect(getPanelDefinition(unknownKind).sections).toStrictEqual([]);
@@ -212,13 +215,13 @@ describe('panel capabilities guard', () => {
expect(
isPanelCombinationValid({
kind: 'signoz/TimeSeriesPanel',
queryType: PROM,
mode: PROM,
}),
).toBe(true);
expect(
isPanelCombinationValid({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
mode: QUERY_BUILDER,
signal: logs,
}),
).toBe(true);
@@ -226,10 +229,10 @@ describe('panel capabilities guard', () => {
it('rejects an unsupported query type', () => {
expect(
isPanelCombinationValid({ kind: 'signoz/ListPanel', queryType: PROM }),
isPanelCombinationValid({ kind: 'signoz/ListPanel', mode: PROM }),
).toBe(false);
expect(
isPanelCombinationValid({ kind: 'signoz/TablePanel', queryType: PROM }),
isPanelCombinationValid({ kind: 'signoz/TablePanel', mode: PROM }),
).toBe(false);
});
@@ -237,7 +240,7 @@ describe('panel capabilities guard', () => {
expect(
isPanelCombinationValid({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
mode: QUERY_BUILDER,
signal: metrics,
}),
).toBe(false);
@@ -247,7 +250,7 @@ describe('panel capabilities guard', () => {
expect(
isPanelCombinationValid({
kind: 'signoz/ListPanel',
queryType: QUERY_BUILDER,
mode: QUERY_BUILDER,
}),
).toBe(true);
});
@@ -268,6 +271,85 @@ describe('panel capabilities guard', () => {
});
});
describe('the AI query mode', () => {
const AI_KINDS: PanelKind[] = [
'signoz/TimeSeriesPanel',
'signoz/BarChartPanel',
'signoz/NumberPanel',
'signoz/HistogramPanel',
'signoz/PieChartPanel',
'signoz/TablePanel',
];
it.each(AI_KINDS)('is offered by %s, for traces only', (kind) => {
expect(isQueryModeSupportedByPanelKind(kind, AI_QUERY_MODE)).toBe(true);
expect(getSupportedSignals(kind, AI_QUERY_MODE)).toStrictEqual([traces]);
});
it('is not offered by List, whose raw rows carry no aggregation', () => {
expect(
isQueryModeSupportedByPanelKind('signoz/ListPanel', AI_QUERY_MODE),
).toBe(false);
});
it('does not leak into the legacy queryType axis', () => {
expect(getSupportedQueryTypes('signoz/TimeSeriesPanel')).not.toContain(
AI_QUERY_MODE,
);
});
it('leaves the kind-wide signal list unchanged', () => {
// traces is already in the builder mode's list, so the union must not repeat it.
expect(getSupportedSignals('signoz/TimeSeriesPanel')).toStrictEqual([
metrics,
logs,
traces,
]);
});
it('pairs with traces but not with the other signals of the kind', () => {
expect(
isPanelCombinationValid({
kind: 'signoz/TimeSeriesPanel',
mode: AI_QUERY_MODE,
signal: traces,
}),
).toBe(true);
expect(
isPanelCombinationValid({
kind: 'signoz/TimeSeriesPanel',
mode: AI_QUERY_MODE,
signal: logs,
}),
).toBe(false);
});
});
describe('resolveQueryMode', () => {
it('keeps the AI mode on a kind that offers it', () => {
expect(
resolveQueryMode('signoz/TimeSeriesPanel', AI_QUERY_MODE, traces),
).toBe(AI_QUERY_MODE);
});
it('coerces the AI mode on a kind that does not offer it', () => {
expect(resolveQueryMode('signoz/ListPanel', AI_QUERY_MODE, traces)).toBe(
QUERY_BUILDER,
);
});
it('coerces the AI mode when the signal moved off traces', () => {
expect(resolveQueryMode('signoz/TimeSeriesPanel', AI_QUERY_MODE, logs)).toBe(
QUERY_BUILDER,
);
});
it('leaves a query-language mode alone', () => {
expect(resolveQueryMode('signoz/TimeSeriesPanel', PROM)).toBe(PROM);
expect(resolveQueryMode('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
});
});
describe('getHiddenQueryBuilderFields', () => {
it('returns {} for kinds that declare no field rules', () => {
expect(

View File

@@ -8,10 +8,16 @@ import {
} from './types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
import type { PanelKind } from './types/panelKind';
import {
listQueryModes,
listQueryTypes,
signalsForMode,
type PanelQueryMode,
} from './types/queryModes';
/**
* The single deterministic guard for V2 dashboards. Every "what works with what"
* question — panel kind × query type × signal, and which query-builder fields a kind
* question — panel kind × query mode × signal, and which query-builder fields a kind
* hides — is answered here by reading each kind's declared capabilities from the panel
* registry. Adding a new kind means declaring its capabilities once in its definition;
* these functions then cover it automatically. Pure and side-effect free.
@@ -52,23 +58,47 @@ export function requireQueryPanelDefinition(
return definition;
}
/** Signals a kind can visualize. */
/** Every mode this kind offers, in declaration order (the builder first). */
export function getSupportedQueryModes(kind: PanelKind): PanelQueryMode[] {
const definition = getQueryPanelDefinition(kind);
return definition ? listQueryModes(definition.supportedQueryModes) : [];
}
export function isQueryModeSupportedByPanelKind(
kind: PanelKind,
mode: PanelQueryMode,
): boolean {
return getSupportedQueryModes(kind).includes(mode);
}
/**
* Signals a kind can visualize — in `mode` when one is given, else across every mode it
* offers. The mode-scoped form is what keeps the two axes interoperable: the AI mode
* authors traces only, on a kind whose builder mode also takes logs and metrics.
*/
export function getSupportedSignals(
kind: PanelKind,
mode?: PanelQueryMode,
): TelemetrytypesSignalDTO[] {
return getQueryPanelDefinition(kind)?.supportedSignals ?? [];
const modes = getQueryPanelDefinition(kind)?.supportedQueryModes;
return modes ? signalsForMode(modes, mode) : [];
}
export function isSignalSupported(
kind: PanelKind,
signal: TelemetrytypesSignalDTO,
mode?: PanelQueryMode,
): boolean {
return getSupportedSignals(kind).includes(signal);
return getSupportedSignals(kind, mode).includes(signal);
}
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
/**
* Query languages a kind supports (Query Builder / ClickHouse / PromQL) — its modes
* minus the AI one, for the call sites that speak the legacy `queryType` axis.
*/
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
return getQueryPanelDefinition(kind)?.supportedQueryTypes ?? [];
const definition = getQueryPanelDefinition(kind);
return definition ? listQueryTypes(definition.supportedQueryModes) : [];
}
export function isQueryTypeSupportedByPanelKind(
@@ -79,48 +109,63 @@ export function isQueryTypeSupportedByPanelKind(
}
/**
* 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
* validated only when one is given.
* Master guard: is this panel kind renderable in this mode (and, where the mode carries
* a signal, with this signal)? ClickHouse/PromQL queries carry no signal, so the signal
* is validated only when one is given.
*/
export function isPanelCombinationValid({
kind,
queryType,
mode,
signal,
}: {
kind: PanelKind;
queryType: EQueryType;
mode: PanelQueryMode;
signal?: TelemetrytypesSignalDTO;
}): boolean {
// A query-less kind ignores the query entirely, so it pairs with anything.
if (isStaticPanelKind(kind)) {
return true;
}
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
if (!isQueryModeSupportedByPanelKind(kind, mode)) {
return false;
}
if (signal !== undefined && !isSignalSupported(kind, signal)) {
if (signal !== undefined && !isSignalSupported(kind, signal, mode)) {
return false;
}
return true;
}
/**
* The query type to use for a kind given a `preferred` one: keep it if the kind
* supports it, otherwise fall back to the kind's first supported type. Used when
* switching panel kinds to coerce an unsupported active query type (e.g. PromQL → a
* List panel coerces to Query Builder).
* The mode to use for a kind given a `preferred` one: keep it if the kind offers it and
* it admits the signal, otherwise fall back to the kind's first mode. Used when switching
* panel kinds to coerce an unsupported active mode (PromQL → a List panel coerces to Query
* Builder; AI → a kind with no AI mode does the same).
*/
export function resolveQueryMode(
kind: PanelKind,
preferred: PanelQueryMode,
signal?: TelemetrytypesSignalDTO,
): PanelQueryMode {
const supported = getSupportedQueryModes(kind);
if (
supported.includes(preferred) &&
(signal === undefined || isSignalSupported(kind, signal, preferred))
) {
return preferred;
}
// A query-less kind has no modes; the builder is the neutral answer.
return supported[0] ?? EQueryType.QUERY_BUILDER;
}
/** `resolveQueryMode` narrowed to the legacy `queryType` axis. */
export function resolveQueryType(
kind: PanelKind,
preferred: EQueryType,
): EQueryType {
const supported = getSupportedQueryTypes(kind);
if (supported.includes(preferred)) {
return preferred;
}
// A query-less kind has no supported types; the builder is the neutral answer.
return supported[0] ?? EQueryType.QUERY_BUILDER;
return supported.includes(preferred)
? preferred
: (supported[0] ?? EQueryType.QUERY_BUILDER);
}
/**

View File

@@ -10,6 +10,8 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { AI_QUERY_MODE } from '../../types/queryModes';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
@@ -18,16 +20,22 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportedQueryModes: {
[EQueryType.QUERY_BUILDER]: {
kind: 'signal',
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
[EQueryType.PROM]: { kind: 'signal-less' },
[AI_QUERY_MODE]: {
kind: 'signal',
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
// Bars are binned client-side from a raw time series, so the request asks for a
// step interval wide enough to keep the bar count readable (V1 parity).

View File

@@ -10,6 +10,8 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { AI_QUERY_MODE } from '../../types/queryModes';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
@@ -18,16 +20,22 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportedQueryModes: {
[EQueryType.QUERY_BUILDER]: {
kind: 'signal',
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
[EQueryType.PROM]: { kind: 'signal-less' },
[AI_QUERY_MODE]: {
kind: 'signal',
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
// Buckets are computed client-side from the raw series, so the request is a plain
// time series — the bucket count is a display concern, not a query one.

View File

@@ -18,16 +18,18 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
icon: List,
Renderer,
EditorPane: ListEditorPane,
// Raw records come from logs and traces; metrics don't produce row data.
supportedSignals: [
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
// Raw records come from logs and traces; metrics don't produce row data. No AI mode:
// the AI builder authors aggregations, which raw rows have no place for.
supportedQueryModes: {
[EQueryType.QUERY_BUILDER]: {
kind: 'signal',
signals: [TelemetrytypesSignalDTO.logs, TelemetrytypesSignalDTO.traces],
},
},
// Raw rows have no aggregation, so step interval / having never apply, and the
// Where clause searches the log/span body via `body CONTAINS`. Traces additionally
// 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],
queryBuilderFields: {
default: {
stepInterval: { isHidden: true, isDisabled: true },

View File

@@ -10,6 +10,8 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { AI_QUERY_MODE } from '../../types/queryModes';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
displayName: 'Number',
@@ -18,16 +20,22 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportedQueryModes: {
[EQueryType.QUERY_BUILDER]: {
kind: 'signal',
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
[EQueryType.PROM]: { kind: 'signal-less' },
[AI_QUERY_MODE]: {
kind: 'signal',
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,

View File

@@ -10,6 +10,8 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { AI_QUERY_MODE } from '../../types/queryModes';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
displayName: 'Pie Chart',
@@ -18,12 +20,21 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
supportedQueryModes: {
[EQueryType.QUERY_BUILDER]: {
kind: 'signal',
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
[AI_QUERY_MODE]: {
kind: 'signal',
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,

View File

@@ -10,6 +10,8 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { AI_QUERY_MODE } from '../../types/queryModes';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
kind: 'signoz/TablePanel',
displayName: 'Table',
@@ -18,12 +20,21 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
supportedQueryModes: {
[EQueryType.QUERY_BUILDER]: {
kind: 'signal',
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
[AI_QUERY_MODE]: {
kind: 'signal',
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
// The only kind that asks the server to transpose its scalar result into UI rows.
queryCapabilities: {

View File

@@ -10,6 +10,8 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { AI_QUERY_MODE } from '../../types/queryModes';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Time Series',
@@ -18,16 +20,22 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportedQueryModes: {
[EQueryType.QUERY_BUILDER]: {
kind: 'signal',
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[EQueryType.CLICKHOUSE]: { kind: 'signal-less' },
[EQueryType.PROM]: { kind: 'signal-less' },
[AI_QUERY_MODE]: {
kind: 'signal',
signals: [TelemetrytypesSignalDTO.traces],
},
},
queryBuilderFields: {},
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.time_series,

View File

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

View File

@@ -4,7 +4,6 @@ import {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ChartLine } from '@signozhq/icons';
import type { EQueryType } from 'types/common/dashboard';
import type { SectionConfig } from './sections';
import type { AnyPanelInteractionProps } from './interactions';
@@ -13,6 +12,7 @@ import type {
PanelQueryCapabilities,
QueryBuilderFieldRule,
} from './panelCapabilities';
import type { SupportedQueryModes } from './queryModes';
import type {
BaseRendererProps,
PanelRendererProps,
@@ -114,10 +114,8 @@ export interface QueryPanelDefinition<
Renderer: ComponentType<PanelRendererProps<K>>;
/** Lower editor pane — the shared query-builder pane, or a kind wrapper of it. */
EditorPane: ComponentType<QueryEditorPaneProps>;
/** Signals this kind can visualize. */
supportedSignals: TelemetrytypesSignalDTO[];
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
supportedQueryTypes: EQueryType[];
/** Modes this kind offers, each with the signals authorable in it. */
supportedQueryModes: SupportedQueryModes;
/** 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

@@ -0,0 +1,66 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { QueryType } from 'types/api/v5/queryRange';
import type { EQueryType } from 'types/common/dashboard';
/**
* The query-authoring modes a panel kind can offer, one per editor tab. Three are query
* languages (`EQueryType`); the fourth is the AI query builder, which is a builder query
* under the hood and so has no `EQueryType` of its own — it is keyed by the v5 query type
* stamped on each of its queries (`IBuilderQuery.builderQueryType`), which is what lets
* the active tab be read straight off the query.
*/
export type PanelQueryMode =
| EQueryType
| Extract<QueryType, 'builder_ai_query'>;
/** The AI query builder's mode key. */
export const AI_QUERY_MODE = 'builder_ai_query' as const;
/**
* What a mode authors: a set of signals, or nothing signal-shaped at all. ClickHouse and
* PromQL are raw query text and carry no signal, which is a different statement from "this
* mode takes an empty set of signals" — the union makes the two unconfusable.
*/
export type QueryModeCapability =
| { kind: 'signal'; signals: TelemetrytypesSignalDTO[] }
| { kind: 'signal-less' };
/**
* Every mode a kind offers, with what each one authors. Partial on purpose: an absent key
* means the kind does not offer that mode at all (no tab, and the kind is disabled while that
* mode is active), which is how List opts out of everything but the builder.
*
* Declaring modes and signals together is what keeps them interoperable: AI is traces-only on
* a kind whose builder mode also takes logs and metrics — which two independent lists can't
* express.
*/
export type SupportedQueryModes = Partial<
Record<PanelQueryMode, QueryModeCapability>
>;
/** Declaration order, which puts the builder first. */
export function listQueryModes(modes: SupportedQueryModes): PanelQueryMode[] {
return Object.keys(modes) as PanelQueryMode[];
}
/** The modes that are query languages, for call sites on the legacy `queryType` axis. */
export function listQueryTypes(modes: SupportedQueryModes): EQueryType[] {
return listQueryModes(modes).filter(
(mode): mode is EQueryType => mode !== AI_QUERY_MODE,
);
}
/** Signals authorable in `mode`, or across every mode when none is given. */
export function signalsForMode(
modes: SupportedQueryModes,
mode?: PanelQueryMode,
): TelemetrytypesSignalDTO[] {
if (mode) {
const capability = modes[mode];
return capability?.kind === 'signal' ? capability.signals : [];
}
const all = Object.values(modes).flatMap((capability) =>
capability?.kind === 'signal' ? capability.signals : [],
);
return [...new Set(all)];
}

View File

@@ -4,6 +4,11 @@ import type {
} from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import {
isBuilderEnvelope,
isBuilderPluginKind,
} from '../../queryV5/builderEnvelope';
/**
* Flattens a panel's queries into its builder queries, unwrapping
* `CompositeQuery` envelopes. Non-builder kinds (PromQL, ClickHouseSQL, Formula,
@@ -16,13 +21,13 @@ export function getBuilderQueries(
const flattened: BuilderQuery[] = [];
queries.forEach((envelope) => {
const plugin = envelope.spec.plugin;
if (plugin.kind === 'signoz/BuilderQuery') {
if (isBuilderPluginKind(plugin.kind)) {
flattened.push(plugin.spec as BuilderQuery);
return;
}
if (plugin.kind === 'signoz/CompositeQuery') {
(plugin.spec.queries || []).forEach((sub) => {
if (sub.type === 'builder_query') {
if (isBuilderEnvelope(sub)) {
flattened.push(sub.spec as BuilderQuery);
}
});

View File

@@ -2,7 +2,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { initialQueriesMap } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getQueryPanelDefinition } from '../capabilities';
import { getQueryPanelDefinition, getSupportedSignals } from '../capabilities';
import { toPanelType } from '../types/panelKind';
import { fromPerses } from '../../queryV5/persesQueryAdapters';
@@ -22,7 +22,7 @@ export function getPanelBuilderQuery(
if (!definition) {
return null;
}
const [defaultSignal] = definition.supportedSignals;
const [defaultSignal] = getSupportedSignals(kind);
// A query-less panel seeds from the kind's first supported signal — `fromPerses`'s
// metrics default isn't authorable in every kind (e.g. List).
if (panel.spec.queries.length === 0 && defaultSignal) {

View File

@@ -0,0 +1,65 @@
import { initialQueryAIWithType } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { AI_QUERY_MODE, type PanelQueryMode } from '../types/queryModes';
/**
* The mode a query is authored in. The AI mode has no `queryType` of its own — it is a
* builder query tagged per-query — so it is read off the queries rather than off the
* envelope around them.
*/
export function getQueryMode(query: Query): PanelQueryMode {
if (
query.queryType === EQueryType.QUERY_BUILDER &&
(query.builder?.queryData ?? []).some(
(q) => q.builderQueryType === AI_QUERY_MODE,
)
) {
return AI_QUERY_MODE;
}
return query.queryType;
}
/**
* Retargets a query at `mode`, which is the only safe way to change it: the AI tag lives on
* every query and has to be stamped or cleared in step with `queryType`, or the tab and the
* queries disagree.
*
* Entering the AI mode keeps queries that are already traces (the common case — the user was
* building a trace query and wants AI's field set); anything else is replaced with a fresh AI
* query rather than coerced, which would leave metric aggregations on a traces query.
*/
export function withQueryMode(query: Query, mode: PanelQueryMode): Query {
if (mode === AI_QUERY_MODE) {
const queryData = query.builder?.queryData ?? [];
const isAllTraces =
queryData.length > 0 &&
queryData.every((q) => q.dataSource === DataSource.TRACES);
return {
...query,
queryType: EQueryType.QUERY_BUILDER,
builder: isAllTraces
? {
...query.builder,
queryData: queryData.map((q) => ({
...q,
builderQueryType: AI_QUERY_MODE,
})),
}
: initialQueryAIWithType.builder,
};
}
return {
...query,
queryType: mode,
builder: {
...query.builder,
queryData: (query.builder?.queryData ?? []).map((q) => ({
...q,
builderQueryType: undefined,
})),
},
};
}

View File

@@ -28,12 +28,17 @@ import { useViewPanel } from '../hooks/useViewPanel';
import { buildMoveItems } from '../utils/buildMoveItems';
import MenuActionItem from '../../../components/MenuActionItem/MenuActionItem';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import { isAIBuilderEnvelope } from '../../../queryV5/builderEnvelope';
import { toQueryEnvelopes } from '../../../queryV5/buildQueryRangeRequest';
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
// 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[] = [];
const ALERT_FROM_AI_PANEL_REASON =
'Alerts are not available for AI Query Builder panels';
interface UsePanelActionItemsArgs {
panelId: string;
/** The panel itself — seeds "Create Alerts" and the download filename. */
@@ -63,6 +68,10 @@ export function usePanelActionItems({
panelActions,
}: UsePanelActionItemsArgs): PanelActionItems {
const panelKind = panel.spec.plugin.kind;
// The alert builder has no AI query mode, so the flow would open on an empty query.
const isAIPanel = toQueryEnvelopes(panel.spec.queries).some(
isAIBuilderEnvelope,
);
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
const openPanelEditor = useOpenPanelEditor();
@@ -157,7 +166,15 @@ export function usePanelActionItems({
if (panelCapabilities.createAlert) {
dataGroup.push({
key: 'create-alert',
label: row('Create Alerts', <Bell size={14} />, { checks: [] }),
label: (
<MenuActionItem
label="Create Alerts"
icon={<Bell size={14} />}
checks={[]}
disabledTooltip={isAIPanel ? ALERT_FROM_AI_PANEL_REASON : undefined}
/>
),
disabled: isAIPanel,
onClick: (): void => createAlert(panel, panelId),
});
}
@@ -197,6 +214,7 @@ export function usePanelActionItems({
editChecks,
editDisabledTooltip,
panelCapabilities,
isAIPanel,
panel,
panelActions,
sections,

View File

@@ -60,7 +60,7 @@ function QueryViewModalBody({
setSpec,
panelDefinition,
signal,
queryType,
queryMode,
query,
runQuery,
resetQuery,
@@ -152,7 +152,7 @@ function QueryViewModalBody({
}}
onSwitchToEdit={onSwitchToEdit}
panelKind={draft.spec.plugin.kind}
queryType={queryType}
queryMode={queryMode}
signal={signal}
onChangePanelKind={onChangePanelKind}
onResetQuery={resetQuery}

View File

@@ -9,6 +9,7 @@ import type {
} from 'container/TopNav/DateTimeSelectionV2/types';
import { usePanelTypeSelectItems } from 'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems';
import ConfigSelect from 'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/controls/ConfigSelect/ConfigSelect';
import type { PanelQueryMode } from '../../../Panels/types/queryModes';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { EQueryType } from 'types/common/dashboard';
@@ -41,7 +42,7 @@ interface QueryViewModalHeaderProps extends ViewPanelModalHeaderBaseProps {
* selector greys out kinds that can't be authored in it — e.g. List is
* Query-Builder-only, so PromQL/ClickHouse disable it.
*/
queryType: EQueryType;
queryMode: PanelQueryMode;
/** Current builder datasource — greys out kinds that don't support it (e.g. List needs logs/traces, not metrics). */
signal: TelemetrytypesSignalDTO;
/** Restore the saved query + kind (drilldown reset). */
@@ -74,7 +75,7 @@ function ViewPanelModalHeader(props: 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: query?.queryType ?? EQueryType.QUERY_BUILDER,
mode: query?.queryMode ?? EQueryType.QUERY_BUILDER,
signal: query?.signal,
});

View File

@@ -13,12 +13,13 @@ import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/Dashboa
import { toPanelType } 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 { getQueryMode } from 'pages/DashboardPage/DashboardContainer/Panels/utils/queryMode';
import { fromPerses } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
import {
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import type { EQueryType } from 'types/common/dashboard';
import type { PanelQueryMode } from 'pages/DashboardPage/DashboardContainer/Panels/types/queryModes';
interface UseViewPanelModeArgs {
panel: DashboardtypesPanelDTO;
@@ -43,7 +44,7 @@ export interface UseViewPanelModeReturn {
*/
signal: TelemetrytypesSignalDTO;
/** Active query type (selected builder tab) — drives the panel-type selector's disabled rule. */
queryType: EQueryType;
queryMode: PanelQueryMode;
/** Query result for the draft over the per-view window. */
query: UsePanelQueryResult;
/** Stage & run the live builder query into the draft (drilldown; not persisted). */
@@ -132,7 +133,7 @@ export function useViewPanelMode({
setSpec,
panelDefinition,
signal,
queryType: currentQuery.queryType,
queryMode: getQueryMode(currentQuery),
query,
runQuery,
resetQuery,

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

@@ -104,6 +104,23 @@ describe('toQueryEnvelopes', () => {
);
});
it('wraps a bare AI builder plugin as a builder_ai_query envelope', () => {
const ai = [
{
kind: 'TimeSeriesQuery',
spec: {
plugin: {
kind: 'signoz/AIBuilderQuery',
spec: { name: 'A', signal: 'traces' },
},
},
},
] as unknown as DashboardtypesQueryDTO[];
expect(toQueryEnvelopes(ai)).toStrictEqual([
{ type: 'builder_ai_query', spec: { name: 'A', signal: 'traces' } },
]);
});
it('wraps PromQL and ClickHouse plugins with their envelope types', () => {
const prom = [
{

View File

@@ -0,0 +1,58 @@
import type { Querybuildertypesv5QueryEnvelopeDTO } from 'api/generated/services/sigNoz.schemas';
import {
isAIBuilderEnvelope,
isBuilderEnvelope,
isBuilderPluginKind,
} from '../builderEnvelope';
// Only `type` is read; the generated envelope union erases spec to unknown anyway.
const envelope = (type: string): Querybuildertypesv5QueryEnvelopeDTO =>
({ type, spec: {} }) as unknown as Querybuildertypesv5QueryEnvelopeDTO;
describe('builder envelope predicates', () => {
describe('isBuilderEnvelope', () => {
it.each(['builder_query', 'builder_ai_query'])(
'accepts %s — both carry a builder query spec',
(type) => {
expect(isBuilderEnvelope(envelope(type))).toBe(true);
},
);
// Formula and TraceOperator carry no signal and reference other queries by name.
it.each([
'builder_formula',
'builder_trace_operator',
'promql',
'clickhouse_sql',
])('rejects %s', (type) => {
expect(isBuilderEnvelope(envelope(type))).toBe(false);
});
});
describe('isAIBuilderEnvelope', () => {
it('accepts builder_ai_query', () => {
expect(isAIBuilderEnvelope(envelope('builder_ai_query'))).toBe(true);
});
it('rejects builder_query, which is the whole point of the narrower check', () => {
expect(isAIBuilderEnvelope(envelope('builder_query'))).toBe(false);
});
});
describe('isBuilderPluginKind', () => {
it.each(['signoz/BuilderQuery', 'signoz/AIBuilderQuery'])(
'accepts %s — both wrap a builder query spec directly',
(kind) => {
expect(isBuilderPluginKind(kind)).toBe(true);
},
);
it.each(['signoz/CompositeQuery', 'signoz/PromQLQuery'])(
'rejects %s',
(kind) => {
expect(isBuilderPluginKind(kind)).toBe(false);
},
);
});
});

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,24 @@ describe('persesQueryAdapters', () => {
);
});
it('emits a bare signoz/AIBuilderQuery for an AI List panel', () => {
const result = toPerses(initialQueryAIWithType, PANEL_TYPES.LIST);
expect(result).toHaveLength(1);
expect(result[0].spec.plugin.kind).toBe('signoz/AIBuilderQuery');
});
// List rejects CompositeQuery backend-side, so the plugin kind is the only place
// the AI-ness can survive a save.
it('preserves an AI List query through toPerses → fromPerses', () => {
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

@@ -11,12 +11,14 @@ import type {
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5OrderDirectionDTO,
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
} from 'api/generated/services/sigNoz.schemas';
import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities';
import { isBuilderEnvelope } from './builderEnvelope';
// Narrow view over the envelope spec variants. Orval erases envelope `spec` to `unknown`, so
// shared fields are read through this view with a localized cast at the envelope boundary.
@@ -58,6 +60,14 @@ export function toQueryEnvelopes(
spec: plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO,
},
];
case 'signoz/AIBuilderQuery':
// Same wire shape; the widening is only orval's separate `signal` enum, which TS treats nominally.
return [
{
type: Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query,
spec: plugin.spec,
} as unknown as Querybuildertypesv5QueryEnvelopeDTO,
];
case 'signoz/PromQLQuery':
return [
{
@@ -125,13 +135,10 @@ 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) {
if ((envelope.spec as QuerySpecView | undefined)?.stepInterval) {
return envelope;
}
return {
@@ -139,8 +146,8 @@ function withBarStepInterval(
spec: {
...envelope.spec,
stepInterval,
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
},
} as Querybuildertypesv5QueryEnvelopeDTO;
});
}
@@ -153,10 +160,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;
@@ -181,8 +185,8 @@ function withListOrderTiebreaker(
...primary,
{ key: { name: 'id' }, direction: primary[0].direction },
],
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
},
} as Querybuildertypesv5QueryEnvelopeDTO;
});
}
@@ -195,10 +199,7 @@ function withPagination(
{ offset, limit }: { offset: number; limit: number },
): Querybuildertypesv5QueryEnvelopeDTO[] {
return envelopes.map((envelope) => {
if (
envelope.type !==
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
) {
if (!isBuilderEnvelope(envelope)) {
return envelope;
}
return {
@@ -207,8 +208,8 @@ function withPagination(
...envelope.spec,
offset,
limit,
} as Querybuildertypesv5BuilderQuerySpecDTO,
};
},
} as Querybuildertypesv5QueryEnvelopeDTO;
});
}
@@ -301,11 +302,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,35 @@
import type { Querybuildertypesv5QueryEnvelopeDTO } from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesQueryPluginKindDTO,
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
} from 'api/generated/services/sigNoz.schemas';
const BUILDER_ENVELOPE_TYPES: string[] = [
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query,
];
export function isBuilderEnvelope(
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): boolean {
return BUILDER_ENVELOPE_TYPES.includes(envelope.type);
}
export function isAIBuilderEnvelope(
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): boolean {
return (
envelope.type ===
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query
);
}
const BUILDER_PLUGIN_KINDS: string[] = [
DashboardtypesQueryPluginKindDTO['signoz/BuilderQuery'],
DashboardtypesQueryPluginKindDTO['signoz/AIBuilderQuery'],
];
export function isBuilderPluginKind(kind: string): boolean {
return BUILDER_PLUGIN_KINDS.includes(kind);
}

View File

@@ -1,13 +1,14 @@
import type {
DashboardtypesAIBuilderQuerySpecDTO,
DashboardtypesBuilderQuerySpecDTO,
DashboardtypesQueryDTO,
Querybuildertypesv5CompositeQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind as AIBuilderQueryPluginKind,
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind as BuilderQueryPluginKind,
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind as CompositeQueryPluginKind,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
Querybuildertypesv5QueryEnvelopePromQLDTOType,
Querybuildertypesv5RequestTypeDTO,
@@ -21,6 +22,7 @@ import type { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { isAIBuilderEnvelope, isBuilderEnvelope } from './builderEnvelope';
import { toQueryEnvelopes } from './buildQueryRangeRequest';
/**
@@ -44,11 +46,6 @@ const toGeneratedEnvelopes = (
): Querybuildertypesv5QueryEnvelopeDTO[] =>
envelopes as unknown as Querybuildertypesv5QueryEnvelopeDTO[];
const isBuilderQueryEnvelope = (
envelope: Querybuildertypesv5QueryEnvelopeDTO,
): boolean =>
envelope.type === Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query;
/**
* Clears the V1 explorer's `pageSize`/`offset` before conversion — the shared mapper folds
* `pageSize` into the V5 `limit`, which usePanelQuery would read as a user cap and hide the
@@ -155,8 +152,9 @@ export function fromPerses(
/**
* V1 `Query` → perses panel queries (to write the builder result back to the editor
* draft). Wrapped in a single `signoz/CompositeQuery` to satisfy the
* `panel.queries.length === 1` invariant. Exception: List emits its one builder query
* as a bare `signoz/BuilderQuery` because the backend rejects a `signoz/CompositeQuery`.
* `panel.queries.length === 1` invariant. Exception: List rejects `signoz/CompositeQuery`
* backend-side, so it emits its one builder query as the bare plugin matching the query's
* own kind — a bare plugin carries no envelope `type`, so the kind is what preserves it.
*/
export function toPerses(
query: Query,
@@ -170,21 +168,24 @@ 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 [];
}
// Envelope `spec` is undiscriminated, so narrow it to the spec its plugin kind declares.
const plugin = isAIBuilderEnvelope(builder)
? {
kind: AIBuilderQueryPluginKind['signoz/AIBuilderQuery'],
spec: builder.spec as unknown as DashboardtypesAIBuilderQuerySpecDTO,
}
: {
kind: BuilderQueryPluginKind['signoz/BuilderQuery'],
spec: builder.spec as DashboardtypesBuilderQuerySpecDTO,
};
return [
{
kind: panelTypeToRequestType(panelType),
spec: {
plugin: {
kind: BuilderQueryPluginKind['signoz/BuilderQuery'],
// The generated envelope union doesn't discriminate `spec` by `type`, so
// narrow the filtered builder query to the dashboard builder spec.
spec: builder.spec as DashboardtypesBuilderQuerySpecDTO,
},
},
spec: { plugin },
},
];
}

View File

@@ -3,10 +3,9 @@ import type {
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5ScalarDataDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
} from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType } from 'api/generated/services/sigNoz.schemas';
import { isBuilderEnvelope } from './builderEnvelope';
import type { PanelTable, PanelTableColumn } from './types';
@@ -28,13 +27,12 @@ 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;
const spec = envelope.spec as
| { name?: string; aggregations?: unknown }
| undefined;
if (spec?.name && spec.aggregations) {
perQuery[spec.name] = spec.aggregations as AggregationView[];
}

View File

@@ -1,7 +1,7 @@
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { isQueryTypeSupportedByPanelKind } from '../DashboardContainer/Panels/capabilities';
import { isQueryModeSupportedByPanelKind } from '../DashboardContainer/Panels/capabilities';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { SectionKind } from '../DashboardContainer/Panels/types/sections';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
@@ -22,7 +22,7 @@ jest.mock('../DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
}));
jest.mock('../DashboardContainer/Panels/capabilities', () => ({
isQueryTypeSupportedByPanelKind: jest.fn(),
isQueryModeSupportedByPanelKind: jest.fn(),
// Real predicate: these specs exercise query kinds; the static guard has its
// own case below.
isStaticPanelKind: jest.requireActual(
@@ -34,7 +34,7 @@ const mockToPerses = toPerses as jest.Mock;
const mockBuildDefaultQueries = buildDefaultQueries as jest.Mock;
const mockBuildPluginSpec = buildPluginSpec as jest.Mock;
const mockGetPanelDefinition = getPanelDefinition as jest.Mock;
const mockIsQueryTypeSupported = isQueryTypeSupportedByPanelKind as jest.Mock;
const mockIsQueryModeSupported = isQueryModeSupportedByPanelKind as jest.Mock;
const DEFAULT_QUERIES = [{ kind: 'default' }];
const CONVERTED_QUERIES = [{ kind: 'converted' }];
@@ -54,7 +54,7 @@ describe('buildNewPanelSeed', () => {
mockBuildDefaultQueries.mockReturnValue(DEFAULT_QUERIES);
mockBuildPluginSpec.mockReturnValue(BASE_SPEC);
mockGetPanelDefinition.mockReturnValue({ sections: withUnit });
mockIsQueryTypeSupported.mockReturnValue(true);
mockIsQueryModeSupported.mockReturnValue(true);
});
it('uses the kind default seed when it is not an explorer export', () => {
@@ -77,7 +77,7 @@ describe('buildNewPanelSeed', () => {
});
it('coerces a builder-only kind to Table for a ClickHouse query', () => {
mockIsQueryTypeSupported.mockReturnValue(false);
mockIsQueryModeSupported.mockReturnValue(false);
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
const seed = buildNewPanelSeed(
@@ -91,7 +91,7 @@ describe('buildNewPanelSeed', () => {
});
it('coerces a builder-only kind to TimeSeries for a PromQL query', () => {
mockIsQueryTypeSupported.mockReturnValue(false);
mockIsQueryModeSupported.mockReturnValue(false);
mockToPerses.mockReturnValue(CONVERTED_QUERIES);
const seed = buildNewPanelSeed(

View File

@@ -4,13 +4,14 @@ import { EQueryType } from 'types/common/dashboard';
import {
isStaticPanelKind,
isQueryTypeSupportedByPanelKind,
isQueryModeSupportedByPanelKind,
} from '../DashboardContainer/Panels/capabilities';
import { getPanelDefinition } from '../DashboardContainer/Panels/registry';
import { toPanelType } from '../DashboardContainer/Panels/types/panelKind';
import type { PanelKind } from '../DashboardContainer/Panels/types/panelKind';
import { SectionKind } from '../DashboardContainer/Panels/types/sections';
import { buildDefaultQueries } from '../DashboardContainer/Panels/utils/buildDefaultQueries';
import { getQueryMode } from '../DashboardContainer/Panels/utils/queryMode';
import {
buildPluginSpec,
type SeededPluginSpec,
@@ -36,7 +37,9 @@ function resolveSeededPanelKind(
requestedKind: PanelKind,
compositeQuery: Query,
): PanelKind {
if (isQueryTypeSupportedByPanelKind(requestedKind, compositeQuery.queryType)) {
if (
isQueryModeSupportedByPanelKind(requestedKind, getQueryMode(compositeQuery))
) {
return requestedKind;
}
return (

View File

@@ -157,6 +157,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

@@ -8,6 +8,7 @@ import (
"testing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/spec/go/dashboard"
"github.com/stretchr/testify/assert"
@@ -1618,6 +1619,43 @@ func TestStorageRoundTrip(t *testing.T) {
assert.Contains(t, responseStr, `"operator":"above"`, "expected operator:above after storage round-trip")
}
// An AI builder query carries no signal of its own: the plugin kind implies
// gen_ai, which only reads traces, so decode pins the signal and marshal emits it.
func TestAIBuilderQueryStorageRoundTrip(t *testing.T) {
input := []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": []
}`)
d, err := unmarshalDashboard(input)
require.NoError(t, err)
plugin := d.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)
stored, err := json.Marshal(plugin)
require.NoError(t, err)
assert.Contains(t, string(stored), `"kind":"signoz/AIBuilderQuery"`)
assert.Contains(t, string(stored), `"signal":"traces"`)
var loaded QueryPlugin
require.NoError(t, json.Unmarshal(stored, &loaded))
assert.Equal(t, plugin, loaded)
}
func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
const validSpec = `"spec": {"variables": [], "panels": {}, "layouts": [], "links": []}`
@@ -1830,6 +1868,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},
@@ -1839,6 +1879,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

@@ -91,6 +91,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"),
@@ -120,6 +121,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)},
@@ -140,6 +142,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:
@@ -234,6 +241,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) },
@@ -246,13 +254,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},
PanelKindText: {},
}
)

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

@@ -5,6 +5,7 @@ import (
"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"
)
@@ -159,6 +160,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"}},
@@ -209,6 +215,42 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
}
})
// The gen_ai statement builder only reads traces, so an AI builder query
// carries no signal of its own and unwraps to a traces builder query.
t.Run("unwraps an AI builder query to a traces builder query", func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: PanelKindTimeSeries},
Queries: []Query{
{
Kind: qb.RequestTypeTimeSeries,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindAIBuilder,
Spec: &AIBuilderQuerySpec{Name: "A", Signal: telemetrytypes.SignalTraces},
},
},
},
},
},
},
},
},
}
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.NoError(t, err)
require.Len(t, req.CompositeQuery.Queries, 1)
spec, ok := req.CompositeQuery.Queries[0].Spec.(qb.QueryBuilderQuery[qb.TraceAggregation])
require.True(t, ok, "expected traces builder query, got %T", req.CompositeQuery.Queries[0].Spec)
assert.Equal(t, "A", spec.Name)
assert.Equal(t, telemetrytypes.SignalTraces, spec.Signal)
})
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{

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
// ══════════════════════════════════════════════