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. 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/Editor/config.tsx b/src/pages/dashboard/Editor/config.tsx index ae4067dfa1..77467e6f80 100644 --- a/src/pages/dashboard/Editor/config.tsx +++ b/src/pages/dashboard/Editor/config.tsx @@ -219,6 +219,7 @@ export const defaultCustomValuesMap = { valueField: 'Value', scheme: 'Blues', }, + iframe: {}, barchart: { calc: 'lastNotNull', valueField: 'Value', @@ -234,6 +235,9 @@ export const defaultOptionsValuesMap = { hexbin: defaultOptionsValues, barGauge: defaultOptionsValues, text: defaultOptionsValues, + heatmap: defaultOptionsValues, + iframe: defaultOptionsValues, + barchart: defaultOptionsValues, gauge: { ...defaultOptionsValues, thresholds: { diff --git a/src/pages/dashboard/README.md b/src/pages/dashboard/README.md index 935c029c08..0037a79379 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/ # 仪表盘专属测试数据 @@ -55,6 +56,12 @@ src/pages/dashboard/ `dashboard.version` 是唯一的持久化 schema 版本。仪表盘及其全部面板结构迁移均由该版本驱动,面板不单独保存版本字段。 +## 运行时状态与多实例限制 + +`globalState.ts` 基于 `react-hooks-global-state` 创建模块级单例。它保存的并不只是本次变量联动的 `variableExecution`,还包括 `dashboardMeta`、`variablesWithOptions`、`range`、`series`、表格字段和图例编辑状态等。因此,同一个 React 树内并存多个仪表盘运行时实例目前**不受支持**:后挂载实例会覆盖前一个实例的变量、时间范围、元数据和编辑辅助状态,面板查询也可能使用错误的变量值。 + +当前路由使用方式是一页只挂载一个仪表盘实例。变量执行状态通过会话 ID 防止已卸载实例的异步链回调覆盖新页面的暂停/恢复状态,但这不是多实例隔离方案。若产品需要在同一 React 树内并列展示多个仪表盘,应将上述全局状态迁移为以仪表盘实例为边界的 `DashboardRuntimeContext`,并让变量、面板渲染与编辑器共享该 Context。 + ## 测试 - **栈**:Jest + ts-jest;组件/集成用例用 `/** @jest-environment jsdom */` + @testing-library/react。 diff --git a/src/pages/dashboard/Renderer/Renderer/Main.test.tsx b/src/pages/dashboard/Renderer/Renderer/Main.test.tsx new file mode 100644 index 0000000000..521565e7ec --- /dev/null +++ b/src/pages/dashboard/Renderer/Renderer/Main.test.tsx @@ -0,0 +1,77 @@ +/** @jest-environment jsdom */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import moment from 'moment'; + +import Main from './Main'; +import type { DashboardQueryState } from '../datasource/types'; +import type { IPanel } from '../../types'; + +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (value: string) => value }) })); +jest.mock('@/components/TimeRangePicker', () => ({ describeTimeRange: () => '' })); +jest.mock('@/pages/dashboard/Variables/utils/replaceTemplateVariables', () => ({ __esModule: true, default: (value: string) => value })); +jest.mock('../../Editor/Components/Markdown', () => ({ __esModule: true, default: ({ content }: { content: string }) =>
{content}
})); +jest.mock('../Inspect', () => () => null); +jest.mock('../components/CloneIcon', () => () => null); +jest.mock('../components/PanelEmpty', () => () =>
empty
); +jest.mock('./TimeSeriesNG', () => () => null); +jest.mock('./Stat', () => () => null); +jest.mock('./Table', () => () => null); +jest.mock('./TableNG', () => () => null); +jest.mock('./Pie', () => () => null); +jest.mock('./Hexbin', () => () => null); +jest.mock('./BarGauge', () => () => null); +jest.mock('./Gauge', () => () => null); +jest.mock('./Iframe', () => () =>
); +jest.mock('./Heatmap', () => () => null); +jest.mock('./BarChart', () => () => null); + +const time = { + start: moment('2026-09-08T00:00:00.000Z'), + end: moment('2026-09-08T01:00:00.000Z'), +}; + +const queryResult: DashboardQueryState = { + query: [], + series: [], + errorsByRef: {}, + error: '', + loading: false, + loaded: false, + range: time, + revision: 0, +}; + +test('text panel renders its body before the query state is loaded', () => { + render( +
, + ); + + expect(screen.getByText('$project')).toBeInTheDocument(); + expect(document.querySelector('.renderer-body')).toBeInTheDocument(); +}); diff --git a/src/pages/dashboard/Renderer/Renderer/Main.tsx b/src/pages/dashboard/Renderer/Renderer/Main.tsx index fa58d1cfff..a1d88aba30 100644 --- a/src/pages/dashboard/Renderer/Renderer/Main.tsx +++ b/src/pages/dashboard/Renderer/Renderer/Main.tsx @@ -423,7 +423,7 @@ function index(
)} - {loaded && ( + {(loaded || values.type === 'text' || values.type === 'iframe') && (
{_.isEmpty(series) && values.type !== 'text' && values.type !== 'iframe' ? ( 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/Renderer/datasource/useQuery.test.tsx b/src/pages/dashboard/Renderer/datasource/useQuery.test.tsx index 5a8d702fe5..8fa5cd7c43 100644 --- a/src/pages/dashboard/Renderer/datasource/useQuery.test.tsx +++ b/src/pages/dashboard/Renderer/datasource/useQuery.test.tsx @@ -6,6 +6,7 @@ import moment from 'moment'; import { createCommonStateWrapper } from '@/test/renderWithProviders'; import { resetDashboardGlobalState } from '@/test/resetGlobalState'; import { createMockQueryResponse, createMockTarget, createMockTimeSeriesResult } from '@/pages/dashboard/test/fixtures/dashboardQuery'; +import { setGlobalState } from '@/pages/dashboard/globalState'; import type { DashboardQueryResponse } from './types'; import useQuery from './useQuery'; @@ -133,6 +134,98 @@ describe('dashboard useQuery', () => { } }); + it('waits for a variable execution chain to settle before requesting once', async () => { + jest.useFakeTimers(); + try { + fetchDashboardQueryMock.mockResolvedValue(createMockQueryResponse()); + renderUseQuery({ targets: [createMockTarget()] }); + + await act(async () => { + jest.advanceTimersByTime(600); + await flushPromises(); + }); + expect(fetchDashboardQueryMock).toHaveBeenCalledTimes(1); + + await act(async () => { + setGlobalState('variableExecution', { sessionId: 0, isExecuting: true, revision: 0 }); + setGlobalState('variablesWithOptions', [{ name: 'metric', value: 'metric-a' }] as any); + }); + await act(async () => { + jest.advanceTimersByTime(600); + await flushPromises(); + }); + + await act(async () => { + setGlobalState('variablesWithOptions', [{ name: 'metric', value: 'metric-b' }] as any); + }); + await act(async () => { + jest.advanceTimersByTime(600); + await flushPromises(); + }); + expect(fetchDashboardQueryMock).toHaveBeenCalledTimes(1); + + await act(async () => { + setGlobalState('variableExecution', { sessionId: 0, isExecuting: false, revision: 1 }); + }); + await act(async () => { + jest.advanceTimersByTime(600); + await flushPromises(); + }); + expect(fetchDashboardQueryMock).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + it('aborts an in-flight request when a variable execution chain starts', async () => { + jest.useFakeTimers(); + try { + const resolvers: Array<(value: DashboardQueryResponse) => void> = []; + const signals: AbortSignal[] = []; + fetchDashboardQueryMock.mockImplementation((_request, signal: AbortSignal) => { + signals.push(signal); + return new Promise((resolve) => { + resolvers.push(resolve); + }); + }); + const { result } = renderUseQuery({ targets: [createMockTarget()] }); + + await act(async () => { + jest.advanceTimersByTime(600); + await flushPromises(); + }); + expect(signals).toHaveLength(1); + + await act(async () => { + setGlobalState('variableExecution', { sessionId: 0, isExecuting: true, revision: 0 }); + }); + expect(signals[0].aborted).toBe(true); + + await act(async () => { + resolvers[0](createMockQueryResponse([createMockTimeSeriesResult({ series: [{ labels: { job: 'stale' }, samples: [[1, 1]] }] })])); + await flushPromises(); + }); + expect(result.current.series).toEqual([]); + + await act(async () => { + setGlobalState('variableExecution', { sessionId: 0, isExecuting: false, revision: 1 }); + }); + await act(async () => { + jest.advanceTimersByTime(600); + await flushPromises(); + }); + expect(signals).toHaveLength(2); + + await act(async () => { + resolvers[1](createMockQueryResponse([createMockTimeSeriesResult({ series: [{ labels: { job: 'final' }, samples: [[1, 1]] }] })])); + await flushPromises(); + }); + expect(result.current.series[0].metric).toEqual({ job: 'final' }); + } finally { + jest.useRealTimers(); + } + }); + it('surfaces ref-level errors from the response', async () => { jest.useFakeTimers(); try { diff --git a/src/pages/dashboard/Renderer/datasource/useQuery.tsx b/src/pages/dashboard/Renderer/datasource/useQuery.tsx index e50cfd6cea..58bee864a2 100644 --- a/src/pages/dashboard/Renderer/datasource/useQuery.tsx +++ b/src/pages/dashboard/Renderer/datasource/useQuery.tsx @@ -19,6 +19,7 @@ import { useDebounceFn, useDeepCompareEffect } from 'ahooks'; import { CommonStateContext } from '@/App'; import { IRawTimeRange } from '@/components/TimeRangePicker'; import { useGlobalState } from '@/pages/dashboard/globalState'; +import { getErrorMessage } from '@/pages/dashboard/utils/json'; import { N9E_PATHNAME } from '@/utils/constant'; import type { ITarget } from '../../types'; @@ -45,20 +46,11 @@ interface IProps { queryOptionsTime?: IRawTimeRange; } -const getErrorMessage = (error: unknown) => { - if (error instanceof Error) return error.message; - if (error && typeof error === 'object') { - const value = error as { message?: unknown; name?: unknown }; - if (typeof value.message === 'string') return value.message; - if (typeof value.name === 'string') return value.name; - } - return String(error); -}; - export default function useQuery(props: IProps) { const { time, targets, inViewPort, datasourceCate, datasourceValue, maxDataPoints, queryOptionsTime } = props; const { datasourceList } = React.useContext(CommonStateContext); const [variablesWithOptions] = useGlobalState('variablesWithOptions'); + const [variableExecution] = useGlobalState('variableExecution'); const [state, setState] = useState({ query: [], series: [], @@ -186,6 +178,14 @@ export default function useQuery(props: IProps) { ); useDeepCompareEffect(() => { + if (variableExecution.isExecuting) { + hasRequestedRef.current = false; + requestSequenceRef.current.invalidate(); + cancelDebounce(); + controllerRef.current?.abort(); + return; + } + if (!targets?.length) { hasRequestedRef.current = false; loadedKeyRef.current = undefined; @@ -229,6 +229,7 @@ export default function useQuery(props: IProps) { targets, time, variablesWithOptions, + variableExecution, datasourceList, datasourceCate, datasourceValue, @@ -242,11 +243,11 @@ export default function useQuery(props: IProps) { ]); useEffect(() => { - if (inViewPort && !hasRequestedRef.current && loadedKeyRef.current !== getQueryKey()) { + if (!variableExecution.isExecuting && inViewPort && !hasRequestedRef.current && loadedKeyRef.current !== getQueryKey()) { hasRequestedRef.current = true; fetchData(); } - }, [inViewPort, fetchData]); + }, [inViewPort, variableExecution, fetchData]); useEffect( () => () => { diff --git a/src/pages/dashboard/Renderer/utils/adjustInitialValues.test.ts b/src/pages/dashboard/Renderer/utils/adjustInitialValues.test.ts new file mode 100644 index 0000000000..eb48f90dff --- /dev/null +++ b/src/pages/dashboard/Renderer/utils/adjustInitialValues.test.ts @@ -0,0 +1,17 @@ +import { visualizations } from '../../Editor/config'; +import adjustInitialValues from './adjustInitialValues'; + +jest.mock('../../utils/getDefaultTargets', () => ({ + __esModule: true, + default: () => [], +})); + +describe('adjustInitialValues', () => { + it.each(visualizations.map(({ type }) => [type]))('keeps the selected %s visualization type when adding a panel', (type) => { + const { initialValues } = adjustInitialValues(type, {}, [], []); + + expect(initialValues.type).toBe(type); + expect(initialValues.custom).toBeDefined(); + expect(initialValues.options).toBeDefined(); + }); +}); diff --git a/src/pages/dashboard/VARIABLES.md b/src/pages/dashboard/VARIABLES.md new file mode 100644 index 0000000000..94992cc1dd --- /dev/null +++ b/src/pages/dashboard/VARIABLES.md @@ -0,0 +1,268 @@ +# 仪表盘变量(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"] + Provider --> Execution["globalState.variableExecution\n会话、执行中状态、稳定版本"] + 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` 不再通知订阅者,避免同一条链被重复触发。 + +### 执行会话与卸载保护 + +面板查询位于 `VariableManagerProvider` 外,需要订阅 `globalState.variableExecution` 才能在变量链执行期间暂停。该状态包含: + +- `sessionId`:每个 Provider 挂载时领取的递增会话 ID; +- `isExecuting`:当前会话是否仍有变量执行链; +- `revision`:当前会话从执行中变为稳定时递增,供面板以最终变量值触发一次查询。 + +Provider 内部仍使用执行计数处理同一实例的重叠链:首条链开始时设为执行中,计数归零时才恢复稳定。Provider 卸载时,若自己仍是全局当前会话,会主动把状态复位为稳定;已经卸载的实例随后收到异步响应时,不会再写入全局执行状态。这样路由切换或配置更新留下的旧链,不能让新仪表盘长期暂停或提前恢复面板查询。 + +这不是多仪表盘实例隔离:`globalState.ts` 还共享变量、时间范围、仪表盘元数据和 series 等状态,同一 React 树内当前只支持一个仪表盘运行时实例。完整限制见同级 [README.md](./README.md) 的“运行时状态与多实例限制”。 + +### 时间范围变化 + +时间范围变化时,`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/VARIABLE_VALUE_FLOW.md b/src/pages/dashboard/VARIABLE_VALUE_FLOW.md new file mode 100644 index 0000000000..0340efc57f --- /dev/null +++ b/src/pages/dashboard/VARIABLE_VALUE_FLOW.md @@ -0,0 +1,86 @@ +# 仪表盘变量值执行流程 + +本文说明仪表盘变量在详情页加载、用户修改和重新访问时,URL、localStorage 与可选项之间的优先级和处理方式。 + +## 值的来源和优先级 + +变量首次加载时依次读取: + +1. URL 中与变量同名的参数; +2. localStorage 中的 `dashboard_v6_{dashboardId}_{variableName}`; +3. 变量查询或配置生成的 options。 + +URL 值优先于 localStorage。只有 URL 中没有该变量的值,且 URL 不带 `__variable_value_fixed` 时,才读取 localStorage。 + +`__variable_value_fixed` 表示当前链接固定变量值:不读取 localStorage,也不根据 options 自动补默认值。该开关用于分享、嵌入等需要严格使用链接参数的场景。 + +## 首次加载 + +```mermaid +flowchart TD + A[读取仪表盘变量定义] --> B[读取 URL query] + B --> C{URL 是否含变量值?} + C -- 是 --> D[使用 URL 值] + C -- 否 --> E{是否带 __variable_value_fixed?} + E -- 否 --> F[读取 localStorage] + E -- 是 --> G[变量值设为 undefined] + F --> H[归一化变量值] + D --> H + G --> H + H --> I[按依赖顺序获取或生成 options] + I --> J{变量值是否固定?} + J -- 是 --> K[保留当前值] + J -- 否 --> L{当前值有效?} + L -- 是 --> K + L -- 否 --> M[defaultValue] + M --> N{存在 defaultValue?} + N -- 是 --> K + N -- 否 --> O[单选取首个 option;多选取首项或 All] + O --> K +``` + +归一化规则:非文本框变量的 `null`、`undefined`、空字符串和空数组会视为未赋值;数据源变量会将数字字符串转为数字;多选变量会将单个字符串转为数组;单选变量会从数组中取第一项。文本框变量没有值时使用 `defaultValue`,否则为空字符串。 + +## options 返回后的校验和回填 + +非固定变量在 options 就绪后执行如下顺序: + +1. 当前值存在于 options 中,保留当前值; +2. 当前值未赋值或已不在 options 中,且配置了 `defaultValue`,使用该默认值; +3. 否则使用首个 option;多选变量开启 All 时使用 `['all']`; +4. 没有 options 时保持 `undefined`,文本框保持空字符串。 + +因此,普通 URL 值和 localStorage 恢复值都会参与 options 校验:若值已不在 options 中,将回退到默认值或首项。带 `__variable_value_fixed` 的 URL 值则不会回退。 + +## 用户修改后的写回 + +用户在变量控件中选择或输入值后: + +1. 更新 `variablesWithOptions` 中的运行时值; +2. 值不是 `undefined` 时写入 localStorage; +3. 将非空、非 `undefined`、非 `null` 的变量值写入 URL; +4. 通知依赖该变量的下游变量重新查询 options。 + +运行时 `undefined` 不会覆盖 localStorage 中已有的记录;单选控件明确清空时会写入空字符串,下一次加载时会按未赋值处理。 + +## 变量联动期间的面板查询 + +变量管理器执行用户选择、时间范围刷新或变量配置变更引起的依赖链时,会标记变量执行中。面板在执行期间会取消尚未发出的防抖查询、中止正在进行的旧请求,并等待所有下游变量的 options 和值稳定。 + +执行链结束后,变量稳定版本递增;每个可见面板再用最终变量值发起一次查询。这样不会减少变量获取 options 的必要请求,但避免了 `service → metric → label_key → label_values` 等中间状态分别触发面板 `query-batch` 请求。 + +### 执行会话与页面切换 + +执行状态保存为 `{ sessionId, isExecuting, revision }`。每个 `VariableManagerProvider` 挂载时领取新的 `sessionId`;只有当前会话可以把状态从执行中切换为稳定。Provider 卸载时会复位自己仍持有的执行状态,旧页面的异步变量请求即使随后返回,也不能覆盖新页面会话。 + +因此,页面切换期间不会因为旧变量链悬挂而让新页面的面板永久停止查询。不过变量、时间范围等仍是仪表盘模块级全局状态;同一 React 树内多个仪表盘实例并存尚不支持,详见同级 [README.md](./README.md)。 + +## 示例 + +| URL | localStorage | options | 最终值 | +| ------------------------------------------------- | ------------- | ------------------------ | ------------- | +| `project=project-2` | `project-1` | `project-1`, `project-2` | `project-2` | +| 无 `project` | `project-2` | `project-1`, `project-2` | `project-2` | +| 无 `project` | `project-old` | `project-1`, `project-2` | `project-1` | +| `project=project-old` | `project-1` | `project-1`, `project-2` | `project-1` | +| `__variable_value_fixed=true&project=project-old` | `project-1` | `project-1`, `project-2` | `project-old` | diff --git a/src/pages/dashboard/VariableConfig/EditItem.tsx b/src/pages/dashboard/VariableConfig/EditItem.tsx index 46e2763d86..db74192bfe 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 (
@@ -205,11 +209,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' }]} > @@ -235,7 +242,7 @@ function EditItem(props: IProps) { {varType === 'datasource' && ( <> - {_.map(datasourceCateOptions, (item) => ( {item.label} @@ -288,7 +295,7 @@ function EditItem(props: IProps) { {varType === 'datasourceIdentifier' && ( <> - {_.map(datasourceCateOptions, (item) => ( {item.label} @@ -361,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))) && ( @@ -372,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 ( @@ -389,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/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/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/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..87c78e88e6 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,15 @@ 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.lastIndex = 0; + return 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 +46,7 @@ export default function DatasourceIdentifier(props: Props) { return ( <> - {_.map(datasourceCateOptions, (item) => ( {item.label} @@ -68,8 +72,9 @@ export default function DatasourceIdentifier(props: Props) { + ) : null} diff --git a/src/pages/dashboard/Variables/Variable/DatasourceIdentifier.tsx b/src/pages/dashboard/Variables/Variable/DatasourceIdentifier.tsx index 310f571edd..ccffce8bc9 100644 --- a/src/pages/dashboard/Variables/Variable/DatasourceIdentifier.tsx +++ b/src/pages/dashboard/Variables/Variable/DatasourceIdentifier.tsx @@ -11,6 +11,7 @@ import stringToRegex from '../utils/stringToRegex'; import { buildVariableInterpolations } from '../utils/ajustData'; import { formatString } from '../utils/formatString'; import { useVariableManager } from '../VariableManagerContext'; +import { hasDatasourceIdentifier } from '../utils/datasourceIdentifier'; import { Props } from './types'; export default function DatasourceIdentifier(props: Props) { @@ -44,14 +45,17 @@ export default function DatasourceIdentifier(props: Props) { range: currentRange, }); - let currentDatasourceList = currentVariable.definition ? (groupedDatasourceList[currentVariable.definition] ?? []).filter((item) => item.identifier !== undefined) : []; + let currentDatasourceList = currentVariable.definition ? (groupedDatasourceList[currentVariable.definition] ?? []).filter(hasDatasourceIdentifier) : []; const formatedRegex = currentVariable.regex ? formatString(currentVariable.regex, variableInterpolations) : ''; const regex = stringToRegex(formatedRegex); if (regex) { - currentDatasourceList = currentDatasourceList.filter((option) => option.identifier !== undefined && regex.test(option.identifier)); + currentDatasourceList = currentDatasourceList.filter((option) => { + regex.lastIndex = 0; + return regex.test(option.identifier); + }); } const itemOptions = _.map(currentDatasourceList, (ds) => { - return { label: ds.identifier ?? ds.name, value: ds.identifier ?? '' }; + return { label: ds.identifier, value: ds.identifier }; }); updateVariable(name, { diff --git a/src/pages/dashboard/Variables/Variable/Query.tsx b/src/pages/dashboard/Variables/Variable/Query.tsx index 67af8909f0..015f401440 100644 --- a/src/pages/dashboard/Variables/Variable/Query.tsx +++ b/src/pages/dashboard/Variables/Variable/Query.tsx @@ -8,9 +8,10 @@ import InputGroupWithFormItem from '@/components/InputGroupWithFormItem'; import { useGlobalState } from '@/pages/dashboard/globalState'; import { buildVariableInterpolations } from '../utils/ajustData'; -import { useVariableManager } from '../VariableManagerContext'; +import { collectVariableDependencies, useVariableManager } from '../VariableManagerContext'; import { formatString, formatDatasource } from '../utils/formatString'; -import filterOptionsByReg from '../utils/filterOptionsByReg'; +import { getBuiltInVariables } from '../utils/replaceTemplateVariables'; +import processQueryOptions from '../utils/processQueryOptions'; import getValueByOptions from '../utils/getValueByOptions'; import datasource, { VariableDatasourceQuery } from '../datasource'; import { Props } from './types'; @@ -25,6 +26,7 @@ export default function Query(props: Props) { const [dropdownVisible, setDropdownVisible] = useState(false); const [searchValue, setSearchValue] = useState(''); const [errorMsg, setErrorMsg] = useState(''); + const [loading, setLoading] = useState(false); const { getVariables, updateVariable, registerVariable, registeredVariables } = useVariableManager(); const variableRef = useRef(variable); @@ -45,10 +47,25 @@ export default function Query(props: Props) { const currentVariable = variableRef.current; const currentRange = rangeRef.current; const requestId = ++requestIdRef.current; + const clearLoadingIfLatestRequest = () => { + if (requestId === requestIdRef.current) { + setLoading(false); + } + }; if (!currentVariable.datasource) { const errMsg = 'Variable ' + currentVariable.name + ' datasource not found'; setErrorMsg(errMsg); + clearLoadingIfLatestRequest(); + return Promise.reject(errMsg); + } + + const availableVariableNames = new Set([...getVariables().map((item) => item.name), ...getBuiltInVariables(currentRange).map((item) => item.name)]); + const missingDependencies = collectVariableDependencies(currentVariable).filter((dependencyName) => !availableVariableNames.has(dependencyName)); + if (missingDependencies.length > 0) { + const errMsg = `Variable ${currentVariable.name} references missing variable(s): ${missingDependencies.join(', ')}`; + setErrorMsg(errMsg); + clearLoadingIfLatestRequest(); return Promise.reject(errMsg); } @@ -69,19 +86,27 @@ export default function Query(props: Props) { if (!datasourceValue) { const errMsg = 'Variable ' + currentVariable.name + ' datasource not found'; setErrorMsg(errMsg); + clearLoadingIfLatestRequest(); return Promise.reject(errMsg); } setErrorMsg(''); + setLoading(true); 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,11 +118,12 @@ export default function Query(props: Props) { datasourceValue, datasourceList, query, + variableContext: { variables: getVariables(), query: { ...currentVariable.query, range: currentRange } }, }); 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({ @@ -115,6 +141,8 @@ export default function Query(props: Props) { options: [], // value: variableValueFixed ? value : undefined, // TODO 如果查询失败暂时不清除变量值 }); + } finally { + clearLoadingIfLatestRequest(); } }; @@ -243,6 +271,7 @@ export default function Query(props: Props) { } }} dropdownMatchSelectWidth={_.toNumber(options?.length) > 100} + loading={loading} value={value} dropdownClassName='overflow-586' maxTagPlaceholder={(omittedValues) => { diff --git a/src/pages/dashboard/Variables/VariableManagerContext.tsx b/src/pages/dashboard/Variables/VariableManagerContext.tsx index 67301a8481..f0ec5e4a9b 100644 --- a/src/pages/dashboard/Variables/VariableManagerContext.tsx +++ b/src/pages/dashboard/Variables/VariableManagerContext.tsx @@ -6,18 +6,21 @@ import { useGlobalState } from '@/pages/dashboard/globalState'; import { IVariable as Variable, VariableExecutionMeta, DependencyGraph } from './types'; +let nextVariableExecutionSessionId = 0; + export function extractDependencies(str: string, validVars?: Set): string[] { // 正则表达式匹配$变量名格式 // 匹配规则: // - 支持 $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 +45,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 | undefined, 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; @@ -130,6 +220,13 @@ export const VariableManagerProvider = ({ // 注册的变量执行元数据 const registeredVariables = useRef>(new Map()); const [range] = useGlobalState('range'); + const [, setVariableExecution] = useGlobalState('variableExecution'); + // 每次挂载都有独立会话;旧页面的异步链不能改写新页面的执行状态。 + const variableExecutionSessionId = useRef(0); + if (variableExecutionSessionId.current === 0) { + variableExecutionSessionId.current = ++nextVariableExecutionSessionId; + } + const isMounted = useRef(true); // 变量值的同步副本,用于在依赖链执行中获取最新值 const variablesRef = useRef(variables); // Ensure ref is synced with props during render to be available for children's effects @@ -138,23 +235,86 @@ 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); + + useEffect(() => { + const sessionId = variableExecutionSessionId.current; + setVariableExecution((previous) => ({ + sessionId, + isExecuting: false, + revision: previous.revision + 1, + })); + + return () => { + isMounted.current = false; + setVariableExecution((previous) => { + if (previous.sessionId !== sessionId) { + return previous; + } + return { + ...previous, + isExecuting: false, + revision: previous.revision + 1, + }; + }); + }; + }, [setVariableExecution]); + + const beginExecutionChain = useCallback(() => { + if (!isMounted.current) { + return; + } + executingChainCount.current += 1; + isExecutingChain.current = true; + if (executingChainCount.current === 1) { + setVariableExecution((previous) => { + if (previous.sessionId !== variableExecutionSessionId.current) { + return previous; + } + return { ...previous, isExecuting: true }; + }); + } + }, [setVariableExecution]); + + const endExecutionChain = useCallback(() => { + if (!isMounted.current) { + return; + } + const wasExecuting = executingChainCount.current > 0; + executingChainCount.current = Math.max(0, executingChainCount.current - 1); + isExecutingChain.current = executingChainCount.current > 0; + if (wasExecuting && !isExecutingChain.current) { + setVariableExecution((previous) => { + if (previous.sessionId !== variableExecutionSessionId.current) { + return previous; + } + return { + ...previous, + isExecuting: false, + revision: previous.revision + 1, + }; + }); + } + }, [setVariableExecution]); const getVariables = useCallback(() => { // 在依赖链执行期间,返回 ref 中的最新值 @@ -223,10 +383,7 @@ export const VariableManagerProvider = ({ // 标记为正在执行 executingDependencies.current.add(dependencyName); - const isRootExecution = !isExecutingChain.current; - if (isRootExecution) { - isExecutingChain.current = true; - } + beginExecutionChain(); try { // 对依赖变量进行拓扑排序,确保执行顺序正确 @@ -242,12 +399,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 +412,7 @@ export const VariableManagerProvider = ({ return; } - isExecutingChain.current = true; + beginExecutionChain(); try { for (const variableName of executionOrder) { const meta = registeredVariables.current.get(variableName); @@ -266,243 +421,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)); - - if (allRegistered && !initialized.current && pendingInitialExecution.current.size > 0) { - initialized.current = true; - - // 执行所有无依赖的变量 - 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); - } - } + }, [beginExecutionChain, endExecutionChain, variables]); - if (pendingRangeRefreshSignature.current && pendingRangeRefreshSignature.current !== previousRangeSignature.current) { - previousRangeSignature.current = pendingRangeRefreshSignature.current; - pendingRangeRefreshSignature.current = null; + // 按拓扑序依次执行指定变量,执行期间持有执行锁,避免链内的取值变化重复触发依赖更新 + // isStale 用于在变量配置又发生变化时提前中止本轮执行 + const executeVariables = useCallback( + async (names: string[], isStale?: () => boolean) => { + const executable = names.filter((name) => registeredVariables.current.has(name)); - 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; - } - - if (previousVariablesKey.current !== variablesKey) { - const currentVariableNames = new Set(variables.map((v) => v.name)); - const namesChanged = previousVariableNamesKey.current !== variableNamesKey; + const signatures = new Map(variableSignatures); + const previous = previousSignatures.current; - // 仅在变量列表发生变化时重置初始化状态 - 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 +556,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..2b09ce625e 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,186 @@ 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: [] }, + }); + }); + + test('should build the complete GCM dependency chain from datasource and query fields', () => { + const variables = [ + { name: 'db', type: 'datasource', definition: 'gcm', datasource: { cate: 'gcm' } }, + { name: 'project', type: 'query', definition: '', datasource: { cate: 'gcm', value: '${db}' }, query: { query_type: 'projects' } }, + { + name: 'service', + type: 'query', + definition: '', + datasource: { cate: 'gcm', value: '${db}' }, + query: { query_type: 'services', project_id: '${project}' }, + }, + { + name: 'metric', + type: 'query', + definition: '', + datasource: { cate: 'gcm', value: '${db}' }, + query: { query_type: 'metricTypes', project_id: '${project}', service: '${service}' }, + }, + { + name: 'label_key', + type: 'query', + definition: '', + datasource: { cate: 'gcm', value: '${db}' }, + query: { query_type: 'labelKeys', project_id: '${project}', service: '${service}', metric_type: '${metric}' }, + }, + { + name: 'label_values', + type: 'query', + definition: '', + datasource: { cate: 'gcm', value: '${db}' }, + query: { + query_type: 'labelValues', + project_id: '${project}', + service: '${service}', + metric_type: '${metric}', + label_key: '${label_key}', + }, + }, + ] as IVariable[]; + + expect(buildDependencyGraph(variables)).toEqual({ + graph: { + db: ['project', 'service', 'metric', 'label_key', 'label_values'], + project: ['service', 'metric', 'label_key', 'label_values'], + service: ['metric', 'label_key', 'label_values'], + metric: ['label_key', 'label_values'], + label_key: ['label_values'], + }, + dependenciesByName: { + db: [], + project: ['db'], + service: ['db', 'project'], + metric: ['db', 'project', 'service'], + label_key: ['db', 'project', 'service', 'metric'], + label_values: ['db', 'project', 'service', 'metric', 'label_key'], + }, + }); + }); +}); + 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__/queryOptions.jsdom.test.tsx b/src/pages/dashboard/Variables/__tests__/queryOptions.jsdom.test.tsx new file mode 100644 index 0000000000..2928ca27f2 --- /dev/null +++ b/src/pages/dashboard/Variables/__tests__/queryOptions.jsdom.test.tsx @@ -0,0 +1,407 @@ +/** @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('variableExecution', { sessionId: 0, isExecuting: false, revision: 0 }); + 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('query variable stays loading until its datasource request completes', async () => { + let resolveOptions!: (value: typeof projects) => void; + queryMock.mockImplementation( + () => + new Promise((resolve) => { + resolveOptions = resolve; + }), + ); + + mountRuntime([projectVariable()]); + + await waitFor(() => expect(document.querySelector('.ant-select-loading')).toBeInTheDocument()); + await act(async () => { + resolveOptions(projects); + }); + await waitFor(() => expect(state().options).toEqual(projects)); + expect(document.querySelector('.ant-select-loading')).not.toBeInTheDocument(); +}); + +test('a newer datasource validation failure clears loading left by an older request', async () => { + let resolveOldRequest!: (value: typeof projects) => void; + queryMock.mockImplementation( + () => + new Promise((resolve) => { + resolveOldRequest = resolve; + }), + ); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + + mountRuntime([projectVariable()]); + await waitFor(() => expect(document.querySelector('.ant-select-loading')).toBeInTheDocument()); + + act(() => setGlobalState('variablesWithOptions', [projectVariable({ datasource: undefined })])); + await waitFor(() => expect(document.querySelector('.ant-select-loading')).not.toBeInTheDocument()); + + await act(async () => { + resolveOldRequest(projects); + }); + errorSpy.mockRestore(); +}); + +test('query variable reports a removed dependency and skips its datasource request', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + mountRuntime([projectVariable({ name: 'metric', query: { service: '${service}' } })]); + + const warningIcon = await waitFor(() => { + const icon = document.querySelector('.anticon-warning'); + expect(icon).toBeInTheDocument(); + return icon!; + }); + fireEvent.mouseEnter(warningIcon); + + expect(await screen.findByText('Variable metric references missing variable(s): service')).toBeInTheDocument(); + expect(queryMock).not.toHaveBeenCalled(); + errorSpy.mockRestore(); +}); + +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('non-fixed stale cache value falls back to the first available option', async () => { + localStorage.setItem('dashboard_v6_42_project', 'project-removed'); + queryMock.mockResolvedValue(projects); + const initialized = initializeVariablesValue([projectVariable()], {}, { dashboardId: 42 }) as IVariable[]; + + mountRuntime(initialized); + + await waitFor(() => expect(state().options).toEqual(projects)); + expect(state().value).toBe('project-1'); + expect(screen.getByText('Production')).toBeInTheDocument(); +}); + +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/__tests__/variableLifecycle.jsdom.test.tsx b/src/pages/dashboard/Variables/__tests__/variableLifecycle.jsdom.test.tsx new file mode 100644 index 0000000000..3826f8b2f8 --- /dev/null +++ b/src/pages/dashboard/Variables/__tests__/variableLifecycle.jsdom.test.tsx @@ -0,0 +1,334 @@ +/** @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('variableExecution', { sessionId: 0, isExecuting: false, revision: 0 }); + 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)); +}); + +test('依赖链执行期间标记为进行中,并在所有下游变量稳定后只完成一次', async () => { + let resolveChangedService: ((value: { label: string; value: string }[]) => void) | undefined; + queryMock.mockImplementation(({ query }) => { + if (query.project_id === 'project-2' && !query.service) { + return new Promise((resolve) => { + resolveChangedService = resolve; + }); + } + if (query.service) { + return Promise.resolve([{ label: `metric of ${query.service}`, value: `metric-${query.service}` }]); + } + if (query.project_id) { + return Promise.resolve([{ label: `service of ${query.project_id}`, value: `service-${query.project_id}` }]); + } + return Promise.resolve(projects); + }); + mountRuntime([ + queryVariable('project'), + queryVariable('service', { query: { project_id: '${project}' } }), + queryVariable('metric', { query: { project_id: '${project}', service: '${service}' } }), + ]); + await waitFor(() => expect(state('metric').value).toBe('metric-service-project-1')); + const initialRevision = getGlobalState('variableExecution').revision; + + await selectOption('Development'); + + await waitFor(() => expect(resolveChangedService).toBeDefined()); + expect(getGlobalState('variableExecution').isExecuting).toBe(true); + + await act(async () => { + resolveChangedService!([{ label: 'service of project-2', value: 'service-project-2' }]); + }); + + await waitFor(() => expect(state('metric').value).toBe('metric-service-project-2')); + expect(getGlobalState('variableExecution')).toEqual({ + sessionId: expect.any(Number), + isExecuting: false, + revision: initialRevision + 1, + }); +}); + +test('变量链未完成时卸载会复位执行状态,后续无 query 变量的仪表盘不会继承暂停状态', async () => { + let resolveOptions: ((value: typeof projects) => void) | undefined; + queryMock.mockImplementation( + () => + new Promise((resolve) => { + resolveOptions = resolve; + }), + ); + const firstRuntime = mountRuntime([queryVariable('project')]); + + await waitFor(() => expect(getGlobalState('variableExecution').isExecuting).toBe(true)); + const firstSessionId = getGlobalState('variableExecution').sessionId; + firstRuntime.unmount(); + + expect(getGlobalState('variableExecution')).toMatchObject({ sessionId: firstSessionId, isExecuting: false }); + + mountRuntime([]); + await waitFor(() => expect(getGlobalState('variableExecution').isExecuting).toBe(false)); + expect(getGlobalState('variableExecution').sessionId).not.toBe(firstSessionId); + + await act(async () => { + resolveOptions!(projects); + }); + expect(getGlobalState('variableExecution').isExecuting).toBe(false); +}); + +test('旧 Provider 的迟到执行链不能覆盖新 Provider 的执行状态', async () => { + const resolvers: Array<(value: typeof projects) => void> = []; + queryMock.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + const firstRuntime = mountRuntime([queryVariable('first')]); + await waitFor(() => expect(resolvers).toHaveLength(1)); + firstRuntime.unmount(); + + mountRuntime([queryVariable('second')]); + await waitFor(() => expect(resolvers).toHaveLength(2)); + const secondSessionId = getGlobalState('variableExecution').sessionId; + expect(getGlobalState('variableExecution').isExecuting).toBe(true); + + await act(async () => { + resolvers[0](projects); + }); + + expect(getGlobalState('variableExecution')).toMatchObject({ sessionId: secondSessionId, isExecuting: true }); + + await act(async () => { + resolvers[1](projects); + }); + await waitFor(() => expect(getGlobalState('variableExecution')).toMatchObject({ sessionId: secondSessionId, isExecuting: false })); +}); + +test('同一 Provider 的重叠执行链在全部完成前保持暂停状态', async () => { + const resolvers: Array<(value: typeof projects) => void> = []; + queryMock.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + mountRuntime([queryVariable('project', { definition: 'v1' })]); + await waitFor(() => expect(resolvers).toHaveLength(1)); + const initialRevision = getGlobalState('variableExecution').revision; + + await saveVariables((prev) => prev.map((variable) => ({ ...variable, definition: 'v2' }))); + await waitFor(() => expect(resolvers).toHaveLength(2)); + expect(getGlobalState('variableExecution').isExecuting).toBe(true); + + await act(async () => { + resolvers[0](projects); + }); + expect(getGlobalState('variableExecution')).toMatchObject({ isExecuting: true, revision: initialRevision }); + + await act(async () => { + resolvers[1](projects); + }); + await waitFor(() => expect(state('project').value).toBe('project-1')); + expect(getGlobalState('variableExecution')).toMatchObject({ isExecuting: false, revision: initialRevision + 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 6963bc5879..17ebfcf061 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,19 +13,20 @@ 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; datasourceValue: number; datasourceList: import('../types').DashboardDatasource[]; query: QueryType; + variableContext?: { + variables: import('./types').IVariable[]; + query: VariableDatasourceQuery; + }; } -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/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/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__/datasourceIdentifier.test.ts b/src/pages/dashboard/Variables/utils/__tests__/datasourceIdentifier.test.ts new file mode 100644 index 0000000000..bdbde459e0 --- /dev/null +++ b/src/pages/dashboard/Variables/utils/__tests__/datasourceIdentifier.test.ts @@ -0,0 +1,14 @@ +import { hasDatasourceIdentifier } from '../datasourceIdentifier'; + +describe('hasDatasourceIdentifier', () => { + test.each([undefined, '', ' ', null])('rejects an absent or blank identifier: %p', (identifier) => { + expect(hasDatasourceIdentifier({ identifier })).toBe(false); + }); + + test('keeps a non-empty identifier without changing its value', () => { + const datasource = { identifier: 'prometheus-primary' }; + + expect(hasDatasourceIdentifier(datasource)).toBe(true); + if (hasDatasourceIdentifier(datasource)) expect(datasource.identifier).toBe('prometheus-primary'); + }); +}); diff --git a/src/pages/dashboard/Variables/utils/__tests__/initializeVariablesValue.test.ts b/src/pages/dashboard/Variables/utils/__tests__/initializeVariablesValue.test.ts index 880aadc705..3694061b87 100644 --- a/src/pages/dashboard/Variables/utils/__tests__/initializeVariablesValue.test.ts +++ b/src/pages/dashboard/Variables/utils/__tests__/initializeVariablesValue.test.ts @@ -30,6 +30,7 @@ import initializeVariablesValue from '../initializeVariablesValue'; import getValueByOptions from '../getValueByOptions'; import adjustData from '../ajustData'; import { formatString } from '../formatString'; +import type { IVariable } from '../../types'; const createLocalStorageMock = () => { let storage: Record = {}; @@ -106,7 +107,7 @@ describe('textbox variable empty value', () => { test('should keep empty string textbox defaultValue in fallback selection', () => { const value = getValueByOptions({ - variableValueFixed: undefined as any, + variableValueFixed: false, variable: { name: 'input', definition: '', @@ -123,7 +124,7 @@ describe('textbox variable empty value', () => { test('should preserve textbox value from URL during initialization execution', () => { const value = getValueByOptions({ - variableValueFixed: undefined as any, + variableValueFixed: false, variable: { name: 'input', definition: '', @@ -218,3 +219,56 @@ describe('textbox variable empty value', () => { expect(formatString('${db}', data)).toBe(''); }); }); + +describe('variable value initialization priority', () => { + beforeEach(() => { + localStorage.clear(); + }); + + const queryVariable = (partial: Partial = {}): IVariable => ({ + name: 'project', + definition: '', + type: 'query', + datasource: { cate: 'prometheus' }, + ...partial, + }); + + test('uses the URL value before the cached value', () => { + localStorage.setItem('dashboard_v6_42_project', 'from-cache'); + + const result = initializeVariablesValue([queryVariable()], { project: 'from-url' }, { dashboardId: 42 }); + + expect(result[0].value).toBe('from-url'); + }); + + test('restores a cached multi-value when the URL has no value', () => { + localStorage.setItem('dashboard_v6_42_project', '["project-1","project-2"]'); + + const result = initializeVariablesValue([queryVariable({ multi: true })], {}, { dashboardId: 42 }); + + expect(result[0].value).toEqual(['project-1', 'project-2']); + }); + + test('normalizes a cached datasource id to a number', () => { + localStorage.setItem('dashboard_v6_42_db', '2'); + const datasourceVariable = { + name: 'db', + definition: 'gcm', + type: 'datasource', + datasource: { cate: 'prometheus' }, + } as IVariable; + + const result = initializeVariablesValue([datasourceVariable], {}, { dashboardId: 42 }); + + expect(result[0].value).toBe(2); + }); + + test('does not read the cache when fixed URL values are enabled', () => { + localStorage.setItem('dashboard_v6_42_project', 'from-cache'); + localStorage.setItem('dashboard_v6_42_service', 'cached-service'); + + const result = initializeVariablesValue([queryVariable(), queryVariable({ name: 'service' })], { __variable_value_fixed: 'true', project: 'from-url' }, { dashboardId: 42 }); + + expect(result.map((variable) => variable.value)).toEqual(['from-url', undefined]); + }); +}); 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..b41ef24a42 --- /dev/null +++ b/src/pages/dashboard/Variables/utils/__tests__/processQueryOptions.test.ts @@ -0,0 +1,242 @@ +/// + +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: false, 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('an unfixed value falls back to the first option when it is absent', () => { + const itemOptions = processQueryOptions([ + { label: 'Production', value: 'project-1' }, + { label: 'Development', value: 'project-2' }, + ]); + const variable: IVariable = { name: 'project', definition: '', type: 'query', datasource: { cate: 'gcm' }, value: 'project-outside' }; + + expect(getValueByOptions({ variableValueFixed: false, variable, itemOptions })).toBe('project-1'); +}); + +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: false, 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/datasourceIdentifier.ts b/src/pages/dashboard/Variables/utils/datasourceIdentifier.ts new file mode 100644 index 0000000000..4a057efec1 --- /dev/null +++ b/src/pages/dashboard/Variables/utils/datasourceIdentifier.ts @@ -0,0 +1,3 @@ +export function hasDatasourceIdentifier(value: T): value is T & { identifier: string } { + return typeof value.identifier === 'string' && value.identifier.trim().length > 0; +} diff --git a/src/pages/dashboard/Variables/utils/getValueByOptions.ts b/src/pages/dashboard/Variables/utils/getValueByOptions.ts index c16295e96d..d9ba210668 100644 --- a/src/pages/dashboard/Variables/utils/getValueByOptions.ts +++ b/src/pages/dashboard/Variables/utils/getValueByOptions.ts @@ -20,7 +20,7 @@ export default function getValueByOptions({ // 设置变量为空时的默认值 // 同初始化 (initializeVariablesValue) 的区别是,这里从可选项或是变量设置的 defaultValue 等中选取 // 如果 __variable_value_fixed 存在,则表示变量值是固定的,不需要再设置默认值 - if (variableValueFixed === undefined) { + if (!variableValueFixed) { // 变量值为空,或者不在可选项中 时,设置默认值 const shouldValidateOptions = variable.type !== 'textbox'; if (value === undefined || (value && shouldValidateOptions && !includes(itemOptions, value))) { 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/globalState.ts b/src/pages/dashboard/globalState.ts index 29cee87161..d7c0544cd4 100644 --- a/src/pages/dashboard/globalState.ts +++ b/src/pages/dashboard/globalState.ts @@ -16,9 +16,16 @@ export interface DashboardMeta { public_cate: 0 | 1 | 2; // 0: 匿名访问,1: 需要登录, 2: 授权访问 } +export interface VariableExecutionState { + sessionId: number; + isExecuting: boolean; + revision: number; +} + export const { useGlobalState, getGlobalState, setGlobalState } = createGlobalState<{ dashboardMeta: DashboardMeta; // 仪表盘的一些配置信息 variablesWithOptions: IVariable[]; // 变量数据 + variableExecution: VariableExecutionState; // 变量依赖链执行状态 range: IRawTimeRange; // 仪表盘的时间范围 statFields: string[]; @@ -29,6 +36,11 @@ export const { useGlobalState, getGlobalState, setGlobalState } = createGlobalSt }>({ dashboardMeta: {} as DashboardMeta, variablesWithOptions: [], + variableExecution: { + sessionId: 0, + isExecuting: false, + revision: 0, + }, range: { start: 'now-1h', end: 'now', 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: '自定義全選值', diff --git a/src/pages/dashboard/utils/json.test.ts b/src/pages/dashboard/utils/json.test.ts index d65983d225..d30b35ae51 100644 --- a/src/pages/dashboard/utils/json.test.ts +++ b/src/pages/dashboard/utils/json.test.ts @@ -1,14 +1,32 @@ -import { isJsonObject, parseJson } from './json'; +import { getErrorMessage, isJsonObject, parseJson } from './json'; -describe('dashboard JSON helpers', () => { - it('distinguishes JSON objects from arrays and null', () => { - expect(isJsonObject({ nested: ['value'] })).toBe(true); +describe('JSON helpers', () => { + test('recognizes JSON objects but rejects arrays and primitive values', () => { + expect(isJsonObject({ key: ['value', 1, true, null] })).toBe(true); expect(isJsonObject([])).toBe(false); - expect(isJsonObject(null)).toBe(false); + expect(isJsonObject('value')).toBe(false); }); - it('returns undefined for invalid JSON', () => { - expect(parseJson('{"valid":true}')).toEqual({ valid: true }); + test('parses valid JSON and returns undefined for invalid input', () => { + expect(parseJson('{"key":"value"}')).toEqual({ key: 'value' }); expect(parseJson('{')).toBeUndefined(); }); }); + +describe('getErrorMessage', () => { + test('prefers proxy response data over an object-stringified Error message', () => { + const error = Object.assign(new Error('[object Object]'), { + data: { error: 'dial tcp 119.45.120.121:8481: i/o timeout' }, + }); + + expect(getErrorMessage(error)).toBe('dial tcp 119.45.120.121:8481: i/o timeout'); + }); + + test('reads response.data and joins array error details', () => { + expect(getErrorMessage({ response: { data: [{ message: 'first failure' }, { error: 'second failure' }] } })).toBe('first failure; second failure'); + }); + + test('keeps ordinary Error messages', () => { + expect(getErrorMessage(new Error('request failed'))).toBe('request failed'); + }); +}); diff --git a/src/pages/dashboard/utils/json.ts b/src/pages/dashboard/utils/json.ts index cb4dcec5fc..93615399e7 100644 --- a/src/pages/dashboard/utils/json.ts +++ b/src/pages/dashboard/utils/json.ts @@ -20,7 +20,36 @@ export function parseJson(value: string): JsonValue | undefined { } } +function extractErrorMessage(value: unknown, seen: WeakSet): string | undefined { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (value == null) return undefined; + if (Array.isArray(value)) { + const messages = value.map((item) => extractErrorMessage(item, seen)).filter((item): item is string => Boolean(item)); + return messages.length > 0 ? messages.join('; ') : undefined; + } + if (typeof value !== 'object') return undefined; + if (seen.has(value)) return undefined; + seen.add(value); + + const record = value as Record; + // umi-request 的 ResponseError 将后端响应放在 data 或 response.data;它们优先于可能已被对象字符串化的 message。 + const candidates = [ + record.data, + record.response && typeof record.response === 'object' ? (record.response as Record).data : undefined, + record.error, + record.err, + record.errors, + record.detail, + record.message, + ]; + for (const candidate of candidates) { + const message = extractErrorMessage(candidate, seen); + if (message) return message; + } + return undefined; +} + export function getErrorMessage(error: unknown): string { - if (error instanceof Error) return error.message; - return String(error); + return extractErrorMessage(error, new WeakSet()) ?? String(error); } 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 = {}; diff --git a/src/test/resetGlobalState.ts b/src/test/resetGlobalState.ts index 39ccb1c1a9..8cdfa38529 100644 --- a/src/test/resetGlobalState.ts +++ b/src/test/resetGlobalState.ts @@ -6,6 +6,7 @@ import { setGlobalState } from '@/pages/dashboard/globalState'; */ export function resetDashboardGlobalState() { setGlobalState('variablesWithOptions', []); + setGlobalState('variableExecution', { sessionId: 0, isExecuting: false, revision: 0 }); setGlobalState('range', { start: 'now-1h', end: 'now' }); setGlobalState('statFields', []); setGlobalState('tableFields', []);