From 83b519cc603881a4d9376e9a023aaad55afbf1e0 Mon Sep 17 00:00:00 2001 From: jsers Date: Mon, 7 Sep 2026 16:07:28 +0800 Subject: [PATCH 01/12] feat(dashboard): support label/value object options for query variables Query datasources can now return { label, value } objects alongside scalars. Extract a shared processQueryOptions module so the new Variables entry and the legacy VariableConfig entry keep their respective string-extraction regex semantics while both honoring object options: the regex matches the value, ordinary matches keep both fields, and named text/value groups override only that field. Also fix joinValues' no-separator branch to emit values instead of "[object Object]". --- .../dashboard/VariableConfig/EditItem.tsx | 13 +- .../dashboard/VariableConfig/constant.tsx | 48 +-- .../dashboard/VariableConfig/datasource.ts | 3 +- src/pages/dashboard/VariableConfig/index.tsx | 16 +- .../VariableConfig/processQueryOptions.ts | 54 +++ .../Variables/EditModal/Variable/Query.tsx | 14 +- .../dashboard/Variables/Variable/Query.tsx | 4 +- .../__tests__/queryOptions.jsdom.test.tsx | 337 ++++++++++++++++++ src/pages/dashboard/Variables/datasource.ts | 8 +- src/pages/dashboard/Variables/types.ts | 9 + .../__tests__/processQueryOptions.test.ts | 232 ++++++++++++ .../dashboard/Variables/utils/ajustData.ts | 2 +- .../Variables/utils/processQueryOptions.ts | 67 ++++ src/pages/dashboard/locale/en_US.ts | 2 + src/pages/dashboard/locale/es_ES.ts | 2 + src/pages/dashboard/locale/fr_FR.ts | 2 + src/pages/dashboard/locale/id_ID.ts | 2 + src/pages/dashboard/locale/ja_JP.ts | 1 + src/pages/dashboard/locale/ko_KR.ts | 1 + src/pages/dashboard/locale/pt_BR.ts | 2 + src/pages/dashboard/locale/ru_RU.ts | 2 + src/pages/dashboard/locale/zh_CN.ts | 1 + src/pages/dashboard/locale/zh_HK.ts | 1 + 23 files changed, 751 insertions(+), 72 deletions(-) create mode 100644 src/pages/dashboard/VariableConfig/processQueryOptions.ts create mode 100644 src/pages/dashboard/Variables/__tests__/queryOptions.jsdom.test.tsx create mode 100644 src/pages/dashboard/Variables/utils/__tests__/processQueryOptions.test.ts create mode 100644 src/pages/dashboard/Variables/utils/processQueryOptions.ts diff --git a/src/pages/dashboard/VariableConfig/EditItem.tsx b/src/pages/dashboard/VariableConfig/EditItem.tsx index 46e2763d86..29bacb9bfb 100644 --- a/src/pages/dashboard/VariableConfig/EditItem.tsx +++ b/src/pages/dashboard/VariableConfig/EditItem.tsx @@ -205,11 +205,14 @@ function EditItem(props: IProps) { label={t('var.reg')} name='reg' tooltip={ - }} - /> + <> + }} + /> +
{t('var.reg_object_tip')}
+ } rules={[{ pattern: new RegExp('^/(.*?)/(g?i?m?y?)$'), message: 'invalid regex' }]} > diff --git a/src/pages/dashboard/VariableConfig/constant.tsx b/src/pages/dashboard/VariableConfig/constant.tsx index c91d585f60..f6cca28f84 100644 --- a/src/pages/dashboard/VariableConfig/constant.tsx +++ b/src/pages/dashboard/VariableConfig/constant.tsx @@ -24,6 +24,7 @@ import { IRawTimeRange, parseRange } from '@/components/TimeRangePicker'; import { getDefaultStepByTime } from '../utils'; import { IVariable } from './definition'; +import processLegacyQueryOptions from './processQueryOptions'; import { normalizeESQueryRequestBody, ajustVarSingleValue, escapeJsonString, escapePromQLString } from './utils'; // https://grafana.com/docs/grafana/latest/datasources/prometheus/#query-variable 根据文档解析表达式 @@ -471,53 +472,12 @@ export const getOptionsList = (options: { variableConfigWithOptions?: any; time: ]; }; -export function filterOptionsByReg(options: string[], reg, formData: IVariable[], limit: number, id: string) { +export function filterOptionsByReg(options: unknown, reg: string | undefined, formData: IVariable[], limit: number, id: string) { reg = replaceExpressionVars({ - text: reg, + text: reg ?? '', variables: formData, limit, dashboardId: id, }); - const regex = stringToRegex(reg); - - if (reg && regex) { - const regFilterOptions: { - label: string; - value: string; - }[] = []; - _.forEach(options, (option) => { - if (!!option) { - const matchResult = option.match(regex); - if (matchResult) { - if (matchResult.groups) { - regFilterOptions.push({ - label: matchResult.groups?.text, - value: matchResult.groups?.value, - }); - } else if (matchResult.length > 0) { - if (matchResult[1]) { - regFilterOptions.push({ - label: matchResult[1], - value: matchResult[1], - }); - } else { - regFilterOptions.push({ - label: option, - value: option, - }); - } - } - } - } - }); - return _.unionBy(regFilterOptions, (item) => { - return `${item.label}-${item.value}`; - }); - } - return _.map(options, (item) => { - return { - label: item, - value: item, - }; - }); + return processLegacyQueryOptions(options, reg); } diff --git a/src/pages/dashboard/VariableConfig/datasource.ts b/src/pages/dashboard/VariableConfig/datasource.ts index 8775eb96e4..dc206d0c10 100644 --- a/src/pages/dashboard/VariableConfig/datasource.ts +++ b/src/pages/dashboard/VariableConfig/datasource.ts @@ -2,6 +2,7 @@ * 各数据源的查询方法 */ +import type { QueryOptionInput } from '@/pages/dashboard/Variables/types'; import { DatasourceCateEnum } from '@/utils/constant'; import clickHouse from '@/plugins/clickHouse/Dashboard/VariableDatasource'; @@ -18,7 +19,7 @@ export interface Props { variables: IVariable[]; } -export default async function datasource(props: Props) { +export default async function datasource(props: Props): Promise { const { datasourceCate } = props; if (datasourceCate === DatasourceCateEnum.ck) { return clickHouse(props as any); diff --git a/src/pages/dashboard/VariableConfig/index.tsx b/src/pages/dashboard/VariableConfig/index.tsx index cfd4e29342..1874c614e8 100644 --- a/src/pages/dashboard/VariableConfig/index.tsx +++ b/src/pages/dashboard/VariableConfig/index.tsx @@ -35,6 +35,7 @@ import { IVariable } from './definition'; import DisplayItem from './DisplayItem'; import EditItems from './EditItems'; import datasource from './datasource'; +import type { QueryOptionInput } from '../Variables/types'; import './index.less'; interface IProps { @@ -48,6 +49,13 @@ interface IProps { dashboard: Dashboard; } +function sortAndDeduplicateScalarOptions(options: QueryOptionInput[]) { + if (options.every((option) => typeof option === 'string' || typeof option === 'number' || typeof option === 'boolean')) { + return _.sortBy(_.uniq(options)); + } + return options; +} + function includes( source: { label: string; @@ -145,7 +153,7 @@ function index(props: IProps) { isEscapeJsonString: true, }) : item.definition; - let options: string[] = []; + let options: QueryOptionInput[] = []; /** * v8 @@ -167,8 +175,7 @@ function index(props: IProps) { id, groupedDatasourceList, ); - options = datasourceCate === 'prometheus' ? _.sortBy(_.uniq(options)) : _.uniq(options); - options = _.map(options, _.toString); // 2024-09-03 统一将选项转为字符串,以防一些数据返回非字符串类型,比如 ES 的 status: 200 + options = sortAndDeduplicateScalarOptions(options); } catch (error) { console.error(error); } @@ -185,8 +192,7 @@ function index(props: IProps) { definition, }, }); - options = _.sortBy(_.uniq(options)); - options = _.map(options, _.toString); + options = sortAndDeduplicateScalarOptions(options); } catch (error) { console.error(error); } diff --git a/src/pages/dashboard/VariableConfig/processQueryOptions.ts b/src/pages/dashboard/VariableConfig/processQueryOptions.ts new file mode 100644 index 0000000000..627d4cc685 --- /dev/null +++ b/src/pages/dashboard/VariableConfig/processQueryOptions.ts @@ -0,0 +1,54 @@ +import _ from 'lodash'; + +import { transformQueryOptions } from '../Variables/utils/processQueryOptions'; +import stringToRegex from '../Variables/utils/stringToRegex'; + +/** 调用前由旧入口解析正则中的变量,字符串保留旧版提取规则。 */ +export default function processLegacyQueryOptions(options: unknown, reg?: string) { + return transformQueryOptions(options, reg, (scalarOptions) => filterStringOptionsByReg(scalarOptions, reg)); +} + +function filterStringOptionsByReg(options: string[], reg?: string) { + const regex = stringToRegex(reg); + + if (reg && regex) { + const regFilterOptions: { + label: string; + value: string; + }[] = []; + _.forEach(options, (option) => { + if (!!option) { + const matchResult = option.match(regex); + if (matchResult) { + if (matchResult.groups) { + regFilterOptions.push({ + label: matchResult.groups?.text, + value: matchResult.groups?.value, + }); + } else if (matchResult.length > 0) { + if (matchResult[1]) { + regFilterOptions.push({ + label: matchResult[1], + value: matchResult[1], + }); + } else { + regFilterOptions.push({ + label: option, + value: option, + }); + } + } + } + } + }); + return _.unionBy(regFilterOptions, (item) => { + return `${item.label}-${item.value}`; + }); + } + return _.map(options, (item) => { + return { + label: item, + value: item, + }; + }); +} diff --git a/src/pages/dashboard/Variables/EditModal/Variable/Query.tsx b/src/pages/dashboard/Variables/EditModal/Variable/Query.tsx index 1385cf9ad2..265ae97ada 100644 --- a/src/pages/dashboard/Variables/EditModal/Variable/Query.tsx +++ b/src/pages/dashboard/Variables/EditModal/Variable/Query.tsx @@ -11,11 +11,11 @@ import { DatasourceSelectV3 } from '@/components/DatasourceSelect'; import DocumentDrawer from '@/components/DocumentDrawer'; import { useGlobalState } from '@/pages/dashboard/globalState'; -import { IVariable } from '../../types'; +import { IVariable, QueryOption } from '../../types'; import adjustData from '../../utils/ajustData'; import isPlaceholderQuoted from '../../utils/isPlaceholderQuoted'; import { formatString, formatDatasource } from '../../utils/formatString'; -import filterOptionsByReg from '../../utils/filterOptionsByReg'; +import processQueryOptions from '../../utils/processQueryOptions'; import { getBuiltInVariables } from '../../utils/replaceTemplateVariables'; import Querybuilder from '../Querybuilder'; import datasource from '../../datasource'; @@ -41,12 +41,7 @@ export default function Query(props: Props) { const { datasourceCateOptions, datasourceList, darkMode } = useContext(CommonStateContext); const { formatedReg, datasourceVars, variablesWithOptions } = props; const [errorMsg, setErrorMsg] = useState(''); - const [options, setOptions] = useState< - { - label: string; - value: string; - }[] - >([]); + const [options, setOptions] = useState([]); const form = Form.useFormInstance(); const item = Form.useWatch([]); const datasourceCate = Form.useWatch(['datasource', 'cate']); @@ -86,7 +81,7 @@ export default function Query(props: Props) { }, }) .then((options) => { - const itemOptions = _.sortBy(filterOptionsByReg(_.map(options, _.toString), formatedReg), 'value'); + const itemOptions = processQueryOptions(options, formatedReg); setOptions(itemOptions); setErrorMsg(''); }) @@ -176,6 +171,7 @@ export default function Query(props: Props) { }} /> +
{t('var.reg_object_tip')}
} rules={[{ pattern: new RegExp('^/(.*?)/(g?i?m?y?)$'), message: 'invalid regex' }]} diff --git a/src/pages/dashboard/Variables/Variable/Query.tsx b/src/pages/dashboard/Variables/Variable/Query.tsx index 67af8909f0..0131ceab3c 100644 --- a/src/pages/dashboard/Variables/Variable/Query.tsx +++ b/src/pages/dashboard/Variables/Variable/Query.tsx @@ -10,7 +10,7 @@ import { useGlobalState } from '@/pages/dashboard/globalState'; import { buildVariableInterpolations } from '../utils/ajustData'; import { useVariableManager } from '../VariableManagerContext'; import { formatString, formatDatasource } from '../utils/formatString'; -import filterOptionsByReg from '../utils/filterOptionsByReg'; +import processQueryOptions from '../utils/processQueryOptions'; import getValueByOptions from '../utils/getValueByOptions'; import datasource, { VariableDatasourceQuery } from '../datasource'; import { Props } from './types'; @@ -97,7 +97,7 @@ export default function Query(props: Props) { if (requestId !== requestIdRef.current) { return; } - const filteredOptions = _.sortBy(filterOptionsByReg(_.map(options, _.toString), formatedReg), 'value'); + const filteredOptions = processQueryOptions(options, formatedReg); updateVariable(name, { options: filteredOptions, value: getValueByOptions({ diff --git a/src/pages/dashboard/Variables/__tests__/queryOptions.jsdom.test.tsx b/src/pages/dashboard/Variables/__tests__/queryOptions.jsdom.test.tsx new file mode 100644 index 0000000000..3292d49d1a --- /dev/null +++ b/src/pages/dashboard/Variables/__tests__/queryOptions.jsdom.test.tsx @@ -0,0 +1,337 @@ +/** @jest-environment jsdom */ + +import React from 'react'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { ConfigProvider, Form, Input } from 'antd'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; + +jest.mock('@/App', () => { + const React = jest.requireActual('react'); + return { + CommonStateContext: React.createContext({ + datasourceList: [{ id: 1, name: 'test', plugin_type: 'gcm' }], + datasourceCateOptions: [], + darkMode: false, + }), + }; +}); +jest.mock('@/utils/constant', () => ({ + DatasourceCateEnum: { prometheus: 'prometheus', elasticsearch: 'elasticsearch', mysql: 'mysql', gcm: 'gcm' }, + IS_PLUS: true, +})); +jest.mock('@/components/TimeRangePicker', () => ({ + parseRange: () => ({ start: new Date('2026-01-01'), end: new Date('2026-01-02') }), +})); +jest.mock('@/pages/dashboard/utils', () => ({ getDefaultStepByTime: () => 60 })); +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en_US' } }), + Trans: ({ i18nKey }: { i18nKey: string }) => {i18nKey}, +})); +// 只隔离外部查询与无关编辑器;保留真实的 Form、Select、Modal、Table、状态和依赖管理。 +jest.mock('../datasource', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('@/components/DatasourceSelect', () => ({ DatasourceSelectV3: () => null })); +jest.mock('@/components/DocumentDrawer', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('../EditModal/Querybuilder', () => ({ + __esModule: true, + default: () => ( + + ), +})); +jest.mock('../Variable/Constant', () => () => null); +jest.mock('../Variable/Custom', () => () => null); +jest.mock('../Variable/Datasource', () => () => null); +jest.mock('../Variable/DatasourceIdentifier', () => () => null); +jest.mock('../Variable/HostIdent', () => () => null); +jest.mock('../Variable/Textbox', () => () => null); + +import Main from '../Main'; +import QueryEditor from '../EditModal/Variable/Query'; +import datasource from '../datasource'; +import { getGlobalState, setGlobalState } from '../../globalState'; +import replaceTemplateVariables from '../utils/replaceTemplateVariables'; +import initializeVariablesValue from '../utils/initializeVariablesValue'; +import type { IVariable } from '../types'; +import processLegacyQueryOptions from '../../VariableConfig/processQueryOptions'; +import LegacyDisplayItem from '../../VariableConfig/DisplayItem'; +import type { IVariable as LegacyVariable } from '../../VariableConfig/definition'; + +const queryMock = datasource as jest.MockedFunction; +const projects = [ + { label: 'Production', value: 'project-1' }, + { label: 'Development', value: 'project-2' }, +]; +const projectVariable = (partial: Partial = {}): IVariable => ({ + name: 'project', + type: 'query', + definition: '', + datasource: { cate: 'gcm', value: 1 }, + ...partial, +}); + +function mountRuntime(variables: IVariable[], fixed?: boolean) { + act(() => setGlobalState('variablesWithOptions', variables)); + const history = createMemoryHistory({ initialEntries: ['/dashboard?keep=1'] }); + const view = render( + + +
+ + , + ); + return { ...view, history }; +} + +async function selectOption(label: string, index = 0) { + fireEvent.mouseDown(screen.getAllByRole('combobox')[index]); + // rc-select 14 的 role=option 是只读辅助节点;点击真实选项内容触发选择。 + const option = await screen.findByText(label, { selector: '.ant-select-item-option-content' }); + await act(async () => { + fireEvent.click(option); + }); +} + +function state(name = 'project') { + return getGlobalState('variablesWithOptions').find((item) => item.name === name)!; +} + +beforeEach(() => { + queryMock.mockReset(); + localStorage.clear(); + setGlobalState('variablesWithOptions', []); + setGlobalState('dashboardMeta', { ...getGlobalState('dashboardMeta'), dashboardId: '42' }); + setGlobalState('range', { start: 'now-1h', end: 'now' }); +}); +afterEach(async () => { + // 让依赖链在卸载前完成当前微任务,避免异步更新泄漏到下一个用例。 + await act(async () => { + await Promise.resolve(); + }); + cleanup(); +}); + +test('query → real dropdown → selection → URL/cache → interpolation → reload', async () => { + queryMock.mockResolvedValue(projects); + const { history, unmount } = mountRuntime([projectVariable()]); + await waitFor(() => expect(state().value).toBe('project-1')); + expect(screen.getByText('Production')).toBeInTheDocument(); + await selectOption('Development'); + await waitFor(() => expect(history.location.search).toContain('project=project-2')); + expect(history.location.search).toContain('keep=1'); + expect(localStorage.getItem('dashboard_v6_42_project')).toBe('project-2'); + expect(replaceTemplateVariables('project=${project}')).toBe('project=project-2'); + expect(state().options).toEqual(projects); + unmount(); + const restored = initializeVariablesValue([projectVariable()], {}, { dashboardId: 42 }) as IVariable[]; + mountRuntime(restored); + await waitFor(() => expect(screen.getByText('Development')).toBeInTheDocument()); + expect(state().value).toBe('project-2'); +}); + +test('real dependency chain receives the project value, not its display label', async () => { + queryMock.mockImplementation(async ({ query }) => (query.project_id ? [{ label: `Metric for ${query.project_id}`, value: `metric/${query.project_id}` }] : projects)); + mountRuntime([projectVariable(), projectVariable({ name: 'metric', query: { project_id: '${project}' } })]); + await waitFor(() => expect(state('metric').value).toBe('metric/project-1')); + await selectOption('Development'); + await waitFor(() => expect(state('metric').value).toBe('metric/project-2')); + expect(queryMock).toHaveBeenLastCalledWith(expect.objectContaining({ query: expect.objectContaining({ project_id: 'project-2' }) })); + expect(screen.getByText('Metric for project-2')).toBeInTheDocument(); +}); + +test('multiple selection and All persist values and interpolate values only', async () => { + queryMock.mockResolvedValue(projects); + const { history } = mountRuntime([projectVariable({ multi: true, allOption: true, value: ['project-1'] })]); + await waitFor(() => expect(state().options).toEqual(projects)); + await selectOption('Development'); + fireEvent.blur(screen.getByRole('combobox')); + await waitFor(() => expect(state().value).toEqual(['project-1', 'project-2'])); + expect(localStorage.getItem('dashboard_v6_42_project')).toBe('["project-1","project-2"]'); + expect(history.location.search).toContain('project=project-1'); + expect(history.location.search).toContain('project=project-2'); + expect(replaceTemplateVariables('${project}')).toBe('project-1,project-2'); + await selectOption('All'); + fireEvent.blur(screen.getByRole('combobox')); + await waitFor(() => expect(state().value).toEqual(['all'])); + expect(localStorage.getItem('dashboard_v6_42_project')).toBe('["all"]'); + expect(replaceTemplateVariables('${project}')).toBe('project-1,project-2'); +}); + +test('refresh preserves selection when only label changes, and falls back when value disappears', async () => { + queryMock.mockResolvedValue(projects); + mountRuntime([projectVariable({ value: 'project-2' })]); + await waitFor(() => expect(state().options).toEqual(projects)); + queryMock.mockResolvedValue([{ label: 'Renamed development', value: 'project-2' }]); + act(() => setGlobalState('range', { start: 'now-2h', end: 'now' })); + await waitFor(() => expect(screen.getByText('Renamed development')).toBeInTheDocument()); + expect(state().value).toBe('project-2'); + queryMock.mockResolvedValue([{ label: 'Replacement', value: 'project-3' }]); + act(() => setGlobalState('range', { start: 'now-3h', end: 'now' })); + await waitFor(() => expect(state().value).toBe('project-3')); + expect(screen.getByText('Replacement')).toBeInTheDocument(); +}); + +test('fixed URL values survive an absent option', async () => { + queryMock.mockResolvedValue(projects); + mountRuntime([projectVariable({ value: 'project-outside' })], true); + await waitFor(() => expect(state().options).toEqual(projects)); + expect(state().value).toBe('project-outside'); + expect(replaceTemplateVariables('${project}')).toBe('project-outside'); +}); + +test('query failure then recovery clears stale options and restores display labels', async () => { + queryMock.mockResolvedValue(projects); + mountRuntime([projectVariable({ value: 'project-2' })]); + await waitFor(() => expect(state().options).toEqual(projects)); + queryMock.mockRejectedValue(new Error('query unavailable')); + act(() => setGlobalState('range', { start: 'now-2h', end: 'now' })); + await waitFor(() => expect(state().options).toEqual([])); + expect(state().value).toBe('project-2'); + queryMock.mockResolvedValue(projects); + act(() => setGlobalState('range', { start: 'now-3h', end: 'now' })); + await waitFor(() => expect(screen.getByText('Development')).toBeInTheDocument()); + expect(state().options).toEqual(projects); +}); + +function EditorHarness({ variable }: { variable: IVariable }) { + const [form] = Form.useForm(); + const footer = React.useRef(null); + const [ready, setReady] = React.useState(false); + React.useEffect(() => setReady(true), []); + return ( + <> +
+
+ + + {ready && } + + + ); +} + +test.each([ + ['/project-/', projects], + [ + '/project-(?.*)/', + [ + { label: 'Production', value: '1' }, + { label: 'Development', value: '2' }, + ], + ], +] as const)('real editor preview and runtime agree for regex %s', async (reg, expected) => { + queryMock.mockResolvedValue(projects); + const variable = projectVariable({ reg }); + const view = render( + + + , + ); + fireEvent.click(screen.getByRole('button', { name: 'common:btn.data_preview' })); + const dialog = await screen.findByRole('dialog'); + await waitFor(() => expect(within(dialog).getByText('Development')).toBeInTheDocument()); + const rows = within(dialog) + .getAllByRole('row') + .filter((row) => row.hasAttribute('data-row-key')); + expect( + rows.map((row) => + within(row) + .getAllByRole('cell') + .map((cell) => cell.textContent), + ), + ).toEqual(expected.map(({ label, value }) => [label, value])); + expect(within(dialog).queryByText('[object Object]')).not.toBeInTheDocument(); + view.unmount(); + mountRuntime([variable]); + await waitFor(() => expect(state().options).toEqual(expected)); + expect(state().value).toBe(expected[0].value); +}); + +test('legacy processing and real legacy dropdown preserve labels and emit actual values', async () => { + const onChange = jest.fn(); + const options = processLegacyQueryOptions(projects, '/project-/'); + const expression = { ...projectVariable(), options } as LegacyVariable; + render( + + + , + ); + expect(screen.getByText('Production')).toBeInTheDocument(); + await selectOption('Development'); + expect(onChange).toHaveBeenCalledWith('project-2'); +}); + +test('preview failure can be retried and replaces the error with fresh label/value rows', async () => { + queryMock.mockRejectedValueOnce(new Error('preview unavailable')).mockResolvedValue(projects); + render( + + + , + ); + fireEvent.click(screen.getByRole('button', { name: 'common:btn.data_preview' })); + const dialog = await screen.findByRole('dialog'); + await waitFor(() => expect(within(dialog).getByRole('alert')).toHaveTextContent('preview unavailable')); + fireEvent.click(within(dialog).getByRole('button', { name: 'Close' })); + fireEvent.click(screen.getByRole('button', { name: 'common:btn.data_preview' })); + await waitFor(() => expect(within(dialog).getByText('Development')).toBeInTheDocument()); + expect(within(dialog).queryByRole('alert')).not.toBeInTheDocument(); + expect(queryMock).toHaveBeenCalledTimes(2); +}); + +test('mixed results render numeric values as strings and HTML labels as text; clear removes persisted selection', async () => { + queryMock.mockResolvedValue([{ label: 'Zero', value: 0 }, 'plain', { label: 'Production', value: 'project-1' }]); + const { history } = mountRuntime([projectVariable()]); + await waitFor(() => expect(state().value).toBe('0')); + expect(screen.getByText('Zero')).toBeInTheDocument(); + await selectOption('Production'); + await waitFor(() => expect(state().value).toBe('project-1')); + expect(screen.getByText('Production', { selector: '.ant-select-selection-item' }).tagName).toBe('SPAN'); + expect(document.querySelector('.ant-select-selection-item b')).toBeNull(); + const clearIcon = document.querySelector('.ant-select-clear'); + expect(clearIcon).not.toBeNull(); + fireEvent.mouseDown(clearIcon!); + await waitFor(() => expect(state().value).toBe('')); + expect(history.location.search).not.toContain('project='); + expect(localStorage.getItem('dashboard_v6_42_project')).toBe(''); +}); + +test('a stale response cannot replace a newer option label/value set', async () => { + queryMock.mockResolvedValue(projects); + mountRuntime([projectVariable()]); + await waitFor(() => expect(state().options).toEqual(projects)); + let resolveOld!: (value: typeof projects) => void; + queryMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOld = resolve; + }), + ); + act(() => setGlobalState('range', { start: 'now-2h', end: 'now' })); + await waitFor(() => expect(queryMock).toHaveBeenCalledTimes(2)); + const latest = [{ label: 'Latest', value: 'project-new' }]; + queryMock.mockResolvedValue(latest); + act(() => setGlobalState('range', { start: 'now-3h', end: 'now' })); + await waitFor(() => expect(state().options).toEqual(latest)); + await act(async () => { + resolveOld([{ label: 'Stale', value: 'project-old' }]); + }); + expect(state().options).toEqual(latest); + expect(state().value).toBe('project-new'); + expect(screen.getByText('Latest')).toBeInTheDocument(); + expect(screen.queryByText('Stale')).not.toBeInTheDocument(); +}); + +test('an empty result produces no default option and no synthetic object string', async () => { + queryMock.mockResolvedValue([]); + mountRuntime([projectVariable()]); + await waitFor(() => expect(state().options).toEqual([])); + expect(state().value).toBeUndefined(); + fireEvent.mouseDown(screen.getByRole('combobox')); + expect(screen.queryByText('[object Object]')).not.toBeInTheDocument(); + expect(document.querySelectorAll('.ant-select-item-option')).toHaveLength(0); +}); diff --git a/src/pages/dashboard/Variables/datasource.ts b/src/pages/dashboard/Variables/datasource.ts index 6963bc5879..393e287598 100644 --- a/src/pages/dashboard/Variables/datasource.ts +++ b/src/pages/dashboard/Variables/datasource.ts @@ -2,6 +2,7 @@ * 各数据源的查询方法 */ +import type { QueryOptionInput } from '@/pages/dashboard/Variables/types'; import { DatasourceCateEnum } from '@/utils/constant'; import type { IRawTimeRange } from '@/components/TimeRangePicker'; import type { JsonValue } from '@/pages/dashboard/types'; @@ -12,10 +13,7 @@ import clickHouse from '@/plugins/clickHouse/Dashboard/VariableDatasource'; // @ts-ignore import variableDatasourcePro from 'plus:/parcels/Dashboard/variableDatasource'; -export type VariableDatasourceQuery = Record< - string, - JsonValue | IRawTimeRange | { index: string; date_field: string } | undefined ->; +export type VariableDatasourceQuery = Record; export interface Props { datasourceCate: string; @@ -24,7 +22,7 @@ export interface Props { query: QueryType; } -export default async function datasource(props: Props) { +export default async function datasource(props: Props): Promise { const { datasourceCate } = props; if (datasourceCate === DatasourceCateEnum.prometheus) { return prometheus(props); diff --git a/src/pages/dashboard/Variables/types.ts b/src/pages/dashboard/Variables/types.ts index cddfce5909..c62e114c8c 100644 --- a/src/pages/dashboard/Variables/types.ts +++ b/src/pages/dashboard/Variables/types.ts @@ -45,3 +45,12 @@ export interface VariableExecutionMeta { export interface DependencyGraph { [key: string]: string[]; // key: 变量名, value: 依赖它的变量列表 } + +/** 查询插件返回的选项;业务对象须由插件转换为 label/value。 */ +export type QueryOptionInput = string | number | boolean | { label: string; value: string | number }; + +/** 查询变量的实际值统一为字符串,不影响其他变量类型的数字值。 */ +export interface QueryOption { + label: string; + value: string; +} diff --git a/src/pages/dashboard/Variables/utils/__tests__/processQueryOptions.test.ts b/src/pages/dashboard/Variables/utils/__tests__/processQueryOptions.test.ts new file mode 100644 index 0000000000..05cef9f8d8 --- /dev/null +++ b/src/pages/dashboard/Variables/utils/__tests__/processQueryOptions.test.ts @@ -0,0 +1,232 @@ +/// + +jest.mock('lodash', () => { + const lodash = jest.requireActual('lodash'); + return { __esModule: true, default: lodash, ...lodash }; +}); +jest.mock('@/utils/constant', () => ({ DatasourceCateEnum: { prometheus: 'prometheus', elasticsearch: 'elasticsearch', mysql: 'mysql' } })); +jest.mock('../replaceTemplateVariables', () => ({ getBuiltInVariables: () => [] })); + +import _ from 'lodash'; +import processQueryOptions from '../processQueryOptions'; +import filterOptionsByReg from '../filterOptionsByReg'; +import processLegacyQueryOptions from '../../../VariableConfig/processQueryOptions'; +import getValueByOptions from '../getValueByOptions'; +import adjustData from '../ajustData'; +import { formatString } from '../formatString'; +import type { IVariable } from '../../types'; + +const cpu = { label: 'CPU utilization', value: 'compute/cpu' }; + +// 对照旧函数,确保无正则时的重复项和各类提取规则没有被新协议改变。 +describe('scalar compatibility', () => { + test.each([undefined, '/cpu/', '/compute\\/(.*)/', '/(?compute)\\/(?cpu)/', '/(cpu)/g', '/[/', '/^$/'])('preserves existing results for %s', (reg) => { + const result = ['compute/cpu', 'cpu cpu', 'compute/cpu', 200, 0, true, false, '']; + expect(processQueryOptions(result, reg)).toEqual(_.sortBy(filterOptionsByReg(result.map(_.toString), reg), 'value')); + }); +}); + +describe.each([ + ['current', processQueryOptions], + ['legacy', processLegacyQueryOptions], +] as const)('%s object options', (_name, process) => { + test.each([undefined, '/cpu/', '/compute\\/(.*)/', '/[/'])('preserves both fields for %s', (reg) => { + expect(process([cpu], reg)).toEqual([cpu]); + }); + test('matches value, not label', () => { + expect(process([cpu], '/utilization/')).toEqual([]); + }); + test('overrides only explicitly captured fields', () => { + expect(process([cpu], '/compute\\/(?.*)/')).toEqual([{ label: cpu.label, value: 'cpu' }]); + expect(process([cpu], '/compute\\/(?.*)/')).toEqual([{ label: 'cpu', value: cpu.value }]); + expect(process([cpu], '/(?compute)\\/(?cpu)/')).toEqual([{ label: 'compute', value: 'cpu' }]); + }); + test('keeps empty captures and ignores groups that did not participate', () => { + expect(process([cpu], '/(?)(?x)?compute/')).toEqual([{ label: '', value: cpu.value }]); + }); + test('takes the first participating group for each field across global matches', () => { + expect(process([{ label: 'original', value: 'v1 tA v2 tB' }], '/v(?\\d)|t(?[A-Z])/g')).toEqual([{ label: 'A', value: '1' }]); + expect(process([cpu], '/(?)/g')).toEqual([{ label: '', value: cpu.value }]); + }); + test('resets stateful regex for every input', () => { + expect(process([cpu, { label: 'Other', value: 'compute/cpu2' }], '/compute/y')).toHaveLength(2); + }); + test('keeps empty strings, zero and pure text labels', () => { + expect( + process([ + { label: '', value: '' }, + { label: 'Zero', value: 0 }, + ]), + ).toEqual([ + { label: '', value: '' }, + { label: 'Zero', value: '0' }, + ]); + }); + test('skips invalid entries individually', () => { + expect(process([null, undefined, [], {}, { label: 'missing value' }, { value: 'missing label' }, { label: {}, value: 'x' }, cpu])).toEqual([cpu]); + expect(process(null)).toEqual([]); + expect(process({ options: [cpu] })).toEqual([]); + }); + test('deduplicates objects after extraction, preserving the first label', () => { + expect(process([cpu, { label: 'Second', value: 'other/cpu' }], '/\\/(?cpu)/')).toEqual([{ label: cpu.label, value: 'cpu' }]); + }); + test('supports mixed arrays with first-value-wins semantics', () => { + expect(process([cpu, 'compute/cpu', 'z', { label: 'Last', value: 'z' }])).toEqual([cpu, { label: 'z', value: 'z' }]); + }); +}); + +test('keeps the legacy string extraction behavior distinct', () => { + expect(processLegacyQueryOptions(['prefix-cpu'], '/cpu/')).toEqual([{ label: 'prefix-cpu', value: 'prefix-cpu' }]); + expect(processQueryOptions(['prefix-cpu'], '/cpu/')).toEqual([{ label: 'cpu', value: 'cpu' }]); + expect(processLegacyQueryOptions(['cpu cpu'], '/(cpu)/g')).toEqual([{ label: 'cpu', value: 'cpu' }]); + expect(processLegacyQueryOptions(['cpu', 'cpu'])).toHaveLength(2); +}); + +test('sorts current query results by value, not display name', () => { + expect( + processQueryOptions([ + { label: 'A', value: 'z' }, + { label: 'Z', value: 'a' }, + ]).map((item) => item.value), + ).toEqual(['a', 'z']); +}); + +test('selects and interpolates actual project IDs while retaining display names', () => { + const options = processQueryOptions([ + { label: 'Production', value: 'project-1' }, + { label: 'Development', value: 'project-2' }, + ]); + const variable: IVariable = { name: 'project', definition: '', type: 'query', datasource: { cate: 'gcm' }, options }; + const select = (value?: string) => getValueByOptions({ variableValueFixed: undefined!, variable: { ...variable, value }, itemOptions: options }); + expect(select()).toBe('project-1'); + expect(select('project-2')).toBe('project-2'); + expect(select('Development')).toBe('project-1'); + const data = adjustData([{ ...variable, value: select('project-2') }], { datasourceList: [] }); + expect(formatString('${project}', data)).toBe('project-2'); + expect(options[1].label).toBe('Development'); +}); + +describe.each([ + ['current', processQueryOptions], + ['legacy', processLegacyQueryOptions], +] as const)('%s additional boundary cases', (_name, process) => { + test('does not mutate frozen input or share output objects across calls', () => { + const input = Object.freeze([Object.freeze({ label: 'CPU', value: 'cpu' })]); + const first = process(input); + first[0].label = 'changed'; + expect(process(input)).toEqual([{ label: 'CPU', value: 'cpu' }]); + expect(input[0].label).toBe('CPU'); + }); + + test('does not deduplicate different values that have the same label', () => { + expect( + process([ + { label: 'CPU', value: 'a' }, + { label: 'CPU', value: 'b' }, + ]), + ).toHaveLength(2); + }); + + test('deduplicates numeric values after string normalization, keeping the first label', () => { + expect( + process([ + { label: 'Numeric', value: 0 }, + { label: 'String', value: '0' }, + ]), + ).toEqual([{ label: 'Numeric', value: '0' }]); + }); + + test('keeps scalar-first collisions and rejects boolean object values', () => { + expect(process([false, { label: 'False', value: 'false' }, { label: 'Invalid', value: false }])).toEqual([{ label: 'false', value: 'false' }]); + }); + + test('empty value captures take precedence over later nonempty captures', () => { + expect(process([{ label: 'Original', value: 'ab' }], '/(?a?)/g')).toEqual([{ label: 'Original', value: 'a' }]); + expect(process([{ label: 'Original', value: 'ba' }], '/(?a?)/g')).toEqual([{ label: 'Original', value: '' }]); + }); + + test('unrecognized named groups and unmatched optional groups do not overwrite fields', () => { + expect(process([cpu], '/(?compute)\\/(?x)?cpu/')).toEqual([cpu]); + }); + + test('matches an empty object value without treating it as a missing value', () => { + expect(process([{ label: 'Empty', value: '' }], '/^$/')).toEqual([{ label: 'Empty', value: '' }]); + expect(process([{ label: 'Empty', value: '' }], '/.+/')).toEqual([]); + }); + + test('supports case-insensitive and multiline matching', () => { + expect(process([cpu], '/CPU/i')).toEqual([cpu]); + expect(process([{ label: 'Lines', value: 'first\nsecond' }], '/^(?second)$/m')).toEqual([{ label: 'second', value: 'first\nsecond' }]); + }); + + test('handles punctuation, unicode labels and literal HTML as plain data', () => { + const input = [{ label: '生产环境 / 東京', value: 'project.a+b/c' }]; + expect(process(input, '/^project\\.a\\+b\\/c$/')).toEqual(input); + }); + + test('resets global regex state after misses and empty matches', () => { + expect( + process( + [ + { label: 'A', value: 'x' }, + { label: 'B', value: 'y' }, + ], + '/(?)/g', + ), + ).toEqual([ + { label: '', value: 'x' }, + { label: '', value: 'y' }, + ]); + expect(process([{ label: 'Miss', value: 'no' }, cpu], '/cpu/g')).toEqual([cpu]); + }); + + test('mixed scalar extraction and object filtering use their respective semantics', () => { + expect(process(['compute/cpu', { label: 'CPU', value: 'compute/cpu' }], '/compute\\/(.*)/')).toEqual( + expect.arrayContaining([ + { label: 'cpu', value: 'cpu' }, + { label: 'CPU', value: 'compute/cpu' }, + ]), + ); + }); +}); + +test('a fixed value is preserved even when no options remain', () => { + const variable: IVariable = { name: 'project', definition: '', type: 'query', datasource: { cate: 'gcm' }, value: 'project-outside' }; + expect(getValueByOptions({ variableValueFixed: true, variable, itemOptions: [] })).toBe('project-outside'); +}); + +test('string zero remains selectable as a numeric object option default', () => { + const itemOptions = processQueryOptions([{ label: 'Zero', value: 0 }]); + const variable: IVariable = { name: 'project', definition: '', type: 'query', datasource: { cate: 'gcm' }, multi: true }; + expect(getValueByOptions({ variableValueFixed: undefined!, variable, itemOptions })).toEqual(['0']); +}); + +test.each([['project-1', 'project-2'], ['all'], ['__all__']])('interpolates values for a datasource without a custom separator: %j', (...value) => { + const variable: IVariable = { + name: 'project', + definition: '', + type: 'query', + datasource: { cate: 'gcm' }, + multi: true, + value, + options: [ + { label: 'Production', value: 'project-1' }, + { label: 'Development', value: 'project-2' }, + ], + }; + expect(formatString('${project}', adjustData([variable], { datasourceList: [] }))).toBe('project-1,project-2'); +}); + +test('custom All value is preserved instead of expanding option labels or values', () => { + const variable: IVariable = { + name: 'project', + definition: '', + type: 'query', + datasource: { cate: 'gcm' }, + multi: true, + value: ['all'], + allValue: '.*', + options: [{ label: 'Production', value: 'project-1' }], + }; + expect(formatString('${project}', adjustData([variable], { datasourceList: [] }))).toBe('.*'); +}); diff --git a/src/pages/dashboard/Variables/utils/ajustData.ts b/src/pages/dashboard/Variables/utils/ajustData.ts index d458edb082..4bbb1430e0 100644 --- a/src/pages/dashboard/Variables/utils/ajustData.ts +++ b/src/pages/dashboard/Variables/utils/ajustData.ts @@ -86,7 +86,7 @@ function joinValues( separator, )})`; } else { - return _.join(values, ','); + return values.map((item) => item.value).join(','); } } diff --git a/src/pages/dashboard/Variables/utils/processQueryOptions.ts b/src/pages/dashboard/Variables/utils/processQueryOptions.ts new file mode 100644 index 0000000000..fc3ad9d97f --- /dev/null +++ b/src/pages/dashboard/Variables/utils/processQueryOptions.ts @@ -0,0 +1,67 @@ +import _ from 'lodash'; + +import type { QueryOption, QueryOptionInput } from '../types'; +import filterOptionsByReg from './filterOptionsByReg'; +import stringToRegex from './stringToRegex'; + +type ScalarOption = Extract; +type ObjectOption = Exclude; + +function isScalar(value: unknown): value is ScalarOption { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; +} + +function isObjectOption(value: unknown): value is ObjectOption { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + return 'label' in value && typeof value.label === 'string' && 'value' in value && (typeof value.value === 'string' || typeof value.value === 'number'); +} + +function processObjectOption(option: ObjectOption, regex: RegExp | null): QueryOption | undefined { + const original = { label: option.label, value: String(option.value) }; + if (!regex) return original; + + regex.lastIndex = 0; + let match = regex.exec(original.value); + if (!match) return undefined; + let label: string | undefined; + let value: string | undefined; + do { + label ??= match.groups?.text; + value ??= match.groups?.value; + if (!regex.global) break; + // 空匹配也可能包含有效的命名组,推进位置以避免死循环。 + if (match[0] === '') regex.lastIndex += 1; + match = regex.exec(original.value); + } while (match); + + return { label: label ?? original.label, value: value ?? original.value }; +} + +/** 新旧入口共用对象协议,但分别保留各自的字符串正则语义和排序策略。 */ +export function transformQueryOptions(result: unknown, reg: string | undefined, processScalars: (options: string[]) => QueryOption[]): QueryOption[] { + if (!Array.isArray(result)) return []; + const inputs = result.filter((item): item is QueryOptionInput => isScalar(item) || isObjectOption(item)); + if (inputs.every(isScalar)) return processScalars(inputs.map((item) => _.toString(item))); + + const regex = stringToRegex(reg); + const options: QueryOption[] = []; + const seen = new Set(); + for (const input of inputs) { + const candidates = isScalar(input) ? processScalars([_.toString(input)]) : [processObjectOption(input, regex)]; + for (const option of candidates) { + if (option && !seen.has(option.value)) { + seen.add(option.value); + options.push(option); + } + } + } + return options; +} + +/** 只处理查询结果;正则中的变量由调用方提前替换。 */ +export default function processQueryOptions(result: unknown, reg?: string): QueryOption[] { + return _.sortBy( + transformQueryOptions(result, reg, (options) => filterOptionsByReg(options, reg)), + 'value', + ); +} diff --git a/src/pages/dashboard/locale/en_US.ts b/src/pages/dashboard/locale/en_US.ts index 068f2aefb3..3067174dd0 100644 --- a/src/pages/dashboard/locale/en_US.ts +++ b/src/pages/dashboard/locale/en_US.ts @@ -178,6 +178,8 @@ const en_US = { reg: 'Regex', reg_tip: 'Optional, can filter options using a regular expression literal, which consists of a pattern enclosed between slashes', reg_tip2: 'If you want to extract a part of the options, named capture groups can be used to separate display text and value', + reg_object_tip: + 'For options with a separate display name, the regex matches the value. Ordinary matches preserve both fields; named groups text/value override only the corresponding field.', multi: 'Multi select', allOption: 'Include all option', allValue: 'Custom all value', diff --git a/src/pages/dashboard/locale/es_ES.ts b/src/pages/dashboard/locale/es_ES.ts index 3ca4466f36..86ae65f4b4 100644 --- a/src/pages/dashboard/locale/es_ES.ts +++ b/src/pages/dashboard/locale/es_ES.ts @@ -159,6 +159,8 @@ const es_ES = { reg: 'Expresión regular', reg_tip: 'Opcional: filtra las opciones mediante una expresión regular. Introduce un literal de expresión regular, es decir, un patrón entre barras', reg_tip2: 'Si quieres extraer solo una parte de una opción, usa grupos de captura con nombre para separar el texto mostrado del valor', + reg_object_tip: + 'Para opciones con un nombre visible separado, la expresión regular compara el valor. Las coincidencias normales conservan ambos campos; los grupos text/value solo reemplazan el campo correspondiente.', multi: 'Selección múltiple', allOption: 'Incluir la opción «todos»', allValue: 'Valor personalizado para «todos»', diff --git a/src/pages/dashboard/locale/fr_FR.ts b/src/pages/dashboard/locale/fr_FR.ts index ebdf357bc2..47e510f724 100644 --- a/src/pages/dashboard/locale/fr_FR.ts +++ b/src/pages/dashboard/locale/fr_FR.ts @@ -161,6 +161,8 @@ const fr_FR = { reg_tip: "Facultatif : une expression régulière filtre les valeurs proposées. Saisissez un littéral d'expression régulière, c'est-à-dire un motif encadré par des barres obliques", reg_tip2: "Pour n'extraire qu'une partie d'une valeur, les groupes de capture nommés séparent le texte affiché de la valeur", + reg_object_tip: + 'Pour les options avec un nom distinct, la regex porte sur la valeur. Une correspondance ordinaire conserve les deux champs ; les groupes text/value remplacent uniquement le champ correspondant.', multi: 'Sélection multiple', allOption: "Inclure l'option Tout", allValue: 'Valeur personnalisée pour Tout', diff --git a/src/pages/dashboard/locale/id_ID.ts b/src/pages/dashboard/locale/id_ID.ts index aaf7531db6..00b96d2ac3 100644 --- a/src/pages/dashboard/locale/id_ID.ts +++ b/src/pages/dashboard/locale/id_ID.ts @@ -159,6 +159,8 @@ const id_ID = { reg: 'Regex', reg_tip: 'Opsional: regex untuk menyaring pilihan. Isikan literal ekspresi reguler, yaitu pola yang diapit garis miring', reg_tip2: 'Untuk mengambil sebagian dari sebuah pilihan, grup tangkapan bernama bisa memisahkan teks tampilan dari nilainya', + reg_object_tip: + 'Untuk pilihan dengan nama tampilan terpisah, regex mencocokkan nilai. Kecocokan biasa mempertahankan kedua bidang; grup bernama text/value hanya mengganti bidang terkait.', multi: 'Pilihan ganda', allOption: 'Sertakan opsi Semua', allValue: 'Nilai khusus untuk opsi Semua', diff --git a/src/pages/dashboard/locale/ja_JP.ts b/src/pages/dashboard/locale/ja_JP.ts index 3f3f3f8afe..030e3de94b 100644 --- a/src/pages/dashboard/locale/ja_JP.ts +++ b/src/pages/dashboard/locale/ja_JP.ts @@ -177,6 +177,7 @@ const ja_JP = { reg: '正規表現', reg_tip: 'オプションとして、正規表現でオプションをフィルタリングできます。以下は、スラッシュで囲まれたパターンで構成される、入力済みの正規表現リテラルです', reg_tip2: 'オプションの一部を抽出したい場合は、名前付きキャプチャグループを使用して表示テキストと値を分離できます', + reg_object_tip: '表示名と値が別の選択肢では、正規表現は値に一致させます。通常の一致では両方を保持し、名前付きグループ text/value は対応する項目だけを上書きします。', multi: '複数選択', allOption: '全選択を含む', allValue: 'カスタム全選択値', diff --git a/src/pages/dashboard/locale/ko_KR.ts b/src/pages/dashboard/locale/ko_KR.ts index 7fd41882ca..3032d686ae 100644 --- a/src/pages/dashboard/locale/ko_KR.ts +++ b/src/pages/dashboard/locale/ko_KR.ts @@ -156,6 +156,7 @@ const ko_KR = { reg: '정규식', reg_tip: '선택 사항입니다. 정규식으로 선택지를 걸러 낼 수 있습니다. 여기에는 정규식 리터럴, 즉 슬래시로 감싼 패턴을 적습니다', reg_tip2: '선택지의 일부만 뽑아내려면 이름 있는 캡처 그룹으로 표시 텍스트와 값을 나눌 수 있습니다', + reg_object_tip: '표시 이름과 값이 다른 선택지는 값을 정규식과 비교합니다. 일반 일치는 두 필드를 유지하고, 명명된 그룹 text/value는 해당 필드만 덮어씁니다.', multi: '다중 선택', allOption: '전체 선택 항목 포함', allValue: '전체 선택 값 지정', diff --git a/src/pages/dashboard/locale/pt_BR.ts b/src/pages/dashboard/locale/pt_BR.ts index 8e1876c1e0..24bda76f80 100644 --- a/src/pages/dashboard/locale/pt_BR.ts +++ b/src/pages/dashboard/locale/pt_BR.ts @@ -158,6 +158,8 @@ const pt_BR = { reg: 'Expressão regular', reg_tip: 'Opcional: filtra as opções por expressão regular. Informe um literal de expressão regular, ou seja, um padrão entre barras', reg_tip2: 'Para extrair apenas parte de uma opção, use grupos de captura nomeados para separar o texto exibido do valor', + reg_object_tip: + 'Para opções com nome de exibição separado, a expressão regular verifica o valor. Correspondências comuns preservam ambos os campos; grupos text/value substituem apenas o campo correspondente.', multi: 'Seleção múltipla', allOption: 'Incluir a opção "todos"', allValue: 'Valor personalizado para "todos"', diff --git a/src/pages/dashboard/locale/ru_RU.ts b/src/pages/dashboard/locale/ru_RU.ts index 44c07c4e5e..1e03ed192a 100644 --- a/src/pages/dashboard/locale/ru_RU.ts +++ b/src/pages/dashboard/locale/ru_RU.ts @@ -177,6 +177,8 @@ const ru_RU = { reg_tip: 'Необязательно, можно отфильтровать варианты с помощью регулярного выражения. Здесь введены регулярные выражения в виде строк, состоящие из шаблонов, содержащихся в косых чертах', reg_tip2: 'Если вы хотите извлечь часть вариантов, именованные группы захвата можно использовать для разделения отображаемого текста и значения', + reg_object_tip: + 'Если имя и значение различаются, регулярное выражение проверяет значение. Обычное совпадение сохраняет оба поля; группы text/value заменяют только соответствующее поле.', multi: 'Множественный выбор', allOption: 'Включить все', allValue: 'Пользовательские значения', diff --git a/src/pages/dashboard/locale/zh_CN.ts b/src/pages/dashboard/locale/zh_CN.ts index dae78610fd..317b9bc7ee 100644 --- a/src/pages/dashboard/locale/zh_CN.ts +++ b/src/pages/dashboard/locale/zh_CN.ts @@ -176,6 +176,7 @@ const zh_CN = { reg: '正则', reg_tip: '可选,可通过正则来过滤可选项。这里是填写的 正则表达式字面量,其由包含在斜杠之间的模式组成', reg_tip2: '如果您想要提取选项中的一部分,命名捕获组可用于分隔显示文本和值', + reg_object_tip: '选项有独立显示名称时,正则匹配实际值;普通匹配保留名称和值,命名捕获组 text/value 仅覆盖对应字段。', multi: '多选', allOption: '包含全选', allValue: '自定义全选值', diff --git a/src/pages/dashboard/locale/zh_HK.ts b/src/pages/dashboard/locale/zh_HK.ts index 74bfab1356..57bae57bc8 100644 --- a/src/pages/dashboard/locale/zh_HK.ts +++ b/src/pages/dashboard/locale/zh_HK.ts @@ -176,6 +176,7 @@ const zh_HK = { reg: '正則', reg_tip: '可選,可通過正則來過濾可選項。這裡是填寫的正規表示式字面量,其由包含在斜線之間的模式組成', reg_tip2: '如果您想要提取選項中的一部分,命名捕獲組可用於分隔顯示文本和值', + reg_object_tip: '選項有獨立顯示名稱時,正則匹配實際值;普通匹配保留名稱和值,命名捕獲組 text/value 僅覆蓋對應欄位。', multi: '多選', allOption: '包含全選', allValue: '自定義全選值', From 011ce9ea398280f2c9553d1f573d813c4944ed69 Mon Sep 17 00:00:00 2001 From: jsers Date: Mon, 7 Sep 2026 23:54:31 +0800 Subject: [PATCH 02/12] feat(dashboard): add variable plugin system and GCM query variables Introduce DashboardVariablePlugin (capabilities + transformQuery) so datasources can declare multi/all support and own their query templating. The built-in open-source registry stays empty with a placeholder fallback; Plus registers the GCM plugin via the shared registry and overrides by name. Refactor VariableManagerContext to reconcile dependencies centrally: rebuild the dependency graph from the full variable set on config change (including nested query fields and [[var]] syntax) instead of analyzing each variable at register time, and execute only changed variables plus downstream with a stale-chain token guard. --- plugins/PlusPlaceholder.tsx | 3 + src/pages/dashboard/README.md | 3 +- .../Renderer/datasource/contract.test.ts | 89 +++- .../dashboard/Renderer/datasource/contract.ts | 46 +- src/pages/dashboard/VARIABLES.md | 255 +++++++++++ .../dashboard/VariableConfig/EditItem.tsx | 12 +- .../dashboard/VariableConfig/Querybuilder.tsx | 7 +- .../Variables/EditModal/Querybuilder.tsx | 4 +- .../Variables/EditModal/Variable/Query.tsx | 14 +- .../dashboard/Variables/Variable/Query.tsx | 23 +- .../Variables/VariableManagerContext.tsx | 431 +++++++++--------- .../__tests__/VariableManagerContext.test.ts | 123 ++++- .../Variables/__tests__/plugins.test.ts | 50 ++ .../variableLifecycle.jsdom.test.tsx | 209 +++++++++ .../dashboard/Variables/builtinPlugins.ts | 4 + src/pages/dashboard/Variables/datasource.ts | 4 + src/pages/dashboard/Variables/pluginTypes.ts | 25 + src/pages/dashboard/Variables/plugins.ts | 11 + src/test/mocks/plusStub.tsx | 2 + 19 files changed, 1036 insertions(+), 279 deletions(-) create mode 100644 src/pages/dashboard/VARIABLES.md create mode 100644 src/pages/dashboard/Variables/__tests__/plugins.test.ts create mode 100644 src/pages/dashboard/Variables/__tests__/variableLifecycle.jsdom.test.tsx create mode 100644 src/pages/dashboard/Variables/builtinPlugins.ts create mode 100644 src/pages/dashboard/Variables/pluginTypes.ts create mode 100644 src/pages/dashboard/Variables/plugins.ts diff --git a/plugins/PlusPlaceholder.tsx b/plugins/PlusPlaceholder.tsx index 888f17bdc6..92e43d2ba9 100644 --- a/plugins/PlusPlaceholder.tsx +++ b/plugins/PlusPlaceholder.tsx @@ -117,3 +117,6 @@ export { getProject, getTopic, }; + +// 开源版没有 Plus 变量扩展,公共注册表仍然有效。 +export const dashboardVariablePlugins: Record = {}; diff --git a/src/pages/dashboard/README.md b/src/pages/dashboard/README.md index 935c029c08..00d23693bf 100644 --- a/src/pages/dashboard/README.md +++ b/src/pages/dashboard/README.md @@ -8,6 +8,7 @@ src/pages/dashboard/ ├── CHANGELOG.md # 版本变更记录(含各版本无法兼容的更新说明) ├── LLMs.txt +├── VARIABLES.md # 变量子系统架构(含架构图与关键约束) ├── config.tsx # 仪表盘模块配置 ├── external-modules.d.ts ├── globalState.ts # 仪表盘全局状态 @@ -40,7 +41,7 @@ src/pages/dashboard/ │ └── utils/ # 渲染工具(valueFormatter 等) ├── Share/ # 分享 ├── VariableConfig/ # 变量配置(v3.2 起废弃,由 Variables 取代) -├── Variables/ # 变量(v3.2 重构) +├── Variables/ # 变量(v3.2 重构),架构见 VARIABLES.md ├── hooks/ # 模块内 Hook ├── locale/ # 国际化资源 ├── test/fixtures/ # 仪表盘专属测试数据 diff --git a/src/pages/dashboard/Renderer/datasource/contract.test.ts b/src/pages/dashboard/Renderer/datasource/contract.test.ts index f692a2e6c7..6ef319cb46 100644 --- a/src/pages/dashboard/Renderer/datasource/contract.test.ts +++ b/src/pages/dashboard/Renderer/datasource/contract.test.ts @@ -4,6 +4,7 @@ import type { ITarget } from '@/pages/dashboard/types'; import { buildDashboardQueryRequest, normalizeDashboardQueryResponse, validateDashboardQueryRequest } from './contract'; import dashboardDatasourceDefinitions, { DASHBOARD_DATASOURCE_CATES } from './registry'; +import { getDashboardVariablePlugin } from '@/pages/dashboard/Variables/plugins'; jest.mock('./queryStep', () => ({ getDashboardQueryStep: () => 30, @@ -16,10 +17,21 @@ jest.mock('@/components/TimeRangePicker/utils', () => ({ jest.mock('@/pages/dashboard/Variables/utils/replaceTemplateVariables', () => ({ __esModule: true, default: jest.fn((value: string) => value), + getBuiltInVariables: () => [], replaceDatasourceVariables: (value: number | string) => (value === '${metrics}' ? 9 : value), })); +jest.mock('@/pages/dashboard/Variables/plugins', () => ({ + getDashboardVariablePlugin: jest.fn(), +})); + +const getDashboardVariablePluginMock = getDashboardVariablePlugin as jest.MockedFunction; + describe('dashboard unified query contract', () => { + afterEach(() => { + getDashboardVariablePluginMock.mockReset(); + }); + it('registers every dashboard datasource contract', () => { const expectedCates = [ 'prometheus', @@ -130,6 +142,35 @@ describe('dashboard unified query contract', () => { expect(query).not.toHaveProperty('values'); }); + it('skips every Elasticsearch value when its variable plugin is not ready', () => { + getDashboardVariablePluginMock.mockReturnValue({ + capabilities: () => ({ multi: false, all: false }), + transformQuery: () => undefined, + }); + + const request = buildDashboardQueryRequest({ + time: { + start: moment('2026-07-24T00:00:00.000Z'), + end: moment('2026-07-24T01:00:00.000Z'), + }, + targets: [ + { + refId: 'A', + kind: 'query', + datasource: { cate: 'elasticsearch', id: 12 }, + query: { + index: 'application-*', + date_field: '@timestamp', + values: [{ func: 'rawData' }, { func: 'count' }], + }, + }, + ], + datasourceList: [], + }); + + expect(request.queries).toEqual([]); + }); + it('expands Elasticsearch values into backend-compatible single-value queries', () => { const request = buildDashboardQueryRequest({ time: { @@ -300,16 +341,20 @@ describe('dashboard unified query contract', () => { const firstQuery = request.queries[0]?.kind === 'query' ? request.queries[0].query : {}; const secondQuery = request.queries[1]?.kind === 'query' ? request.queries[1].query : {}; - expect(firstQuery).toMatchObject({ keys: { - valueKey: 'value count', - labelKey: 'host region', - timeKey: 'time', - } }); - expect(secondQuery).toMatchObject({ keys: { - metricKey: 'value count', - labelKey: 'host', - timeFormat: '2006-01-02T15:04:05', - } }); + expect(firstQuery).toMatchObject({ + keys: { + valueKey: 'value count', + labelKey: 'host region', + timeKey: 'time', + }, + }); + expect(secondQuery).toMatchObject({ + keys: { + metricKey: 'value count', + labelKey: 'host', + timeFormat: '2006-01-02T15:04:05', + }, + }); }); it('builds a mixed datasource request with only backend execution fields', () => { @@ -439,10 +484,14 @@ describe('dashboard unified query contract', () => { queryOptionsTime, scopedVars, datasourceList: [], - targets: [{ - refId: 'A', kind: 'query', datasource: { cate: 'mysql', id: 1 }, - query: { query: 'select ${host}', nested: { label: '${host}', timezone: 'UTC', request_id: 'old' }, values: ['${host}'], max_data_points: 1 }, - }], + targets: [ + { + refId: 'A', + kind: 'query', + datasource: { cate: 'mysql', id: 1 }, + query: { query: 'select ${host}', nested: { label: '${host}', timezone: 'UTC', request_id: 'old' }, values: ['${host}'], max_data_points: 1 }, + }, + ], }); expect(request).toMatchObject({ from: 1784858400, to: 1784862000, queries: [{ query: { query: 'select ${host}', nested: { label: '${host}' }, values: ['${host}'] } }] }); @@ -451,11 +500,13 @@ describe('dashboard unified query contract', () => { }); it('serializes instant Prometheus queries', () => { - expect(buildDashboardQueryRequest({ - time: { start: moment('2026-07-24T00:00:00.000Z'), end: moment('2026-07-24T01:00:00.000Z') }, - datasourceList: [], - targets: [{ refId: 'A', kind: 'query', datasource: { cate: 'prometheus', id: 1 }, expr: 'up', instant: true }], - }).queries[0]).toMatchObject({ query: { expr: 'up', instant: true } }); + expect( + buildDashboardQueryRequest({ + time: { start: moment('2026-07-24T00:00:00.000Z'), end: moment('2026-07-24T01:00:00.000Z') }, + datasourceList: [], + targets: [{ refId: 'A', kind: 'query', datasource: { cate: 'prometheus', id: 1 }, expr: 'up', instant: true }], + }).queries[0], + ).toMatchObject({ query: { expr: 'up', instant: true } }); }); it('normalizes timeseries, logs and ref-level failures with stable ids', () => { diff --git a/src/pages/dashboard/Renderer/datasource/contract.ts b/src/pages/dashboard/Renderer/datasource/contract.ts index 2160d2ff35..5587afe8de 100644 --- a/src/pages/dashboard/Renderer/datasource/contract.ts +++ b/src/pages/dashboard/Renderer/datasource/contract.ts @@ -5,7 +5,7 @@ import type { IRawTimeRange } from '@/components/TimeRangePicker/types'; import { parseRange } from '@/components/TimeRangePicker/utils'; import type { ITarget, JsonObject, JsonValue } from '@/pages/dashboard/types'; import flatten from '@/utils/flatten'; -import replaceTemplateVariables, { replaceDatasourceVariables } from '@/pages/dashboard/Variables/utils/replaceTemplateVariables'; +import replaceTemplateVariables, { getBuiltInVariables, replaceDatasourceVariables } from '@/pages/dashboard/Variables/utils/replaceTemplateVariables'; import { getDashboardQueryStep } from './queryStep'; import { normalizeInterval } from './elasticsearch/utils'; @@ -13,6 +13,9 @@ import type { DashboardQueryRequest, DashboardQueryResponse, DatasourceQuery, Ex import { getTargetRefId, inferTargetResultType, isExpressionTarget } from './target'; import { DASHBOARD_TARGET_META_FIELDS, getDashboardDatasourceDefinition } from './registry'; +import { getGlobalState } from '@/pages/dashboard/globalState'; +import { getDashboardVariablePlugin } from '@/pages/dashboard/Variables/plugins'; + export { inferTargetResultType, isExpressionTarget } from './target'; const FORBIDDEN_REQUEST_FIELDS = new Set(['timezone', 'max_data_points', 'interval_ms', 'request_id']); @@ -78,6 +81,15 @@ function getDatasourceQueryPayload(target: ITarget, cate: string, options: Build delete payload.interval_unit; } + const plugin = getDashboardVariablePlugin(cate); + if (plugin) { + return plugin.transformQuery(payload, { + variables: [...getGlobalState('variablesWithOptions'), ...getBuiltInVariables(options.effectiveRange, { step })], + range: options.effectiveRange, + step, + scopedVars: options.scopedVars, + }); + } return interpolateQueryValue(payload, options.effectiveRange, step, options.scopedVars); } @@ -144,19 +156,27 @@ export function buildDashboardQueryRequest(options: BuildDashboardQueryRequestOp const values = target.query?.values; const isElasticsearchQuery = _.includes(['elasticsearch', 'opensearch'], datasource.cate); if (isElasticsearchQuery && Array.isArray(values)) { - return values.map((value, valueIndex) => ({ - kind: 'query' as const, - // 保留首个指标的 RefID,兼容表达式对该 target 的已有引用;其余指标使用唯一子 RefID。 - ref_id: valueIndex === 0 ? refId : getValueRefId(refId, valueIndex), - datasource: { - cate: datasource.cate, - id: resolvedDatasourceId, - }, - result_type: inferTargetResultType(target), - query: getDatasourceQueryPayload(target, datasource.cate, buildOptions, value), - })); + return values.flatMap((value, valueIndex) => { + const queryPayload = getDatasourceQueryPayload(target, datasource.cate, buildOptions, value); + if (queryPayload === undefined) return []; + return [ + { + kind: 'query' as const, + // 保留首个指标的 RefID,兼容表达式对该 target 的已有引用;其余指标使用唯一子 RefID。 + ref_id: valueIndex === 0 ? refId : getValueRefId(refId, valueIndex), + datasource: { + cate: datasource.cate, + id: resolvedDatasourceId, + }, + result_type: inferTargetResultType(target), + query: queryPayload, + }, + ]; + }); } + const queryPayload = getDatasourceQueryPayload(target, datasource.cate, buildOptions); + if (queryPayload === undefined) return []; return [ { kind: 'query', @@ -166,7 +186,7 @@ export function buildDashboardQueryRequest(options: BuildDashboardQueryRequestOp id: resolvedDatasourceId, }, result_type: inferTargetResultType(target), - query: getDatasourceQueryPayload(target, datasource.cate, buildOptions), + query: queryPayload, }, ]; }); diff --git a/src/pages/dashboard/VARIABLES.md b/src/pages/dashboard/VARIABLES.md new file mode 100644 index 0000000000..54425f5ba8 --- /dev/null +++ b/src/pages/dashboard/VARIABLES.md @@ -0,0 +1,255 @@ +# 仪表盘变量(Variables) + +仪表盘变量为面板查询提供可交互的参数。用户在仪表盘顶部选择变量值,面板查询中的 `$var` 占位符被替换为实际值后再发起请求。变量之间可以相互引用,形成依赖链。 + +当前实现位于 `src/pages/dashboard/Variables/`(v3.2 重构)。同级的 `VariableConfig/` 是旧版实现,仪表盘详情页已不再使用,但内置仪表盘导入(`List/Import/ImportBuiltinContent.tsx`)以及若干工具函数(`escapePromQLString`、`stringToRegex` 等)仍在复用它。 + +## 目录结构 + +``` +src/pages/dashboard/Variables/ +├── index.tsx # 模块入口:渲染 Main + EditModal,追踪初始化完成并回调 onInitialized +├── Main.tsx # 挂载 VariableManagerProvider,渲染变量组件,同步 URL 与 localStorage +├── VariableManagerContext.tsx # 核心:依赖图、reconcile、执行调度、订阅 +├── types.ts # IVariable / VariableExecutionMeta / DependencyGraph / QueryOption +├── constant.ts # 变量类型选项、多选拼接分隔符 +├── datasource.ts # 按数据源 cate 分发变量查询 +├── plugins.ts # 变量插件注册表(内置 + Plus 扩展) +├── builtinPlugins.ts # 开源内置插件注册(当前为空,预留扩展点) +├── pluginTypes.ts # DashboardVariablePlugin 接口 +├── Variable/ # 运行时展示组件,每种类型一个 +│ ├── index.tsx # 按 type 路由到具体组件 +│ ├── Query.tsx # query:调用 datasource.ts 拉取 options +│ ├── Datasource.tsx # datasource:从 groupedDatasourceList 生成 options +│ ├── DatasourceIdentifier.tsx # datasourceIdentifier:同上,值为 identifier +│ ├── Custom.tsx # custom:解析 definition 的逗号分隔值 +│ ├── Constant.tsx # constant:definition 即为值 +│ ├── Textbox.tsx # textbox:自由输入 +│ └── HostIdent.tsx # hostIdent:调用 getMonObjectList +├── EditModal/ # 变量编辑弹窗 +│ ├── index.tsx # 变量列表:增删改排序,保存时写回全局态并回调 onChange +│ ├── Variable/ # 各类型的编辑表单 +│ ├── Querybuilder.tsx # 按 cate 渲染对应的查询构造器 +│ └── Preview.tsx # 编辑态「数据预览」 +└── utils/ + ├── initializeVariablesValue.ts # 首次加载:从 URL / localStorage 恢复 value + ├── ajustData.ts # 构建插值映射表(含多选拼接、All 展开) + ├── formatString.ts # 占位符替换;formatDatasource 解析数据源 ID + ├── replaceTemplateVariables.ts # 对外插值入口;getBuiltInVariables 提供内置变量 + ├── processQueryOptions.ts # 规范化查询结果、正则过滤与命名捕获组 + ├── filterOptionsByReg.ts # 字符串选项的正则过滤 + ├── getValueByOptions.ts # options 就绪后确定变量值 + ├── stringToRegex.ts # 字符串转正则(支持 /pattern/flags) + ├── escapeString.ts # PromQL / JSON 转义 + ├── isPlaceholderQuoted.ts # ES 场景判断占位符是否已被引号包裹 + └── includes.ts # 判断值是否存在于 options 中 +``` + +## 分层架构 + +```mermaid +flowchart TD + Detail["Detail.tsx 仪表盘详情页"] --> Entry["Variables/index.tsx"] + Entry --> MainComp["Main.tsx"] + Entry --> Edit["EditModal 变量编辑"] + MainComp --> Provider["VariableManagerProvider 依赖图与执行调度"] + Provider --> VarComp["Variable/*.tsx 七种类型组件"] + VarComp --> Dispatch["datasource.ts 按 cate 分发"] + Dispatch --> Builtin["src/plugins 下的 prometheus / elasticsearch / clickHouse"] + Dispatch --> Plus["plus:/parcels/Dashboard/variableDatasource"] + VarComp --> Store["globalState.variablesWithOptions"] + Edit --> Store + Store --> Interp["replaceTemplateVariables 插值"] + Interp --> Render["Panels / Renderer 面板查询"] + Store -.->|"持久化 configs.var"| Detail +``` + +## 数据模型 + +### 变量类型 + +| type | 值的来源 | 是否发起查询 | 是否参与依赖分析 | +| --- | --- | --- | --- | +| `query` | 数据源查询结果 | 是 | 是(唯一会声明依赖的类型) | +| `datasource` | 按 `definition`(cate)筛选的数据源列表,值为数据源 ID | 否 | 否,但可被引用 | +| `datasourceIdentifier` | 同上,值为 `identifier` 字符串 | 否 | 否,但可被引用 | +| `custom` | `definition` 的逗号分隔值 | 否 | 否 | +| `constant` | `definition` 直接作为值 | 否 | 否 | +| `textbox` | 用户输入,回落到 `defaultValue` | 否 | 否 | +| `hostIdent` | `getMonObjectList` 接口 | 是 | 否 | + +「参与依赖分析」指该变量是否会被解析出对其他变量的引用。只有 `query` 类型会:其他类型即使在 `regex` 等字段里写了 `$var`,也不会建立依赖边(插值仍会在执行时生效)。 + +### IVariable 关键字段 + +定义见 `Variables/types.ts`。除常规的 `name` / `type` / `label` / `hide` 外,需要留意: + +- `definition`:主查询表达式。不同类型语义不同(query 是查询语句,datasource 是 cate,custom 是逗号分隔值,constant 是常量本身)。 +- `query`:v8 起新增的结构化查询条件,字段随数据源而异。其中所有字符串字段都会参与插值与依赖分析。 +- `datasource.value`:可以是数字 ID,也可以是 `"${db}"` 这样引用其他变量的字符串。 +- `reg` / `regex`:`reg` 过滤 query 结果,`regex` 过滤数据源列表。 +- `multi` / `allOption` / `allValue`:多选与 All 选项。 +- `options` / `value`:运行时产物,不持久化。 + +### 三层状态 + +```mermaid +flowchart LR + Persist["dashboard.configs.var 持久化配置"] -->|"载入时去掉 options"| Runtime["globalState.variablesWithOptions 运行时全局态"] + Runtime -->|"保存时去掉 value 和 options"| Persist + Runtime --> Local["Variable 组件本地 value 用于输入过程"] + Local -->|"选择完成"| Runtime +``` + +`Detail.tsx` 在保存时会 `_.omit(item, ['value', 'options'])`,因此 `value` 和 `options` 只存在于运行时。组件本地 `value` 是为了让多选下拉在展开期间不逐项触发查询,收起或单选时才写回全局态。 + +## 运行时执行模型 + +### 职责划分 + +`VariableManagerProvider` 是唯一的调度中心,变量组件只做两件事:登记自己的 `executor`,以及在用户交互时调用 `updateVariable` 写回值。 + +- **组件侧**(`Variable/*.tsx`):在 `useEffect([variableConfigSignature])` 中调用 `registerVariable({ name, variable, executor })`。`executor` 通过 `variableRef` / `rangeRef` 读取最新配置和时间范围,因此不需要随每次渲染重新注册。 +- **Provider 侧**:`registerVariable` 只写入注册表,不做依赖分析、不触发执行。依赖图构建与执行调度全部集中在 reconcile effect 中。 + +这个划分是必须的。React 的 effect 执行顺序是**子先父后**,只有父组件的 effect 才能同时看到完整的变量列表和完整的注册表。如果让子组件在注册时自行决定"要不要执行",新增变量时它看到的初始化状态是上一轮遗留的,会出现无人执行的空档。 + +### 依赖图 + +`buildDependencyGraph(variables)` 是纯函数,只依赖变量配置。对每个 `query` 变量扫描四处:`definition`、`query.query`、`datasource.value`、以及 `query` 对象的全部字符串字段(CloudWatch 一类数据源把变量引用放在 `query.region` 等子字段里)。 + +占位符语法三种都支持:`$var`、`${var}`、`[[var]]`。引用不存在的变量会被过滤;自引用会被剔除,否则拓扑排序会将其判定为循环依赖。 + +产物有两份: + +- `graph`:`{ 被依赖的变量名: [依赖它的变量名] }`,用于向下游传播。 +- `dependenciesByName`:`{ 变量名: [它依赖的变量名] }`,用于拓扑排序算入度。 + +以本文档常用的例子为例: + +```mermaid +flowchart LR + db["db (datasource)"] --> device["device (query, datasource.value = ${db})"] + device --> fstype["fstype (query, definition 引用 $device)"] + fstype --> pathVar["path (query, definition 引用 $device 与 $fstype)"] + db --> fstype + db --> pathVar +``` + +### reconcile + +变量列表或配置发生变化时(以排除了 `label` / `value` / `options` / `hide` 的**逐变量配置签名**判定),Provider 的 reconcile effect 会: + +1. 删除已不存在的变量的注册项; +2. 用 `buildDependencyGraph` **整体重建**依赖图,并为**全部**已注册变量刷新依赖与订阅; +3. 确认所有变量都已注册,否则本轮不推进签名,等组件就绪后重来; +4. 决定执行集合并按拓扑序执行。 + +第 2 步必须覆盖全部注册项,而不只是配置变化的那些。因为组件的注册 effect 只在自身配置签名变化时才重跑,配置没变的老变量不会重新注册——如果只重建变化部分,它们的依赖边会永久丢失。 + +执行集合分两种情况: + +- **首轮**(`initialized` 为 false):执行全部变量。 +- **增量**(已初始化):取配置发生变化的变量,沿 `graph` 展开出全部传递下游,去重后执行。新增的变量在上一轮签名中不存在,天然被识别为变化。 + +每轮 reconcile 持有一个递增的 token,执行过程中每步都会检查是否已被更新的一轮取代,以便尽早中止。 + +### 首次加载时序 + +```mermaid +sequenceDiagram + participant Detail as "Detail.tsx" + participant Child as "Variable/*.tsx 子 effect" + participant Prov as "Provider 父 effect" + participant DS as "datasource.ts" + Detail->>Detail: "拉取仪表盘配置,去掉已保存的 options" + Detail->>Detail: "initializeVariablesValue 从 URL / localStorage 恢复 value" + Detail->>Child: "setVariablesWithOptions" + Child->>Prov: "registerVariable 逐个登记 executor" + Prov->>Prov: "buildDependencyGraph 重建依赖图与订阅" + Prov->>DS: "executeVariables 按拓扑序执行全部变量" + DS-->>Prov: "options" + Prov->>Prov: "updateVariable 写回 options 与 value" + Prov->>Detail: "全部 query 变量 options 就绪后回调 onInitialized" + Detail->>Detail: "variablesInitialized = true,开始渲染面板" +``` + +面板要等 `onInitialized` 之后才渲染,避免用未就绪的变量值发起查询。判定条件是所有 `query` 变量的 `options` 属性存在(空数组也算就绪)。 + +### 用户改值的级联 + +用户在下拉框选择新值时走的是另一条路径:`updateVariable` 写回值并通知该变量的订阅者,订阅回调触发 `triggerDependencyUpdate`,后者查 `graph` 找到全部下游、拓扑排序后依次执行。 + +执行链期间 `isExecutingChain` 为 true,此时链内的 `updateVariable` 不再通知订阅者,避免同一条链被重复触发。 + +### 时间范围变化 + +时间范围变化时,`refreshQueryVariablesForRangeChange` 会按拓扑序重跑全部已注册的 `query` 变量。 + +这里有一个容易出错的时序:`Detail.tsx` 的本地 `range` 是通过 effect 同步到 globalState 的,Provider 首次渲染时拿到的还是默认值 `now-1h`,之后才被改写成仪表盘的真实范围,而此时变量列表通常还是空的。因此: + +- 变量尚未完成首轮执行时,时间范围变化**不做任何处理**,因为首轮执行本来就会使用最新的时间范围; +- 首轮执行开始时,reconcile 会把当前时间范围签名记为已处理,防止随后触发一轮重复刷新。 + +如果缺了这两步,页面加载时会出现首轮执行与范围刷新两条链并发推进,每个变量被查询两次。 + +## 插值 + +`replaceTemplateVariables(str, params)` 是对外的统一入口,面板查询、文本面板、链接等都走它。流程是先用 `adjustData` 把变量列表压成 `{ 变量名: 插值结果 }` 的映射表,再交给 `formatString` 做替换。 + +几个要点: + +- **三种语法**:`$var`、`${var}`、`[[var]]`。`$var` 会做最长前缀匹配,`$devicename` 在只存在 `device` 变量时会替换成 `${device}name`。 +- **多选拼接**:由数据源决定分隔符,见 `constant.ts` 的 `replaceAllSeparatorMap`(Prometheus 用 `|`,ES 用 ` OR `),多个值会被括号包裹;MySQL 走 Grafana sqlstring 风格,逐值加单引号。 +- **All 选项**:值为 `['all']` 时,若配置了 `allValue` 就用它,否则展开为全部 options 的拼接。 +- **转义**:Prometheus 走 `escapePromQLString`,ES 视占位符是否已被引号包裹决定是否补引号并做 JSON 转义。 +- **数据源变量**:`formatDatasource` 会把插值结果转成数字 ID,供查询请求使用。 +- **内置变量**:`getBuiltInVariables` 提供 `$__from`、`$__to`、`$__interval`、`$__range` 等时间相关变量,随插值一起注入,不需要用户定义。 + +## 查询与选项处理 + +`datasource.ts` 按 `datasourceCate` 分发:Prometheus、Elasticsearch、ClickHouse 走 `src/plugins/` 下各自的 `variableDatasource`,其余交给 Plus 的 `plus:/parcels/Dashboard/variableDatasource`。 + +查询结果由 `processQueryOptions` 规范化。它同时接受标量数组(`['a', 'b']`)和对象数组(`[{ label, value }]`),统一产出 `{ label: string, value: string }[]` 并按 value 排序去重。`reg` 支持两种写法:普通字符串按全等匹配,`/pattern/flags` 按正则匹配;正则可以用命名捕获组 `(?...)` 和 `(?...)` 分别指定展示文本与实际值。 + +options 就绪后由 `getValueByOptions` 决定值:已有值且仍在 options 中就保留,否则回落到 `defaultValue`,再否则取第一项(多选且开了 All 时取 `['all']`)。URL 带 `__variable_value_fixed` 时跳过这套逻辑,保持传入值不变。 + +## 插件机制 + +`pluginTypes.ts` 定义了 `DashboardVariablePlugin`,按数据源 cate 注册,提供两个能力: + +- `capabilities(query)`:声明该数据源在当前查询条件下是否支持多选与 All,编辑表单据此显示或隐藏对应开关。 +- `transformQuery(query, context)`:面板查询构建时替代通用插值逻辑(见 `Renderer/datasource/contract.ts`)。返回 `undefined` 表示依赖尚未就绪,本次不发起查询。 + +注册表由 `builtinPlugins.ts`(开源内置,目前为空)与 `plus:/parcels/Dashboard/variablePlugins`(Plus 扩展)合并而成,同名以 Plus 为准。未注册的 cate 继续走原有的通用流程。 + +## 值的持久化与恢复 + +| 载体 | 写入时机 | 用途 | +| --- | --- | --- | +| `dashboard.configs.var` | 编辑变量后保存仪表盘 | 变量定义,不含 `value` / `options` | +| URL query | 变量值变化时 `history.replace` | 分享链接、跨页携带 | +| localStorage | 变量值变化时,键为 `dashboard_v6_{dashboardId}_{name}` | 同一用户下次访问时恢复上次选择 | + +`initializeVariablesValue` 在首次加载时按 URL 优先、localStorage 兜底的顺序恢复值,并做类型归一:datasource 变量转数字,多选转数组,单选取数组首项,空值统一为 `undefined`(textbox 例外,回落到 `defaultValue` 或空字符串)。URL 中带 `__variable_value_fixed` 时不读 localStorage,也不再自动补默认值。 + +## 关键约束 + +改动这块代码时容易破坏的几条不变量: + +1. **依赖图必须整体重建**。配置未变的变量不会重新注册,只重建变化部分会让它们的依赖边丢失。 +2. **执行调度必须留在 Provider 的 effect 里**。子组件的 effect 先于父组件执行,在那里判断"要不要执行"看到的是上一轮的状态。 +3. **首轮执行要同时认领当前时间范围**,否则会与范围刷新并发,导致每个变量被查询两次。 +4. **不要给批量执行加串行队列**。系统靠 `Query.tsx` 的 `requestIdRef` 丢弃过期响应来处理并发,串行队列会让一个悬挂的请求阻塞后续所有执行。 +5. **配置签名必须排除 `label` / `value` / `options` / `hide`**。这些字段在每次查询后都会变化,纳入签名会造成无限重执行。 +6. **`executor` 通过 ref 读取最新配置**,因此注册 effect 只依赖配置签名,不要改成每次渲染都重新注册。 + +## 测试 + +- `__tests__/VariableManagerContext.test.ts`:依赖分析与拓扑排序的纯函数单测。 +- `__tests__/variableLifecycle.jsdom.test.tsx`:生命周期集成测试,覆盖新增 / 编辑 / 删除变量、依赖联动、时间范围与初始化的时序。 +- `__tests__/queryOptions.jsdom.test.tsx`:查询选项集成测试,覆盖下拉交互、多选与 All、正则、过期响应、失败恢复,并对比编辑态预览与运行时的一致性。 +- `__tests__/plugins.test.ts`:插件注册表的合并与覆盖规则。 +- `utils/__tests__/`:插值、初始化、选项处理等工具函数单测。 + +运行:`npx jest src/pages/dashboard/Variables`。这两个 jsdom 套件较重,在高并行度下偶尔会触发 5 秒用例超时,可加 `--maxWorkers=2`。 diff --git a/src/pages/dashboard/VariableConfig/EditItem.tsx b/src/pages/dashboard/VariableConfig/EditItem.tsx index 29bacb9bfb..fed925fc96 100644 --- a/src/pages/dashboard/VariableConfig/EditItem.tsx +++ b/src/pages/dashboard/VariableConfig/EditItem.tsx @@ -14,6 +14,7 @@ * limitations under the License. * */ +import { getDashboardVariablePlugin } from '../Variables/plugins'; import React, { useContext } from 'react'; import { Form, Input, Row, Col, Select, Switch, Button, Space, Alert } from 'antd'; import _ from 'lodash'; @@ -24,6 +25,7 @@ import { DatasourceSelectV3 } from '@/components/DatasourceSelect'; import { IRawTimeRange } from '@/components/TimeRangePicker'; import { CommonStateContext } from '@/App'; import { Dashboard } from '@/store/dashboardInterface'; +import type { JsonObject } from '@/pages/dashboard/types'; import { IVariable } from './definition'; import { stringToRegex } from './constant'; @@ -82,6 +84,8 @@ function EditItem(props: IProps) { const otherVars = _.filter(vars, (item) => item.name !== data.name); const varType = Form.useWatch(['type'], form); const datesourceCate = Form.useWatch(['datasource', 'cate'], form); + const variableQuery = Form.useWatch('query', form); + const capabilities = varType === 'query' ? getDashboardVariablePlugin(datesourceCate)?.capabilities(variableQuery) : undefined; return (
@@ -364,7 +368,7 @@ function EditItem(props: IProps) { )} {(_.includes(['custom', 'hostIdent'], varType) || - _.includes([DatasourceCateEnum.prometheus, DatasourceCateEnum.elasticsearch, DatasourceCateEnum.pgsql], datesourceCate)) && ( + (capabilities?.multi ?? _.includes([DatasourceCateEnum.prometheus, DatasourceCateEnum.elasticsearch, DatasourceCateEnum.pgsql], datesourceCate))) && ( @@ -375,7 +379,7 @@ function EditItem(props: IProps) { prevValues.multi !== curValues.multi} noStyle> {({ getFieldValue }) => { const multi = getFieldValue('multi'); - if (multi) { + if (multi && capabilities?.all !== false) { return ( @@ -392,10 +396,10 @@ function EditItem(props: IProps) { {({ getFieldValue }) => { const multi = getFieldValue('multi'); const allOption = getFieldValue('allOption'); - if (multi && allOption) { + if (multi && allOption && capabilities?.all !== false) { return ( - + ); } diff --git a/src/pages/dashboard/VariableConfig/Querybuilder.tsx b/src/pages/dashboard/VariableConfig/Querybuilder.tsx index c5df294134..6c12ad1426 100644 --- a/src/pages/dashboard/VariableConfig/Querybuilder.tsx +++ b/src/pages/dashboard/VariableConfig/Querybuilder.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import React, { useContext, useMemo } from 'react'; import { Form, Space, Input } from 'antd'; import { QuestionCircleOutlined } from '@ant-design/icons'; import _ from 'lodash'; @@ -11,7 +11,7 @@ import { VariableQuerybuilder as ClickHouse } from '@/plugins/clickHouse'; import { IVariable } from './definition'; import ElasticsearchSettings from './datasource/elasticsearch'; -import { replaceExpressionVars } from './constant'; +import { replaceExpressionVars, getVaraiableSelected } from './constant'; // @ts-ignore import VariableQuerybuilderPro from 'plus:/parcels/Dashboard/VariableQuerybuilder'; @@ -37,6 +37,7 @@ export default function Querybuilder(props: Props) { datasourceList, }), ); + const variablesWithSelectedValue = useMemo(() => variables.map((variable) => ({ ...variable, value: getVaraiableSelected(variable, dashboardId) })), [dashboardId, variables]); if (_.includes(['prometheus', 'elasticsearch', 'pgsql'], datasourceCate)) { return ( @@ -94,5 +95,5 @@ export default function Querybuilder(props: Props) { if (datasourceCate === DatasourceCateEnum.ck) { return ; } - return ; + return ; } diff --git a/src/pages/dashboard/Variables/EditModal/Querybuilder.tsx b/src/pages/dashboard/Variables/EditModal/Querybuilder.tsx index c0b8e4e924..37001250b7 100644 --- a/src/pages/dashboard/Variables/EditModal/Querybuilder.tsx +++ b/src/pages/dashboard/Variables/EditModal/Querybuilder.tsx @@ -13,7 +13,7 @@ import { replaceDatasourceVariables } from '../utils/replaceTemplateVariables'; // @ts-ignore import VariableQuerybuilderPro from 'plus:/parcels/Dashboard/VariableQuerybuilder'; -export default function Querybuilder() { +export default function Querybuilder({ variables }: { variables?: import('../types').IVariable[] }) { const { datasourceList } = useContext(CommonStateContext); const datasourceCate = Form.useWatch(['datasource', 'cate']); const datasourceValue = Form.useWatch(['datasource', 'value']); @@ -31,7 +31,7 @@ export default function Querybuilder() { {datasourceCate === DatasourceCateEnum.prometheus && } {datasourceCate === DatasourceCateEnum.elasticsearch && } {datasourceCate === DatasourceCateEnum.ck && } - +
); diff --git a/src/pages/dashboard/Variables/EditModal/Variable/Query.tsx b/src/pages/dashboard/Variables/EditModal/Variable/Query.tsx index 265ae97ada..fb3e62ae96 100644 --- a/src/pages/dashboard/Variables/EditModal/Variable/Query.tsx +++ b/src/pages/dashboard/Variables/EditModal/Variable/Query.tsx @@ -16,6 +16,7 @@ import adjustData from '../../utils/ajustData'; import isPlaceholderQuoted from '../../utils/isPlaceholderQuoted'; import { formatString, formatDatasource } from '../../utils/formatString'; import processQueryOptions from '../../utils/processQueryOptions'; +import { getDashboardVariablePlugin } from '../../plugins'; import { getBuiltInVariables } from '../../utils/replaceTemplateVariables'; import Querybuilder from '../Querybuilder'; import datasource from '../../datasource'; @@ -45,6 +46,7 @@ export default function Query(props: Props) { const form = Form.useFormInstance(); const item = Form.useWatch([]); const datasourceCate = Form.useWatch(['datasource', 'cate']); + const capabilities = getDashboardVariablePlugin(datasourceCate)?.capabilities(item?.query); const service = () => { if (item) { @@ -73,6 +75,7 @@ export default function Query(props: Props) { datasourceCate, datasourceValue, datasourceList, + variableContext: { variables: variablesWithOptions, query: { ...item.query, range } }, query: { ...(item.query || {}), query: formatedDefinition || formatedQuery, // query 是标准写法 @@ -138,7 +141,7 @@ export default function Query(props: Props) { }} /> - + - {_.includes([DatasourceCateEnum.prometheus, DatasourceCateEnum.elasticsearch, DatasourceCateEnum.pgsql, DatasourceCateEnum.mysql], datasourceCate) && ( + {(capabilities?.multi ?? + _.includes([DatasourceCateEnum.prometheus, DatasourceCateEnum.elasticsearch, DatasourceCateEnum.pgsql, DatasourceCateEnum.mysql], datasourceCate)) && ( - {item?.multi ? ( + {item?.multi && capabilities?.all !== false ? ( ) : null} - {item?.multi && item?.allOption ? ( + {item?.multi && item?.allOption && capabilities?.all !== false ? ( - + ) : null} diff --git a/src/pages/dashboard/Variables/Variable/Query.tsx b/src/pages/dashboard/Variables/Variable/Query.tsx index 0131ceab3c..e0b41558d6 100644 --- a/src/pages/dashboard/Variables/Variable/Query.tsx +++ b/src/pages/dashboard/Variables/Variable/Query.tsx @@ -74,14 +74,20 @@ export default function Query(props: Props) { setErrorMsg(''); try { - // 对 query 对象中所有字符串字段执行变量替换,确保依赖链执行时使用最新变量值 - // 部分数据源(如 CloudWatch query.region)的变量引用在此处提前解析 - const interpolatedQuery: JsonObject = {}; - if (currentVariable.query) { - Object.entries(currentVariable.query).forEach(([key, val]) => { - interpolatedQuery[key] = typeof val === 'string' ? formatString(val, variableInterpolations) : val; - }); - } + // 递归替换 query 树中的变量引用,覆盖 GCM filters、group_bys 等嵌套字段。 + // currentVariable.query 来源于可序列化的表单配置,不含循环引用,故不设 visited 防护。 + const interpolateQueryValue = (value: unknown): unknown => { + if (typeof value === 'string') return formatString(value, variableInterpolations); + if (Array.isArray(value)) return value.map(interpolateQueryValue); + if (value && typeof value === 'object') { + return Object.entries(value).reduce>((result, [key, item]) => { + result[key] = interpolateQueryValue(item); + return result; + }, {}); + } + return value; + }; + const interpolatedQuery = (interpolateQueryValue(currentVariable.query) ?? {}) as JsonObject; const query: VariableDatasourceQuery = { ...interpolatedQuery, query: formatedDefinition || formatedQuery, // query 是标准写法 @@ -93,6 +99,7 @@ export default function Query(props: Props) { datasourceValue, datasourceList, query, + variableContext: { variables: getVariables(), query: { ...currentVariable.query, range: currentRange } }, }); if (requestId !== requestIdRef.current) { return; diff --git a/src/pages/dashboard/Variables/VariableManagerContext.tsx b/src/pages/dashboard/Variables/VariableManagerContext.tsx index 67301a8481..ba0321544f 100644 --- a/src/pages/dashboard/Variables/VariableManagerContext.tsx +++ b/src/pages/dashboard/Variables/VariableManagerContext.tsx @@ -11,13 +11,14 @@ export function extractDependencies(str: string, validVars?: Set): strin // 匹配规则: // - 支持 $var 格式:$ 符号后跟一个或多个字母、数字、下划线 // - 支持 ${var} 格式:$ 符号后跟大括号,内部为一个或多个字母、数字、下划线 - const regex = /\$\{([a-zA-Z0-9_]+)\}|\$([a-zA-Z0-9_]+)/g; + // - 支持 [[var]],与变量插值支持的语法一致 + const regex = /\$\{([a-zA-Z0-9_]+)\}|\$([a-zA-Z0-9_]+)|\[\[([a-zA-Z0-9_]+)\]\]/g; let match; const dependencies = new Set(); while ((match = regex.exec(str)) !== null) { - // 第一个捕获组是 ${var} 的情况,第二个是 $var 的情况 - const varName = match[1] || match[2]; + // 三个捕获组分别对应 ${var}、$var 和 [[var]] + const varName = match[1] || match[2] || match[3]; if (varName) { if (validVars && !validVars.has(varName)) { continue; @@ -42,6 +43,93 @@ function stringifyStable(obj: unknown): string { return '{' + keys.map((k) => `"${k}":${stringifyStable(record[k])}`).join(',') + '}'; } +// 变量的配置签名,排除 label、value、options、hide 这些不影响执行逻辑的字段 +function getVariableSignature(variable: Variable): string { + const { label, value, options, hide, ...rest } = variable; + return stringifyStable(rest); +} + +// 递归扫描 query 树收集变量引用。 +// variable.query 来源于可序列化的表单配置,不含循环引用,故不设 visited 防护。 +function collectDependenciesFromValue(value: unknown, validVarNames: Set, dependencySet: Set) { + if (typeof value === 'string') { + extractDependencies(value, validVarNames).forEach((dep) => dependencySet.add(dep)); + } else if (Array.isArray(value)) { + value.forEach((item) => collectDependenciesFromValue(item, validVarNames, dependencySet)); + } else if (value && typeof value === 'object') { + Object.values(value).forEach((item) => collectDependenciesFromValue(item, validVarNames, dependencySet)); + } +} + +// 分析单个变量引用了哪些其他变量,只有 query 类型的变量需要参与依赖求值 +export function collectVariableDependencies(variable: Variable, validVarNames: Set): string[] { + if (variable.type !== 'query') { + return []; + } + + const dependencySet = new Set(); + + // 分析 definition 中的依赖 + if (variable.definition) { + extractDependencies(variable.definition, validVarNames).forEach((dep) => dependencySet.add(dep)); + } else if (variable.query?.query) { + const variableQuery = variable.query.query; + if (typeof variableQuery === 'string') { + extractDependencies(variableQuery, validVarNames).forEach((dep) => dependencySet.add(dep)); + } + } + + // 分析 datasource.value 中的依赖 + if (variable.datasource?.value && typeof variable.datasource.value === 'string') { + extractDependencies(variable.datasource.value, validVarNames).forEach((dep) => dependencySet.add(dep)); + } + + // 扫描完整 query 树。除顶层字段外,GCM 等数据源还会在 filters、group_bys 等嵌套结构中保存变量引用。 + collectDependenciesFromValue(variable.query, validVarNames, dependencySet); + + // 自引用不构成依赖,否则会被拓扑排序判定为循环依赖 + dependencySet.delete(variable.name); + + return Array.from(dependencySet); +} + +// 基于完整变量列表重建依赖关系图,结果只与变量配置有关,与组件注册顺序无关 +export function buildDependencyGraph(variables: Variable[]): { graph: DependencyGraph; dependenciesByName: Record } { + const validVarNames = new Set(variables.map((v) => v.name)); + const graph: DependencyGraph = {}; + const dependenciesByName: Record = {}; + + variables.forEach((variable) => { + const dependencies = collectVariableDependencies(variable, validVarNames); + dependenciesByName[variable.name] = dependencies; + dependencies.forEach((dep) => { + if (!graph[dep]) graph[dep] = []; + if (!graph[dep].includes(variable.name)) { + graph[dep].push(variable.name); + } + }); + }); + + return { graph, dependenciesByName }; +} + +// 沿依赖图向下游展开,得到需要跟随变化一起重新执行的变量集合 +function collectWithDownstream(names: string[], graph: DependencyGraph): string[] { + const result = new Set(); + const queue = [...names]; + + while (queue.length > 0) { + const current = queue.shift()!; + if (result.has(current)) continue; + result.add(current); + (graph[current] || []).forEach((next) => { + if (!result.has(next)) queue.push(next); + }); + } + + return Array.from(result); +} + interface VariableManagerContextType { getVariables: () => Variable[]; updateVariable: (name: string, partial: Partial) => void; @@ -138,23 +226,36 @@ export const VariableManagerProvider = ({ } // 依赖关系图 const dependencyGraph = useRef({}); + // 每个变量依赖的变量名,与 dependencyGraph 一同由 reconcile 集中重建 + const dependenciesByName = useRef>({}); // 订阅者映射:key为变量名,value为订阅该变量变化的回调函数列表 const subscribers = useRef void>>>(new Map()); // 执行锁:防止递归触发依赖更新 const executingDependencies = useRef>(new Set()); // 全局执行锁:标记是否正在执行依赖链 const isExecutingChain = useRef(false); + // 执行链可以重叠(例如新 reconcile 取代旧 reconcile),用计数避免旧链结束时提前释放共享锁。 + const executingChainCount = useRef(0); // 初始化状态:标记是否已完成初始执行 const initialized = useRef(false); - // 等待初始化的变量队列 - const pendingInitialExecution = useRef>(new Set()); - // 追踪正在重新执行的变量,防止并发竞争 - const reExecutingVariables = useRef>(new Set()); + // 上一次 reconcile 时各变量的配置签名,用于定位发生变化的变量 + const previousSignatures = useRef | null>(null); + // reconcile 轮次,用于丢弃上一轮遗留的异步执行 + const reconcileToken = useRef(0); const rangeStart = moment.isMoment(range.start) ? range.start.valueOf() : range.start; const rangeEnd = moment.isMoment(range.end) ? range.end.valueOf() : range.end; const rangeSignature = `${rangeStart}\u0000${rangeEnd}\u0000${range.refreshFlag || ''}`; const previousRangeSignature = useRef(rangeSignature); - const pendingRangeRefreshSignature = useRef(null); + + const beginExecutionChain = useCallback(() => { + executingChainCount.current += 1; + isExecutingChain.current = true; + }, []); + + const endExecutionChain = useCallback(() => { + executingChainCount.current = Math.max(0, executingChainCount.current - 1); + isExecutingChain.current = executingChainCount.current > 0; + }, []); const getVariables = useCallback(() => { // 在依赖链执行期间,返回 ref 中的最新值 @@ -223,10 +324,7 @@ export const VariableManagerProvider = ({ // 标记为正在执行 executingDependencies.current.add(dependencyName); - const isRootExecution = !isExecutingChain.current; - if (isRootExecution) { - isExecutingChain.current = true; - } + beginExecutionChain(); try { // 对依赖变量进行拓扑排序,确保执行顺序正确 @@ -242,12 +340,10 @@ export const VariableManagerProvider = ({ } finally { // 执行完成后移除标记 executingDependencies.current.delete(dependencyName); - if (isRootExecution) { - isExecutingChain.current = false; - } + endExecutionChain(); } }, - [topologicalSort], + [beginExecutionChain, endExecutionChain, topologicalSort], ); const refreshQueryVariablesForRangeChange = useCallback(async () => { @@ -257,7 +353,7 @@ export const VariableManagerProvider = ({ return; } - isExecutingChain.current = true; + beginExecutionChain(); try { for (const variableName of executionOrder) { const meta = registeredVariables.current.get(variableName); @@ -266,243 +362,133 @@ export const VariableManagerProvider = ({ } } } finally { - isExecutingChain.current = false; + endExecutionChain(); } - }, [variables]); - - // 检查并执行初始化 - const checkAndExecuteInitialization = useCallback(() => { - const registeredNames = new Set(registeredVariables.current.keys()); - const allRegistered = variables.length > 0 && variables.every((v) => registeredNames.has(v.name)); + }, [beginExecutionChain, endExecutionChain, variables]); - if (allRegistered && !initialized.current && pendingInitialExecution.current.size > 0) { - initialized.current = true; + // 按拓扑序依次执行指定变量,执行期间持有执行锁,避免链内的取值变化重复触发依赖更新 + // isStale 用于在变量配置又发生变化时提前中止本轮执行 + const executeVariables = useCallback( + async (names: string[], isStale?: () => boolean) => { + const executable = names.filter((name) => registeredVariables.current.has(name)); - // 执行所有无依赖的变量 - const executeInitialVariables = async () => { - // 保存待执行的变量名,用于后续触发依赖链 - const initialVarNames = Array.from(pendingInitialExecution.current); - - // 设置执行锁,防止初始化期间触发依赖链 - isExecutingChain.current = true; - - try { - for (const name of initialVarNames) { - const meta = registeredVariables.current.get(name); - if (meta) { - try { - await meta.executor(); - } catch (error) { - console.error(`[VariableManager] 执行 ${name} 失败:`, error); - } - } - } - pendingInitialExecution.current.clear(); - } finally { - // 释放执行锁 - isExecutingChain.current = false; - - // 初始化完成后,手动触发所有无依赖变量的依赖链 - for (const name of initialVarNames) { - const dependentVariables = dependencyGraph.current[name] || []; - if (dependentVariables.length > 0) { - await triggerDependencyUpdate(name); - } - } - - if (pendingRangeRefreshSignature.current && pendingRangeRefreshSignature.current !== previousRangeSignature.current) { - previousRangeSignature.current = pendingRangeRefreshSignature.current; - pendingRangeRefreshSignature.current = null; - - try { - await refreshQueryVariablesForRangeChange(); - } catch (error) { - console.error('[VariableManager] pending range refresh failed:', error); - } - } - } - }; + if (executable.length === 0) { + return; + } - executeInitialVariables(); - } - }, [refreshQueryVariablesForRangeChange, triggerDependencyUpdate, variables]); - - // 注册变量到管理器(自动分析依赖) - const registerVariable = useCallback( - async (meta: Omit & { variable: Variable }) => { - const { name, variable } = meta; - - // 如果已经注册过,先清理旧的订阅 - const oldMeta = registeredVariables.current.get(name); - const wasRegistered = !!oldMeta; - if (oldMeta?.cleanup) { - oldMeta.cleanup(); + let executionOrder = executable; + try { + executionOrder = topologicalSort(executable, dependencyGraph.current); + } catch (error) { + // 存在循环依赖时退化为注册顺序执行,至少保证变量能拿到可选项 + console.error('[VariableManager] 变量依赖排序失败:', error); } - // 自动分析依赖关系 - let dependencies: string[] = []; - const dependencySet = new Set(); - const validVarNames = new Set(variablesRef.current.map((v) => v.name)); - - if (variable.type === 'query') { - // 分析 definition 中的依赖 - if (variable.definition) { - extractDependencies(variable.definition as string, validVarNames).forEach((dep) => dependencySet.add(dep)); - } else if (variable.query?.query) { - const variableQuery = variable.query.query; - if (typeof variableQuery === 'string') { - extractDependencies(variableQuery, validVarNames).forEach((dep) => dependencySet.add(dep)); + beginExecutionChain(); + try { + for (const name of executionOrder) { + if (isStale?.()) break; + const meta = registeredVariables.current.get(name); + if (!meta) continue; + try { + await meta.executor(); + } catch (error) { + console.error(`[VariableManager] 执行 ${name} 失败:`, error); } } - - // 分析 datasource.value 中的依赖 - if (variable.datasource?.value && typeof variable.datasource.value === 'string') { - extractDependencies(variable.datasource.value, validVarNames).forEach((dep) => dependencySet.add(dep)); - } - - // 扫描 variable.query 对象中所有字符串字段的变量引用 - // 部分数据源(如 CloudWatch)将变量引用存储在 query 子字段中, - // 如 query.region、query.namespace 等 - if (variable.query && typeof variable.query === 'object') { - Object.values(variable.query).forEach((val) => { - if (typeof val === 'string') { - extractDependencies(val, validVarNames).forEach((dep) => dependencySet.add(dep)); - } - }); - } + } finally { + endExecutionChain(); } + }, + [beginExecutionChain, endExecutionChain, topologicalSort], + ); - dependencies = Array.from(dependencySet); - - // 更新依赖关系图 - dependencies.forEach((dep) => { - if (!dependencyGraph.current[dep]) dependencyGraph.current[dep] = []; - if (!dependencyGraph.current[dep].includes(name)) { - dependencyGraph.current[dep].push(name); - } - }); - - // 创建完整的执行元数据 - const fullMeta: VariableExecutionMeta = { - ...meta, - dependencies, - }; - - registeredVariables.current.set(name, fullMeta); - - // 如果是无依赖变量,加入待执行队列 - if (dependencies.length === 0) { - pendingInitialExecution.current.add(name); - } + // 用最新的依赖图刷新所有已注册变量的依赖与订阅 + // 配置未变化的变量不会重新注册,因此这里必须覆盖全部注册项,否则它们的依赖联动会丢失 + const applyDependencies = useCallback(() => { + registeredVariables.current.forEach((meta, name) => { + meta.cleanup?.(); - // 订阅所有依赖变量的变化 + const dependencies = dependenciesByName.current[name] ?? []; const unsubscribers = dependencies.map((dep) => { return subscribeToVariable(dep, () => { triggerDependencyUpdate(dep); }); }); - // 添加清理函数 - if (fullMeta.cleanup) { - const originalCleanup = fullMeta.cleanup; - fullMeta.cleanup = () => { - originalCleanup(); - unsubscribers.forEach((unsubscribe) => unsubscribe()); - }; - } else { - fullMeta.cleanup = () => { - unsubscribers.forEach((unsubscribe) => unsubscribe()); - }; - } + meta.dependencies = dependencies; + meta.cleanup = () => { + unsubscribers.forEach((unsubscribe) => unsubscribe()); + }; + }); + }, [subscribeToVariable, triggerDependencyUpdate]); - // 检查是否所有变量都已注册,如果是则触发初始化 - checkAndExecuteInitialization(); + // 注册变量到管理器 + // 这里只登记执行器,依赖分析与执行调度由下方的 reconcile 统一负责: + // 子组件的 effect 先于 Provider 的 effect 执行,只有 reconcile 才能看到完整的变量集合 + const registerVariable = useCallback((meta: Omit & { variable: Variable }) => { + const { name } = meta; - // 变量配置变更后需要立即重新执行并触发依赖链(防止并发竞争) - if (wasRegistered && initialized.current) { - // 如果已经在重新执行,则跳过(防止同一变量的多个重新执行竞争) - if (reExecutingVariables.current.has(name)) { - return; - } + // 如果已经注册过,先清理旧的订阅 + registeredVariables.current.get(name)?.cleanup?.(); - const reExecute = async () => { - reExecutingVariables.current.add(name); - try { - await fullMeta.executor(); - } catch (error) { - console.error(`[registerVariable] 重新执行 ${name} 失败:`, error); - } finally { - await triggerDependencyUpdate(name); - reExecutingVariables.current.delete(name); - } - }; - reExecute(); - } - }, - [subscribeToVariable, triggerDependencyUpdate, checkAndExecuteInitialization], - ); + registeredVariables.current.set(name, { + ...meta, + dependencies: dependenciesByName.current[name] ?? [], + }); + }, []); - // 清理依赖关系图(当变量列表或配置变化时) - // 生成包含关键配置的 key,排除 label、value、options、hide 这些不影响执行逻辑的字段 + // 变量配置的签名列表,排除 label、value、options、hide 这些不影响执行逻辑的字段 // 使用 useMemo 缓存以避免不必要的重新计算 - const variablesKey = React.useMemo(() => { - return variables - .map((v) => { - const { label, value, options, hide, ...rest } = v; - return stringifyStable(rest); - }) - .sort() - .join(','); - }, [variables]); - - const variableNamesKey = React.useMemo(() => { - return variables - .map((v) => v.name) - .sort() - .join(','); - }, [variables]); - const previousVariablesKey = useRef(''); - const previousVariableNamesKey = useRef(''); + const variableSignatures = React.useMemo(() => variables.map((v) => [v.name, getVariableSignature(v)] as const), [variables]); + const variablesKey = React.useMemo(() => variableSignatures.map(([name, signature]) => `${name}\u0000${signature}`).join(','), [variableSignatures]); + // 变量列表或配置变化后的 reconcile + // 该 effect 在所有变量组件完成注册之后运行,是唯一能同时看到完整变量集与完整注册表的时机 useEffect(() => { // 当变量列表发生变化时,同步更新 ref variablesRef.current = variables; - // 清理不存在的变量的依赖关系,或配置已变化的变量 - if (!previousVariablesKey.current) { - previousVariablesKey.current = variablesKey; - previousVariableNamesKey.current = variableNamesKey; - return; - } + const signatures = new Map(variableSignatures); + const previous = previousSignatures.current; - if (previousVariablesKey.current !== variablesKey) { - const currentVariableNames = new Set(variables.map((v) => v.name)); - const namesChanged = previousVariableNamesKey.current !== variableNamesKey; - - // 仅在变量列表发生变化时重置初始化状态 - if (namesChanged) { - initialized.current = false; - pendingInitialExecution.current.clear(); + // 移除已被删除的变量 + registeredVariables.current.forEach((meta, name) => { + if (!signatures.has(name)) { + meta.cleanup?.(); + registeredVariables.current.delete(name); } + }); - // 清理所有已注册变量的注册信息(配置变化时需要重新注册) - Array.from(registeredVariables.current.keys()).forEach((name) => { - const meta = registeredVariables.current.get(name); - if (meta?.cleanup) { - meta.cleanup(); - } - // 如果变量已被删除,则从注册表中移除;否则保留但会在重新注册时被覆盖 - if (!currentVariableNames.has(name)) { - registeredVariables.current.delete(name); - } - }); + // 依赖图只与变量配置有关,这里整体重建,避免部分变量未重新注册导致依赖边丢失 + const { graph, dependenciesByName: nextDependencies } = buildDependencyGraph(variables); + dependencyGraph.current = graph; + dependenciesByName.current = nextDependencies; + applyDependencies(); - // 清理依赖关系图,准备重新构建 - dependencyGraph.current = {}; + // 还有变量组件未完成注册时先不推进签名,等就绪后仍能识别出本次变化 + const allRegistered = variables.length > 0 && variables.every((v) => registeredVariables.current.has(v.name)); + if (!allRegistered) { + return; + } + previousSignatures.current = signatures; - previousVariablesKey.current = variablesKey; - previousVariableNamesKey.current = variableNamesKey; + let targets: string[]; + if (!initialized.current) { + initialized.current = true; + targets = variables.map((v) => v.name); + // 首轮执行本就使用当前时间范围,标记为已处理,避免下方的 effect 再重复刷新一轮 + previousRangeSignature.current = rangeSignature; + } else { + // 新增的变量在 previous 中没有签名,同样会被识别为变化 + const changed = variables.filter((v) => previous?.get(v.name) !== signatures.get(v.name)).map((v) => v.name); + targets = collectWithDownstream(changed, graph); } + + const token = ++reconcileToken.current; + const isStale = () => token !== reconcileToken.current; + + void executeVariables(targets, isStale); // eslint-disable-next-line react-hooks/exhaustive-deps }, [variablesKey]); @@ -511,13 +497,12 @@ export const VariableManagerProvider = ({ return; } + // 变量尚未完成首轮执行,交给 reconcile 处理即可,它会使用最新的时间范围 if (!initialized.current) { - pendingRangeRefreshSignature.current = rangeSignature; return; } previousRangeSignature.current = rangeSignature; - pendingRangeRefreshSignature.current = null; void (async () => { try { diff --git a/src/pages/dashboard/Variables/__tests__/VariableManagerContext.test.ts b/src/pages/dashboard/Variables/__tests__/VariableManagerContext.test.ts index 7ac2adeef0..b9e427253e 100644 --- a/src/pages/dashboard/Variables/__tests__/VariableManagerContext.test.ts +++ b/src/pages/dashboard/Variables/__tests__/VariableManagerContext.test.ts @@ -1,6 +1,6 @@ /// -import { extractDependencies, getQueryVariableExecutionOrderForRangeChange } from '../VariableManagerContext'; +import { buildDependencyGraph, collectVariableDependencies, extractDependencies, getQueryVariableExecutionOrderForRangeChange } from '../VariableManagerContext'; import { IVariable, VariableExecutionMeta, DependencyGraph } from '../types'; describe('extractDependencies', () => { @@ -46,6 +46,127 @@ describe('extractDependencies', () => { }); }); +describe('collectVariableDependencies', () => { + test('should ignore non-query variables even when they reference other variables', () => { + const variable = { + name: 'db', + type: 'datasource', + definition: 'gcm', + regex: '/$env/', + datasource: { cate: 'gcm' }, + } as IVariable; + + expect(collectVariableDependencies(variable, new Set(['db', 'env']))).toEqual([]); + }); + + test('should not treat a self reference as a dependency', () => { + const variable = { + name: 'a', + type: 'query', + definition: 'label_values(up{a="$a"}, a)', + datasource: { cate: 'prometheus' }, + } as IVariable; + + expect(collectVariableDependencies(variable, new Set(['a']))).toEqual([]); + }); + + test('should collect from definition and datasource.value together', () => { + const variable = { + name: 'metric', + type: 'query', + definition: 'label_values(up{region="$region"}, metric)', + datasource: { cate: 'prometheus', value: '${db}' }, + } as IVariable; + + expect(collectVariableDependencies(variable, new Set(['metric', 'region', 'db']))).toEqual(['region', 'db']); + }); + + test('should collect references from nested query filters and arrays', () => { + const variable = { + name: 'metric', + type: 'query', + definition: '', + datasource: { cate: 'gcm' }, + query: { + filters: [{ key: 'zone', value: '${zone}' }], + group_bys: ['[[project]]', 'literal'], + }, + } as IVariable; + + expect(collectVariableDependencies(variable, new Set(['metric', 'project', 'zone']))).toEqual(['zone', 'project']); + }); +}); + +describe('buildDependencyGraph', () => { + test('should link a query variable to the datasource variable used by datasource.value', () => { + const variables = [ + { + name: 'db', + type: 'datasource', + definition: 'gcm', + datasource: { cate: 'gcm' }, + }, + { + name: 'project', + type: 'query', + definition: '', + datasource: { cate: 'gcm', value: '${db}' }, + }, + ] as IVariable[]; + + expect(buildDependencyGraph(variables)).toEqual({ + graph: { db: ['project'] }, + dependenciesByName: { db: [], project: ['db'] }, + }); + }); + + test('should collect dependencies from query sub-fields', () => { + const variables = [ + { + name: 'region', + type: 'query', + definition: 'regions', + datasource: { cate: 'cloudwatch' }, + query: { type: 'regions' }, + }, + { + name: 'namespace', + type: 'query', + definition: 'namespaces', + datasource: { cate: 'cloudwatch' }, + }, + { + name: 'metric', + type: 'query', + definition: '', + datasource: { cate: 'cloudwatch' }, + query: { region: '${region}', namespace: '[[namespace]]' }, + }, + ] as IVariable[]; + + expect(buildDependencyGraph(variables)).toEqual({ + graph: { region: ['metric'], namespace: ['metric'] }, + dependenciesByName: { region: [], namespace: [], metric: ['region', 'namespace'] }, + }); + }); + + test('should drop references to variables that do not exist', () => { + const variables = [ + { + name: 'instance', + type: 'query', + definition: 'label_values(up{region="$region"}, instance)', + datasource: { cate: 'prometheus' }, + }, + ] as IVariable[]; + + expect(buildDependencyGraph(variables)).toEqual({ + graph: {}, + dependenciesByName: { instance: [] }, + }); + }); +}); + describe('getQueryVariableExecutionOrderForRangeChange', () => { test('should only execute registered query variables in dependency order', () => { const variables = [ diff --git a/src/pages/dashboard/Variables/__tests__/plugins.test.ts b/src/pages/dashboard/Variables/__tests__/plugins.test.ts new file mode 100644 index 0000000000..1b570e8aea --- /dev/null +++ b/src/pages/dashboard/Variables/__tests__/plugins.test.ts @@ -0,0 +1,50 @@ +import type { DashboardVariablePlugin } from '../pluginTypes'; + +const builtinPlugin: DashboardVariablePlugin = { + capabilities: () => ({ multi: false, all: false }), + transformQuery: (query) => query, +}; +const extensionPlugin: DashboardVariablePlugin = { + capabilities: () => ({ multi: true, all: true, allValuePlaceholder: '.*' }), + transformQuery: (query) => ({ ...query, transformed: true }), +}; + +function loadRegistry(builtin: Record, extension?: Record) { + jest.resetModules(); + jest.doMock('../builtinPlugins', () => ({ dashboardVariablePlugins: builtin })); + jest.doMock('plus:/parcels/Dashboard/variablePlugins', () => + extension === undefined ? jest.requireActual('../../../../../plugins/PlusPlaceholder') : { dashboardVariablePlugins: extension }, + ); + return jest.requireActual('../plugins') as typeof import('../plugins'); +} + +afterEach(() => { + jest.resetModules(); + jest.dontMock('../builtinPlugins'); + jest.dontMock('plus:/parcels/Dashboard/variablePlugins'); +}); + +test('the real open-source placeholder explicitly exports an empty extension registry', () => { + const placeholder = jest.requireActual('../../../../../plugins/PlusPlaceholder'); + expect(placeholder.dashboardVariablePlugins).toEqual({}); + expect(typeof placeholder.dashboardVariablePlugins).toBe('object'); +}); + +test('open-source builds retain registered public capabilities without Plus implementations', () => { + const { getDashboardVariablePlugin } = loadRegistry({ prometheus: builtinPlugin }); + expect(getDashboardVariablePlugin('prometheus')).toBe(builtinPlugin); + expect(getDashboardVariablePlugin('prometheus')?.capabilities()).toEqual({ multi: false, all: false }); + expect(getDashboardVariablePlugin('gcm')).toBeUndefined(); +}); + +test('Plus extensions coexist with public plugins and override same-name registrations', () => { + const { getDashboardVariablePlugin } = loadRegistry({ prometheus: builtinPlugin, shared: builtinPlugin }, { gcm: extensionPlugin, shared: extensionPlugin }); + expect(getDashboardVariablePlugin('prometheus')).toBe(builtinPlugin); + expect(getDashboardVariablePlugin('gcm')).toBe(extensionPlugin); + expect(getDashboardVariablePlugin('shared')).toBe(extensionPlugin); +}); + +test.each([undefined, '', 'cw', 'unknown', 'constructor', '__proto__'])('unregistered category %s keeps the legacy fallback', (cate) => { + const { getDashboardVariablePlugin } = loadRegistry({}); + expect(getDashboardVariablePlugin(cate)).toBeUndefined(); +}); diff --git a/src/pages/dashboard/Variables/__tests__/variableLifecycle.jsdom.test.tsx b/src/pages/dashboard/Variables/__tests__/variableLifecycle.jsdom.test.tsx new file mode 100644 index 0000000000..cbead35c61 --- /dev/null +++ b/src/pages/dashboard/Variables/__tests__/variableLifecycle.jsdom.test.tsx @@ -0,0 +1,209 @@ +/** @jest-environment jsdom */ + +import React from 'react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ConfigProvider } from 'antd'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; + +jest.mock('@/App', () => { + const React = jest.requireActual('react'); + const datasourceList = [ + { id: 1, name: 'ds-a', plugin_type: 'gcm' }, + { id: 2, name: 'ds-b', plugin_type: 'gcm' }, + ]; + return { + CommonStateContext: React.createContext({ + datasourceList, + groupedDatasourceList: { gcm: datasourceList }, + datasourceCateOptions: [], + darkMode: false, + }), + }; +}); +jest.mock('@/utils/constant', () => ({ + DatasourceCateEnum: { prometheus: 'prometheus', elasticsearch: 'elasticsearch', mysql: 'mysql', gcm: 'gcm' }, + IS_PLUS: true, +})); +jest.mock('@/components/TimeRangePicker', () => ({ + parseRange: () => ({ start: new Date('2026-01-01'), end: new Date('2026-01-02') }), +})); +jest.mock('@/pages/dashboard/utils', () => ({ getDefaultStepByTime: () => 60 })); +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en_US' } }), + Trans: ({ i18nKey }: { i18nKey: string }) => {i18nKey}, +})); +// 只隔离外部查询与本用例无关的变量类型;保留真实的 Query / Datasource 组件与依赖管理。 +jest.mock('../datasource', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('../Variable/Constant', () => () => null); +jest.mock('../Variable/Custom', () => () => null); +jest.mock('../Variable/DatasourceIdentifier', () => () => null); +jest.mock('../Variable/HostIdent', () => () => null); +jest.mock('../Variable/Textbox', () => () => null); + +import Main from '../Main'; +import datasource from '../datasource'; +import { getGlobalState, setGlobalState } from '../../globalState'; +import type { IVariable } from '../types'; + +const queryMock = datasource as jest.MockedFunction; +const projects = [ + { label: 'Production', value: 'project-1' }, + { label: 'Development', value: 'project-2' }, +]; + +const dbVariable = (partial: Partial = {}): IVariable => ({ + name: 'db', + type: 'datasource', + definition: 'gcm', + datasource: { cate: 'gcm' }, + ...partial, +}); + +const queryVariable = (name: string, partial: Partial = {}): IVariable => ({ + name, + type: 'query', + definition: '', + datasource: { cate: 'gcm', value: 1 }, + ...partial, +}); + +function mountRuntime(variables: IVariable[]) { + act(() => setGlobalState('variablesWithOptions', variables)); + const history = createMemoryHistory({ initialEntries: ['/dashboard'] }); + return render( + + +
+ + , + ); +} + +/** 模拟 EditModal 保存:整体替换 variablesWithOptions,组件不重新挂载。 */ +async function saveVariables(next: (prev: IVariable[]) => IVariable[]) { + await act(async () => { + setGlobalState('variablesWithOptions', next(getGlobalState('variablesWithOptions'))); + }); +} + +async function selectOption(label: string, comboboxIndex = 0) { + fireEvent.mouseDown(screen.getAllByRole('combobox')[comboboxIndex]); + // rc-select 14 的 role=option 是只读辅助节点;点击真实选项内容触发选择。 + const option = await screen.findByText(label, { selector: '.ant-select-item-option-content' }); + await act(async () => { + fireEvent.click(option); + }); +} + +function state(name: string) { + return getGlobalState('variablesWithOptions').find((item) => item.name === name)!; +} + +beforeEach(() => { + queryMock.mockReset(); + localStorage.clear(); + setGlobalState('variablesWithOptions', []); + setGlobalState('dashboardMeta', { ...getGlobalState('dashboardMeta'), dashboardId: '42' }); + setGlobalState('range', { start: 'now-1h', end: 'now' }); +}); +afterEach(async () => { + await act(async () => { + await Promise.resolve(); + }); + cleanup(); +}); + +test('新增的 query 变量把数据源指向 $db 时,保存后立即查询可选项', async () => { + queryMock.mockResolvedValue(projects); + mountRuntime([dbVariable()]); + await waitFor(() => expect(state('db').value).toBe(1)); + + await saveVariables((prev) => [...prev, queryVariable('project', { datasource: { cate: 'gcm', value: '${db}' } })]); + + await waitFor(() => expect(state('project').options).toEqual(projects)); + expect(queryMock).toHaveBeenLastCalledWith(expect.objectContaining({ datasourceValue: 1 })); + expect(state('project').value).toBe('project-1'); +}); + +test('新增的无依赖 query 变量,保存后同样立即查询可选项', async () => { + queryMock.mockResolvedValue(projects); + mountRuntime([dbVariable()]); + await waitFor(() => expect(state('db').value).toBe(1)); + + await saveVariables((prev) => [...prev, queryVariable('project')]); + + await waitFor(() => expect(state('project').options).toEqual(projects)); +}); + +test('新增变量不会破坏已有变量对 $db 的依赖联动', async () => { + queryMock.mockImplementation(async ({ datasourceValue }) => [{ label: `metric-of-${datasourceValue}`, value: `metric-${datasourceValue}` }]); + mountRuntime([dbVariable(), queryVariable('metric', { datasource: { cate: 'gcm', value: '${db}' } })]); + await waitFor(() => expect(state('metric').value).toBe('metric-1')); + + await saveVariables((prev) => [...prev, queryVariable('project')]); + await waitFor(() => expect(state('project').options).toBeDefined()); + + await selectOption('ds-b'); + + await waitFor(() => expect(state('metric').value).toBe('metric-2')); +}); + +test('编辑已有 query 变量的配置后,其下游变量随之刷新', async () => { + queryMock.mockImplementation(async ({ query }) => { + if (query.project_id) return [{ label: `metric of ${query.project_id}`, value: `metric/${query.project_id}` }]; + return query.query === 'v2' ? [{ label: 'P2', value: 'project-2' }] : [{ label: 'P1', value: 'project-1' }]; + }); + mountRuntime([queryVariable('project', { definition: 'v1' }), queryVariable('metric', { query: { project_id: '${project}' } })]); + await waitFor(() => expect(state('metric').value).toBe('metric/project-1')); + + await saveVariables((prev) => prev.map((item) => (item.name === 'project' ? { ...item, definition: 'v2' } : item))); + + await waitFor(() => expect(state('project').value).toBe('project-2')); + await waitFor(() => expect(state('metric').value).toBe('metric/project-2')); +}); + +test('嵌套 query filters 中的变量引用会随上游选择更新并重新查询', async () => { + queryMock.mockImplementation(async ({ query }) => { + const filterValue = query.filters?.[0]?.value; + return filterValue ? [{ label: `zone of ${filterValue}`, value: `zone/${filterValue}` }] : projects; + }); + mountRuntime([queryVariable('project'), queryVariable('zone', { query: { filters: [{ key: 'project_id', value: '${project}' }] } })]); + await waitFor(() => expect(state('zone').value).toBe('zone/project-1')); + + await selectOption('Development'); + + await waitFor(() => expect(state('zone').value).toBe('zone/project-2')); + expect(queryMock).toHaveBeenLastCalledWith(expect.objectContaining({ query: expect.objectContaining({ filters: [{ key: 'project_id', value: 'project-2' }] }) })); +}); + +// Detail 首次渲染时 globalState.range 仍是默认值,随后才被同步为仪表盘真实的时间范围, +// 此时变量尚未就绪。初始化执行本身就会使用最新的时间范围,不应再额外刷新一轮。 +test('时间范围在变量就绪前变化时,每个变量只查询一次', async () => { + queryMock.mockResolvedValue(projects); + mountRuntime([]); + + await act(async () => { + setGlobalState('range', { start: 'now-3h', end: 'now' }); + }); + await saveVariables(() => [dbVariable(), queryVariable('device', { datasource: { cate: 'gcm', value: '${db}' } })]); + + await waitFor(() => expect(state('device').options).toEqual(projects)); + expect(queryMock).toHaveBeenCalledTimes(1); +}); + +test('删除变量后其执行器不再被调度', async () => { + queryMock.mockResolvedValue(projects); + mountRuntime([queryVariable('project'), queryVariable('metric', { query: { project_id: '${project}' } })]); + await waitFor(() => expect(state('metric').options).toEqual(projects)); + + await saveVariables((prev) => prev.filter((item) => item.name !== 'metric')); + await waitFor(() => expect(getGlobalState('variablesWithOptions')).toHaveLength(1)); + + queryMock.mockClear(); + await act(async () => { + setGlobalState('range', { start: 'now-2h', end: 'now' }); + }); + + await waitFor(() => expect(queryMock).toHaveBeenCalledTimes(1)); +}); diff --git a/src/pages/dashboard/Variables/builtinPlugins.ts b/src/pages/dashboard/Variables/builtinPlugins.ts new file mode 100644 index 0000000000..df698781a4 --- /dev/null +++ b/src/pages/dashboard/Variables/builtinPlugins.ts @@ -0,0 +1,4 @@ +import type { DashboardVariablePlugin } from './pluginTypes'; + +/** 开源数据源的变量能力在此注册;未注册的数据源继续使用原有流程。 */ +export const dashboardVariablePlugins: Record = {}; diff --git a/src/pages/dashboard/Variables/datasource.ts b/src/pages/dashboard/Variables/datasource.ts index 393e287598..17ebfcf061 100644 --- a/src/pages/dashboard/Variables/datasource.ts +++ b/src/pages/dashboard/Variables/datasource.ts @@ -20,6 +20,10 @@ export interface Props { datasourceValue: number; datasourceList: import('../types').DashboardDatasource[]; query: QueryType; + variableContext?: { + variables: import('./types').IVariable[]; + query: VariableDatasourceQuery; + }; } export default async function datasource(props: Props): Promise { diff --git a/src/pages/dashboard/Variables/pluginTypes.ts b/src/pages/dashboard/Variables/pluginTypes.ts new file mode 100644 index 0000000000..2c745767c0 --- /dev/null +++ b/src/pages/dashboard/Variables/pluginTypes.ts @@ -0,0 +1,25 @@ +import type { IRawTimeRange } from '@/components/TimeRangePicker/types'; +import type { JsonObject, ScopedVariables } from '../types'; +import type { IVariable } from './types'; + +export interface VariableCapabilities { + multi: boolean; + all: boolean; + allValuePlaceholder?: string; +} + +export interface QueryVariableContext { + variables: IVariable[]; + range?: IRawTimeRange; + step?: number; + scopedVars?: ScopedVariables; +} + +export interface DashboardVariablePlugin { + capabilities: (query?: JsonObject) => VariableCapabilities; + /** + * undefined 表示依赖尚未就绪;返回的查询不再进行通用插值。 + * 用户可修正的查询校验错误应抛出,以便调用方显示错误而不是静默跳过 target。 + */ + transformQuery: (query: JsonObject, context: QueryVariableContext) => JsonObject | undefined; +} diff --git a/src/pages/dashboard/Variables/plugins.ts b/src/pages/dashboard/Variables/plugins.ts new file mode 100644 index 0000000000..55df63dd04 --- /dev/null +++ b/src/pages/dashboard/Variables/plugins.ts @@ -0,0 +1,11 @@ +import type { DashboardVariablePlugin } from './pluginTypes'; +import { dashboardVariablePlugins as builtinPlugins } from './builtinPlugins'; +// @ts-ignore 开源版由 plusResolve 提供空扩展注册表。 +import { dashboardVariablePlugins as extensionPlugins } from 'plus:/parcels/Dashboard/variablePlugins'; + +// Plus 可以扩展公共注册表;同名注册以 Plus 实现为准。 +const plugins: Record = { ...builtinPlugins, ...extensionPlugins }; + +export function getDashboardVariablePlugin(cate?: string): DashboardVariablePlugin | undefined { + return cate && Object.prototype.hasOwnProperty.call(plugins, cate) ? plugins[cate] : undefined; +} diff --git a/src/test/mocks/plusStub.tsx b/src/test/mocks/plusStub.tsx index 5b786aeec9..b80cffd025 100644 --- a/src/test/mocks/plusStub.tsx +++ b/src/test/mocks/plusStub.tsx @@ -9,3 +9,5 @@ import React from 'react'; export default function PlusModuleStub() { return
; } + +export const dashboardVariablePlugins = {}; From 8eb742f665906fe3286db51b63e7164801d3bd1e Mon Sep 17 00:00:00 2001 From: jsers Date: Mon, 7 Sep 2026 23:55:45 +0800 Subject: [PATCH 03/12] chore(skills): add cmt skill for git review and commit workflow --- .codex/skills/cmt/SKILL.md | 228 +++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 .codex/skills/cmt/SKILL.md diff --git a/.codex/skills/cmt/SKILL.md b/.codex/skills/cmt/SKILL.md new file mode 100644 index 0000000000..733937103c --- /dev/null +++ b/.codex/skills/cmt/SKILL.md @@ -0,0 +1,228 @@ +--- +name: cmt +description: When user uses /cmt slash command, commit current git changes using fast mode by default, or strict mode when requested. Fast mode reviews, shows summary, and waits for approval. Strict mode adds safe simplifications, full /cr review, and verification before approval. +--- + +# /cmt — Git Review & Commit + +> **Override:** This skill replaces the default system commit workflow when `/cmt` is invoked. Follow this skill exactly. + +When the user invokes `/cmt [mode]`, prepare a conventional commit for current git changes. + +| Command | Mode | Behavior | +| ------------- | -------- | ---------------------------------------------------------------------------------- | +| `/cmt` | `fast` | Default. Inspect changes, run `git diff --check`, show summary, wait for approval. | +| `/cmt fast` | `fast` | Same as default. | +| `/cmt strict` | `strict` | Simplify safely, run full review, verify, wait for approval. | + +Unknown modes: stop and list `fast` and `strict`. + +## Workflow + +### 1. Parse Mode And Check Status + +Default to `fast` when no mode is provided. + +```bash +git branch --show-current +git status --short --branch +``` + +Identify: + +- current branch +- staged files +- unstaged changes +- untracked files +- merge conflicts + +Stop if: + +- working tree is clean → report "Nothing to commit" +- merge conflicts are present → report conflicts +- only untracked files with unclear purpose → ask before staging + +Branch policy: + +- `main` or `master`: always require extra-clear confirmation before commit. +- Feature branches (`feat*`): proceed through the selected mode, but still require explicit user approval. +- Other branches: use judgment; ask briefly if unclear. + +If unrelated changes are mixed together, ask what to include before proceeding. + +### 2. Inspect Current Changes + +For staged changes: + +```bash +git diff --staged +``` + +For unstaged changes: + +```bash +git diff +``` + +Focus on: + +- correctness and regressions +- accidental debug code, secrets, generated noise, or unrelated churn +- whether the change should be split into separate commits + +If there are unstaged changes that belong to the same task, include them. If unrelated, ask before staging. + +### 3. Fast Mode + +Use this path for `/cmt` and `/cmt fast`. + +Run: + +```bash +git diff --check +``` + +Then show the user: + +- changed file summary (files, insertions, deletions) +- staged vs unstaged scope +- any whitespace or conflict-marker issues from `git diff --check` +- draft commit message +- a note that full review and verification were skipped (suggest `/cmt strict` for thorough checks) + +Stop and ask the user whether to: + +- approve and commit +- switch to strict mode +- cancel + +Do not commit until the user explicitly approves. + +### 4. Strict Mode + +Use this path for `/cmt strict`. + +#### 4.1 Simplify Safely + +If the diff contains safe, obvious simplifications, make them: + +- remove dead code, redundant branches, duplicate variables, or unnecessary wrappers +- simplify naming or small conditionals without changing behavior +- tighten types or test mocks when needed for passing checks + +Do **not** perform broad refactors or unrelated cleanup. Keep simplification scoped to the current diff. + +**If any code was changed in this step, you MUST:** + +1. Show the user exactly what was simplified (file, before → after) +2. Explain why each change is safe +3. Re-run `git diff` so the user sees the final state +4. Ask the user to approve the simplifications before continuing + +Do not proceed past this step until simplifications are approved or skipped. + +#### 4.2 Review + +Run a thorough review on the current uncommitted changes: + +- prefer project-local review rules when present (`.cursor/rules`, `AGENTS.md`, etc.) +- check type safety, error handling, duplicated logic, null safety, layout impact, and i18n +- classify findings as `blocker`, `warn`, or `info` + +Show the review result: + +- review summary +- blocker/warn/info findings with concrete suggested fixes +- simplifications already applied (if any) + +Then ask the user: + +- fix findings first +- continue committing as-is +- cancel + +Do not continue until the user explicitly chooses. + +If the user chooses to fix: make only requested fixes, re-review, and ask again. + +#### 4.3 Verify + +After user approval, run focused verification: + +```bash +npm test -- --runInBand +# or +npm run build +# or +go test ./... +``` + +Prefer the smallest useful command. If verification fails, stop and ask whether to fix or continue. Do not commit after failed verification unless the user explicitly approves. + +### 5. Generate Commit Message + +Use [Conventional Commits](https://www.conventionalcommits.org/) format: + +```text +(): + +[optional body] +``` + +Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`. + +Keep the first line under 72 characters. Add a body only when the change needs explanation. + +### 6. Commit + +Stage only the related files by name. Never use `git add -A` or `git add .`. + +Use HEREDOC to avoid shell escaping issues: + +```bash +git add ... +git commit -m "$(cat <<'EOF' +(): + +[optional body] +EOF +)" +``` + +Commit only after: + +- the selected mode has completed +- results were shown to the user +- the user explicitly approved + +After commit, report: + +- commit hash and message +- mode used +- checks performed +- skipped checks (if any) +- known risks the user accepted (if any) + +## Edge Cases + +| Situation | Action | +| ------------------------------- | --------------------------------------------------------------- | +| Missing mode | Use `fast` | +| Unknown mode | Stop and list `fast` and `strict` | +| Clean working tree | Report nothing to commit | +| Merge conflicts | Do not commit; report conflicts | +| Only unclear untracked files | Ask before staging | +| Mixed unrelated changes | Ask what to include | +| Strict review finds blockers | Recommend fixing first; commit only if user explicitly approves | +| Strict verification fails | Recommend fixing first; commit only if user explicitly approves | +| Fast mode on risky-looking diff | Show risk and suggest switching to `strict` | +| Simplification changes code | Show before/after, get approval before continuing | + +## Summary + +1. Parse mode: default `fast`, or `strict`. +2. Inspect branch and current changes. +3. In `fast`: lightweight checks → show summary → wait for approval. +4. In `strict`: simplify (show changes, get approval) → review → ask user → verify → wait for approval. +5. Generate conventional commit message. +6. Commit only after explicit user approval. From 2d8533667d0740f3a4047764a6d8605e8216859d Mon Sep 17 00:00:00 2001 From: jsers Date: Tue, 8 Sep 2026 11:23:29 +0800 Subject: [PATCH 04/12] feat(dashboard): support searching datasource definitions and reuse identifier guard - Add showSearch/optionFilterProp to datasource definition selects across variable editors - Extract hasDatasourceIdentifier type guard to narrow identifier to a non-empty string - Filter identifier options by the guard, dropping the name/empty-string fallback - Mark variable datasources with isVariable for downstream hooks --- src/pages/dashboard/VariableConfig/EditItem.tsx | 4 ++-- .../Variables/EditModal/Variable/Datasource.tsx | 2 +- .../EditModal/Variable/DatasourceIdentifier.tsx | 11 ++++++----- .../Variables/EditModal/Variable/Query.tsx | 2 ++ .../Variables/Variable/DatasourceIdentifier.tsx | 7 ++++--- .../utils/__tests__/datasourceIdentifier.test.ts | 14 ++++++++++++++ .../Variables/utils/datasourceIdentifier.ts | 3 +++ 7 files changed, 32 insertions(+), 11 deletions(-) create mode 100644 src/pages/dashboard/Variables/utils/__tests__/datasourceIdentifier.test.ts create mode 100644 src/pages/dashboard/Variables/utils/datasourceIdentifier.ts diff --git a/src/pages/dashboard/VariableConfig/EditItem.tsx b/src/pages/dashboard/VariableConfig/EditItem.tsx index fed925fc96..db74192bfe 100644 --- a/src/pages/dashboard/VariableConfig/EditItem.tsx +++ b/src/pages/dashboard/VariableConfig/EditItem.tsx @@ -242,7 +242,7 @@ function EditItem(props: IProps) { {varType === 'datasource' && ( <> - {_.map(datasourceCateOptions, (item) => ( {item.label} @@ -295,7 +295,7 @@ function EditItem(props: IProps) { {varType === 'datasourceIdentifier' && ( <> - {_.map(datasourceCateOptions, (item) => ( {item.label} diff --git a/src/pages/dashboard/Variables/EditModal/Variable/Datasource.tsx b/src/pages/dashboard/Variables/EditModal/Variable/Datasource.tsx index 55030ab3ab..65fc0c5fc0 100644 --- a/src/pages/dashboard/Variables/EditModal/Variable/Datasource.tsx +++ b/src/pages/dashboard/Variables/EditModal/Variable/Datasource.tsx @@ -44,7 +44,7 @@ export default function Datasource(props: Props) { return ( <> - {_.map(datasourceCateOptions, (item) => ( {item.label} diff --git a/src/pages/dashboard/Variables/EditModal/Variable/DatasourceIdentifier.tsx b/src/pages/dashboard/Variables/EditModal/Variable/DatasourceIdentifier.tsx index 863d3c6439..6d8b3327a9 100644 --- a/src/pages/dashboard/Variables/EditModal/Variable/DatasourceIdentifier.tsx +++ b/src/pages/dashboard/Variables/EditModal/Variable/DatasourceIdentifier.tsx @@ -7,6 +7,7 @@ import _ from 'lodash'; import { CommonStateContext } from '@/App'; import stringToRegex from '../../utils/stringToRegex'; +import { hasDatasourceIdentifier } from '../../utils/datasourceIdentifier'; import Preview from '../Preview'; interface Props { @@ -29,12 +30,12 @@ export default function DatasourceIdentifier(props: Props) { >([]); useEffect(() => { - let datasourceList = typeof definition === 'string' ? (groupedDatasourceList[definition] ?? []).filter((item) => item.identifier !== undefined) : []; + let datasourceList = typeof definition === 'string' ? (groupedDatasourceList[definition] ?? []).filter(hasDatasourceIdentifier) : []; if (regex) { - datasourceList = datasourceList.filter((option) => option.identifier !== undefined && regex.test(option.identifier)); + datasourceList = datasourceList.filter((option) => regex.test(option.identifier)); } const itemOptions = _.map(datasourceList, (ds) => { - return { label: ds.identifier ?? ds.name, value: ds.identifier ?? '' }; + return { label: ds.identifier, value: ds.identifier }; }); setOptions(itemOptions); }, [definition, regex, groupedDatasourceList]); @@ -42,7 +43,7 @@ export default function DatasourceIdentifier(props: Props) { return ( <> - {_.map(datasourceCateOptions, (item) => ( {item.label} @@ -68,7 +69,7 @@ export default function DatasourceIdentifier(props: Props) {