Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion src/pages/alertRules/Form/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jest.mock('lodash', () => {
});

import _ from 'lodash';
import { processInitialValues, parseTimeToValueAndUnit } from './utils';
import { processInitialValues, processFormValues, parseTimeToValueAndUnit } from './utils';

// ---------- dependency mocks ----------

Expand Down Expand Up @@ -261,3 +261,67 @@ describe('processInitialValues', () => {
expect(result.callbacks).toEqual([{ url: 'http://a.com' }, { url: 'http://b.com' }]);
});
});

// ---------- processFormValues 回归测试 ----------

const baseFormValues = {
effective_time: [
{
enable_days_of_week: ['0', '1', '2', '3', '4', '5', '6'],
enable_stime: { format: jest.fn().mockReturnValue('00:00') },
enable_etime: { format: jest.fn().mockReturnValue('23:59') },
},
],
};

describe('processFormValues', () => {
/**
* 核心回归测试:editMode 记录 SQL 的 Builder/Code 视图状态,需随规则配置保存,
* 再次编辑时才能还原视图;builderConfig 是 Builder 的草稿配置,SQL 才是编译产物,不落库
*/
it('ES SQL 查询应保留 editMode 并剥离 builderConfig', () => {
const result = processFormValues({
cate: 'elasticsearch',
rule_config: {
queries: [
{
ref: 'A',
syntax: 'sql',
editMode: 'builder',
builderConfig: { index: 'logs', date_field: '@timestamp' },
sql: 'SELECT count(*) FROM logs',
interval: 5,
interval_unit: 'min',
},
],
},
...baseFormValues,
});
const query = result.rule_config.queries[0];
expect(query.editMode).toBe('builder');
expect(query.builderConfig).toBeUndefined();
expect(query.sql).toBe('SELECT count(*) FROM logs');
expect(query.interval).toBe(300);
expect(query.interval_unit).toBeUndefined();
});

it('ES DSL 查询仍应清除 SQL 模式遗留的 sql/keys', () => {
const result = processFormValues({
cate: 'elasticsearch',
rule_config: {
queries: [
{
ref: 'A',
syntax: 'dsl',
sql: 'SELECT 1',
keys: { valueKey: 'count' },
},
],
},
...baseFormValues,
});
const query = result.rule_config.queries[0];
expect(query.sql).toBeUndefined();
expect(query.keys).toBeUndefined();
});
});
10 changes: 9 additions & 1 deletion src/pages/alertRules/Form/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export function processFormValues(values) {
}
if (values?.rule_config?.queries) {
values.rule_config.queries = _.map(values.rule_config.queries, (item) => {
const isESDSLQuery = ['elasticsearch', 'opensearch'].includes(values.cate) && item.syntax !== 'sql';
let parsedRange;
if (item.range) {
parsedRange = mapOptionToRelativeTimeRange(item.range);
Expand All @@ -110,8 +111,15 @@ export function processFormValues(values) {
if (_.isArray(item?.keys?.metricKey)) {
item.keys.metricKey = _.join(item.keys.metricKey, ' ');
}
// 提交前剥离表单态字段:
// - interval_unit/range 换算成引擎消费的秒数和 from/to;
// - builderConfig 是 Builder 的草稿配置,SQL 才是编译产物,不落库;
// - editMode 记录 SQL 的 Builder/Code 视图状态,需随规则配置保存,再次编辑时才能还原;
// 查询接口(数据预览、模拟触发等)不消费它,由查询侧自行构造 payload。
// ES/OpenSearch 的 DSL 查询(syntax !== 'sql')还会清除 SQL 模式遗留的 sql/keys,
// 避免两套语义并存。
return {
..._.omit(item, ['interval_unit', 'range']),
..._.omit(item, ['interval_unit', 'range', 'builderConfig', ...(isESDSLQuery ? ['sql', 'keys'] : [])]),
interval: item.interval_unit ? normalizeTime(item.interval, item.interval_unit) : undefined,
from: parsedRange?.start,
to: parsedRange?.end,
Expand Down
26 changes: 24 additions & 2 deletions src/pages/dashboard/Detail/utils/dashboardMigrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,28 @@ describe('dashboard v4 migration', () => {
expect(migrated.panels[0].targets[0].query).not.toHaveProperty('syntax');
});

it('preserves the Elasticsearch SQL query mode', () => {
const migrated = dashboardMigrator({
version: '4.0.0',
panels: [{
id: 'panel-es-sql',
version: '4.0.0',
type: 'tableNG',
datasourceCate: 'elasticsearch',
datasourceValue: 12,
targets: [{
refId: 'A',
kind: 'query',
query: { syntax: 'sql', mode: 'timeSeries', sql: 'SELECT time, value FROM logs', keys: { valueKey: ['value'], timeKey: 'time' } },
}],
custom: {},
options: {},
}],
});

expect(migrated.panels[0].targets[0].query).toMatchObject({ syntax: 'sql', sql: 'SELECT time, value FROM logs' });
});

it('is idempotent', () => {
const once = dashboardMigrator({
version: '3.4.0',
Expand Down Expand Up @@ -344,9 +366,9 @@ describe('dashboard v4 migration', () => {

expect(migrated.options?.thresholds).toEqual({ mode: 'percentage' });
expect(migrated.targets).toMatchObject([
{ refId: 'A', resultType: 'logs', query: { filter_language: 'lucene' } },
{ refId: 'A', resultType: 'logs', query: { syntax: 'sql', mode: 'logs' } },
{ refId: 'B', resultType: 'logs' },
]);
expect(migrated.targets[0].query).not.toHaveProperty('syntax');
expect(migrated.targets[0].query).not.toHaveProperty('filter_language');
});
});
9 changes: 7 additions & 2 deletions src/pages/dashboard/Detail/utils/dashboardMigrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,13 @@ const migratePanelToV4 = (panel: LegacyPanel): LegacyPanel => {
}
const datasourceCate = targetCopy.datasource?.cate ?? panelCopy.datasourceCate;
if ((datasourceCate === 'elasticsearch' || datasourceCate === 'opensearch') && targetCopy.query) {
targetCopy.query.filter_language = targetCopy.query.filter_language ?? (targetCopy.query.syntax === 'kuery' || targetCopy.query.syntax === 'kql' ? 'kql' : 'lucene');
delete targetCopy.query.syntax;
// SQL is a persisted query mode. Only migrate the legacy DSL syntax
// field into filter_language; otherwise an ES SQL dashboard reopens in
// DSL mode after every load.
if (targetCopy.query.syntax !== 'sql') {
targetCopy.query.filter_language = targetCopy.query.filter_language ?? (targetCopy.query.syntax === 'kuery' || targetCopy.query.syntax === 'kql' ? 'kql' : 'lucene');
delete targetCopy.query.syntax;
}
}
targetCopy.resultType = inferTargetResultType(targetCopy);
}
Expand Down
121 changes: 118 additions & 3 deletions src/pages/dashboard/Editor/QueryEditor/Elasticsearch/QueryPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Form, Row, Col, Input, InputNumber, Space, Select, Tooltip, Radio } from 'antd';
import React, { useState, useEffect, useMemo, useContext } from 'react';
import { Form, Row, Col, Input, InputNumber, Space, Select, Tooltip, Radio, Segmented, Button, Modal } from 'antd';
import { DeleteOutlined, InfoCircleOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { FormListFieldData, FormListOperation } from 'antd/lib/form/FormList';
import _ from 'lodash';
import { useTranslation } from 'react-i18next';

import QueryExtraActions from '@/pages/dashboard/Components/QueryExtraActions';
import { IS_PLUS } from '@/utils/constant';
import { IS_PLUS, DatasourceCateEnum } from '@/utils/constant';
import { generateQueryNameByIndex } from '@/components/QueryName/utils';
import InputGroupWithFormItem from '@/components/InputGroupWithFormItem';
import LegendInput from '@/pages/dashboard/Components/LegendInput';
import { getESIndexPatterns } from '@/pages/log/IndexPatterns/services';
import { getESClusterInfo } from '@/plugins/elasticsearch/services';
import { SqlMonacoEditor, SqlMonacoPreview } from '@fc-components/monaco-editor';
import SQLBuilderModal from '@/plugins/elasticsearch/components/SQLBuilderModal';
import { CommonStateContext } from '@/App';

import { Panel } from '../../Components/Collapse';
import DateField from './DateField';
Expand All @@ -21,6 +25,8 @@ import Time from './Time';
import IndexPatternSelect from './IndexPatternSelect';
import type { ElasticsearchIndexPattern } from './types';

const DEFAULT_SQL_BUILDER_INTERVAL_SECONDS = 3600;

interface Props {
fields: FormListFieldData[];
field: FormListFieldData;
Expand All @@ -32,9 +38,13 @@ interface Props {

export default function QueryPanel({ fields, field, index, add, remove, datasourceValue }: Props) {
const { t } = useTranslation('dashboard');
const { t: tES } = useTranslation('elasticsearch');
const { darkMode } = useContext(CommonStateContext);
const [indexPatterns, setIndexPatterns] = useState<ElasticsearchIndexPattern[]>([]);
const [supportsSQL, setSupportsSQL] = useState(false);
const prefixName = ['targets', field.name];
const datasourceCate = Form.useWatch('datasourceCate');
const esDatasourceCate = datasourceCate === DatasourceCateEnum.opensearch ? DatasourceCateEnum.opensearch : DatasourceCateEnum.elasticsearch;
const refId = Form.useWatch([...prefixName, 'refId']) || generateQueryNameByIndex(index);
const indexType = Form.useWatch([...prefixName, 'query', 'index_type']);
const indexValue = Form.useWatch([...prefixName, 'query', 'index']);
Expand All @@ -54,6 +64,12 @@ export default function QueryPanel({ fields, field, index, add, remove, datasour
};
}, [indexType, indexValue, indexPatternId, indexPatterns, dateField]);
const targetQueryValues = Form.useWatch([...prefixName, 'query', 'values']);
const savedQuerySyntax = Form.useWatch([...prefixName, 'query', 'syntax']);
const querySyntax = supportsSQL && savedQuerySyntax === 'sql' ? 'sql' : 'dsl';
const targetQuery = Form.useWatch([...prefixName, 'query']);
const editMode = targetQuery?.editMode ?? 'code';
const [builderModalVisible, setBuilderModalVisible] = useState(false);
const form = Form.useFormInstance();
const isRawData = _.get(targetQueryValues, [0, 'func']) === 'rawData';
const { key: fieldKey, ...restField } = field;

Expand All @@ -65,6 +81,14 @@ export default function QueryPanel({ fields, field, index, add, remove, datasour
}
}, [datasourceValue]);

useEffect(() => {
setSupportsSQL(false);
if (!IS_PLUS || !datasourceValue) return;
getESClusterInfo({ cate: esDatasourceCate, datasource_id: datasourceValue })
.then((info) => setSupportsSQL(info?.is_sql_supported ?? false))
.catch(() => setSupportsSQL(false));
}, [datasourceValue, esDatasourceCate]);
Comment on lines +84 to +90

return (
<Panel
header={refId}
Expand All @@ -85,6 +109,96 @@ export default function QueryPanel({ fields, field, index, add, remove, datasour
<Form.Item noStyle {...restField} name={[field.name, 'refId']} hidden>
<input type='hidden' />
</Form.Item>
{supportsSQL && (
<div className='mb-3 flex items-center justify-between'>
<Space size={8}>
<Form.Item {...restField} name={[field.name, 'query', 'syntax']} initialValue='dsl' noStyle>
<Segmented size='small' options={[{ label: 'DSL', value: 'dsl' }, { label: 'SQL', value: 'sql' }]} />
</Form.Item>
{querySyntax === 'sql' && (
<Segmented
size='small'
value={editMode}
options={[{ label: 'Builder', value: 'builder' }, { label: 'Code', value: 'code' }]}
onChange={(value) => {
// 从 Code 切到 Builder 且已有 SQL 时需确认丢弃:清空 sql 和 builderConfig,
// 强制用 Builder 重新生成,避免残留配置生成的 SQL 与当前手写 SQL 不一致。
if (value === 'builder' && editMode === 'code' && targetQuery?.sql) {
Modal.confirm({ title: tES('builder.switch_to_builder_confirm_title'), content: tES('builder.switch_to_builder_confirm_content'), onOk: () => form.setFields([
{ name: [...prefixName, 'query', 'editMode'], value: 'builder' },
{ name: [...prefixName, 'query', 'sql'], value: undefined },
{ name: [...prefixName, 'query', 'builderConfig'], value: undefined },
]) });
return;
}
form.setFields([{ name: [...prefixName, 'query', 'editMode'], value }]);
}}
/>
)}
</Space>
{querySyntax === 'sql' && (
<Form.Item {...restField} name={[field.name, 'query', 'mode']} initialValue='timeSeries' noStyle hidden={editMode === 'builder'}>
<Select size='small'>
<Select.Option value='timeSeries'>{tES('query.dashboard.mode.timeSeries')}</Select.Option>
<Select.Option value='raw'>{tES('query.dashboard.mode.table')}</Select.Option>
</Select>
</Form.Item>
)}
</div>
)}
{querySyntax === 'sql' ? (
<>
<Form.Item {...restField} name={[field.name, 'query', 'editMode']} initialValue='code' hidden><input type='hidden' /></Form.Item>
{editMode === 'builder' && targetQuery?.sql && <div className={`p-3 rounded max-h-[160px] overflow-y-auto mb-4 ${darkMode ? 'bg-gray-800' : 'bg-gray-100'}`}><SqlMonacoPreview theme={darkMode ? 'dark' : 'light'} value={targetQuery.sql} /></div>}
{editMode === 'builder' && <Button className='mb-3' disabled={!datasourceValue} onClick={() => setBuilderModalVisible(true)}>{tES('builder.open_builder')}</Button>}
{editMode === 'code' && <Form.Item {...restField} name={[field.name, 'query', 'sql']} label='SQL' rules={[{ required: true, message: tES('query.sql_required') }]}>
<SqlMonacoEditor maxHeight={200} enableAutocomplete enableFormat />
</Form.Item>}
{editMode === 'builder' && <SQLBuilderModal
visible={builderModalVisible}
datasourceValue={datasourceValue}
interval={DEFAULT_SQL_BUILDER_INTERVAL_SECONDS}
builderConfig={targetQuery?.builderConfig}
onCancel={() => setBuilderModalVisible(false)}
onConfirm={(builderConfig, result) => {
form.setFields([
{ name: [...prefixName, 'query', 'sql'], value: result.sql, errors: [] },
{ name: [...prefixName, 'query', 'builderConfig'], value: builderConfig, errors: [] },
{ name: [...prefixName, 'query', 'mode'], value: result.mode === 'timeseries' ? 'timeSeries' : 'raw' },
{ name: [...prefixName, 'query', 'keys', 'valueKey'], value: result.value_key },
{ name: [...prefixName, 'query', 'keys', 'labelKey'], value: result.label_key },
]);
setBuilderModalVisible(false);
}}
/>}
<Row gutter={10}>
<Col span={12}>
<Form.Item
{...restField}
name={[field.name, 'query', 'keys', 'valueKey']}
label={<Space>{tES('query.advancedSettings.valueKey')}<Tooltip title={tES('query.advancedSettings.valueKey_tip')}><QuestionCircleOutlined /></Tooltip></Space>}
rules={[{ required: true, message: tES('query.advancedSettings.valueKey_required') }]}
>
<Select mode='tags' tokenSeparators={[' ']} placeholder={tES('query.advancedSettings.tags_placeholder')} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
{...restField}
name={[field.name, 'query', 'keys', 'labelKey']}
label={<Space>{tES('query.advancedSettings.labelKey')}<Tooltip title={tES('query.advancedSettings.labelKey_tip')}><QuestionCircleOutlined /></Tooltip></Space>}
>
<Select mode='tags' tokenSeparators={[' ']} placeholder={tES('query.advancedSettings.tags_placeholder')} />
</Form.Item>
</Col>
</Row>
{IS_PLUS && (
<Form.Item label='Legend' {...restField} name={[field.name, 'legend']}>
<LegendInput />
</Form.Item>
)}
</>
) : <>
<Form.Item {...restField} name={[field.name, 'query', 'index_type']} initialValue='index'>
<Radio.Group>
<Radio value='index'>{t('datasource:es.index')}</Radio>
Expand Down Expand Up @@ -198,6 +312,7 @@ export default function QueryPanel({ fields, field, index, add, remove, datasour
<LegendInput />
</Form.Item>
)}
</>}
</Panel>
);
}
48 changes: 48 additions & 0 deletions src/pages/dashboard/Renderer/datasource/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,54 @@ describe('dashboard unified query contract', () => {
expect(JSON.stringify(request)).not.toContain('"values"');
});

it('builds Elasticsearch SQL requests for timeseries and raw data modes', () => {
const options = {
time: {
start: moment('2026-07-24T00:00:00.000Z'),
end: moment('2026-07-24T01:00:00.000Z'),
},
datasourceList: [],
};

const timeseriesRequest = buildDashboardQueryRequest({
...options,
targets: [{
refId: 'A',
kind: 'query',
datasource: { cate: 'elasticsearch', id: 12 },
query: {
syntax: 'sql',
mode: 'timeSeries',
sql: 'SELECT time, value FROM logs',
keys: { valueKey: ['value'], timeKey: 'time' },
builderConfig: { index: 'logs', date_field: '@timestamp' },
},
}],
});
expect(timeseriesRequest.queries).toMatchObject([{
ref_id: 'A',
result_type: 'time_series',
query: { syntax: 'sql', sql: 'SELECT time, value FROM logs', keys: { valueKey: 'value', timeKey: 'time' } },
}]);
const timeseriesQuery = timeseriesRequest.queries[0]?.kind === 'query' ? timeseriesRequest.queries[0].query : {};
expect(timeseriesQuery).not.toHaveProperty('builderConfig');

const rawRequest = buildDashboardQueryRequest({
...options,
targets: [{
refId: 'B',
kind: 'query',
datasource: { cate: 'elasticsearch', id: 12 },
query: { syntax: 'sql', mode: 'raw', sql: 'SELECT * FROM logs' },
}],
});
expect(rawRequest.queries).toMatchObject([{
ref_id: 'B',
result_type: 'logs',
query: { sql: 'SELECT * FROM logs' },
}]);
});

it('normalizes Elasticsearch and OpenSearch interval values for query-batch v2', () => {
const request = buildDashboardQueryRequest({
time: {
Expand Down
Loading