diff --git a/.changeset/metric-formula-editor.md b/.changeset/metric-formula-editor.md new file mode 100644 index 0000000000..598a0fa7f3 --- /dev/null +++ b/.changeset/metric-formula-editor.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': minor +--- + +Add metric formula editing to the chart editor. Metric-source charts (time series, table, number) gain an "Add Formula" row: a letter-ref arithmetic expression over the chart's series (`A` = series 1, `B` = series 2, ...) such as `A / (A + B) * 100`, with inline structured validation (malformed expressions, unknown series references), per-formula alias and number format, and a "Show input series" toggle to render only the formula column(s) or the formula alongside its operand series. Series rows now carry their reference letter as a badge. Formulas and the "As Ratio" toggle are mutually exclusive, and formulas persist on dashboard tiles and standalone charts. diff --git a/packages/app/src/__tests__/SessionSidePanel.test.tsx b/packages/app/src/__tests__/SessionSidePanel.test.tsx index 078b19e2ac..5006b0c7bb 100644 --- a/packages/app/src/__tests__/SessionSidePanel.test.tsx +++ b/packages/app/src/__tests__/SessionSidePanel.test.tsx @@ -36,7 +36,7 @@ const mockNuqs: { jest.mock('nuqs', () => { const actual = jest.requireActual('nuqs'); - // eslint-disable-next-line @typescript-eslint/no-empty-function + const noop = () => {}; return { ...actual, diff --git a/packages/app/src/__tests__/source.test.ts b/packages/app/src/__tests__/source.test.ts index a9932e708c..bb4d4f7313 100644 --- a/packages/app/src/__tests__/source.test.ts +++ b/packages/app/src/__tests__/source.test.ts @@ -10,6 +10,7 @@ import { notifications } from '@mantine/notifications'; import { renderHook } from '@testing-library/react'; import { + getBuilderValueColumnCount, getEventBody, getSourceValidationNotificationId, getTraceDurationNumberFormat, @@ -230,6 +231,14 @@ describe('useSources validation notifications', () => { }); }); +const METRIC_TABLES = { + gauge: 'otel_metrics_gauge', + sum: 'otel_metrics_sum', + histogram: 'otel_metrics_histogram', + summary: 'otel_metrics_summary', + 'exponential histogram': 'otel_metrics_exponential_histogram', +}; + const DURATION_FORMAT: NumberFormat = { output: 'duration', factor: 1e-9 }; const CURRENCY_FORMAT: NumberFormat = { output: 'currency' }; const PERCENT_FORMAT: NumberFormat = { output: 'percent' }; @@ -521,4 +530,118 @@ describe('useChartNumberFormats', () => { ); expect(result.current.formatByColumn.size).toBe(0); }); + + // --- metric formula configs (HDX-5080) --- + // The composed metric query projects operand series columns (unless + // showOperandSeries is false) followed by one column per formula. + + const META_FORMULA = { name: 'A / B', type: 'Float64' } as ColumnMetaType; + + const makeFormulaConfig = ( + overrides: Partial = {}, + ) => + makeBuilderConfig({ + metricTables: METRIC_TABLES, + select: [ + { valueExpression: 'Value', numberFormat: NUMBER_FORMAT }, + { valueExpression: 'Value' }, + ], + formulas: [{ expression: 'A / B', numberFormat: PERCENT_FORMAT }], + ...overrides, + }); + + it('formula config: maps operand columns then formula columns positionally', () => { + const config = makeFormulaConfig(); + const { result } = renderHook(() => + useChartNumberFormats(config, [META_A, META_B, META_FORMULA]), + ); + expect(Array.from(result.current.formatByColumn.entries())).toEqual([ + ['col_a', NUMBER_FORMAT], + ['A / B', PERCENT_FORMAT], + ]); + }); + + it('formula config: maps only formula columns when operand series are hidden', () => { + const config = makeFormulaConfig({ showOperandSeries: false }); + const { result } = renderHook(() => + useChartNumberFormats(config, [META_FORMULA]), + ); + expect(Array.from(result.current.formatByColumn.entries())).toEqual([ + ['A / B', PERCENT_FORMAT], + ]); + }); + + it('formula config: formula columns fall back to config.numberFormat', () => { + const config = makeFormulaConfig({ + formulas: [{ expression: 'A / B' }], + numberFormat: CURRENCY_FORMAT, + showOperandSeries: false, + }); + const { result } = renderHook(() => + useChartNumberFormats(config, [META_FORMULA]), + ); + expect(result.current.formatByColumn.get('A / B')).toEqual(CURRENCY_FORMAT); + }); + + it('formula config: formula supersedes ratio mapping when both are set', () => { + const config = makeFormulaConfig({ seriesReturnType: 'ratio' }); + const { result } = renderHook(() => + useChartNumberFormats(config, [META_A, META_B, META_FORMULA]), + ); + // The ratio branch would map only meta[0]; the formula branch maps + // operands + formula columns. + expect(result.current.formatByColumn.get('A / B')).toEqual(PERCENT_FORMAT); + }); + + it('formula config: chartFormat prefers formula format when operands are hidden', () => { + const config = makeFormulaConfig({ showOperandSeries: false }); + const { result } = renderHook(() => useChartNumberFormats(config)); + expect(result.current.chartFormat).toEqual(PERCENT_FORMAT); + }); + + it('formula config: chartFormat prefers series format when operands are shown', () => { + const config = makeFormulaConfig(); + const { result } = renderHook(() => useChartNumberFormats(config)); + expect(result.current.chartFormat).toEqual(NUMBER_FORMAT); + }); +}); + +describe('getBuilderValueColumnCount', () => { + it('counts one column per select entry by default', () => { + const config = makeBuilderConfig({ + select: [{ valueExpression: 'count()' }, { valueExpression: 'sum(x)' }], + }); + expect(getBuilderValueColumnCount(config)).toBe(2); + }); + + it('counts one merged column for ratio configs', () => { + const config = makeBuilderConfig({ + seriesReturnType: 'ratio', + select: [{ valueExpression: 'count()' }, { valueExpression: 'sum(x)' }], + }); + expect(getBuilderValueColumnCount(config)).toBe(1); + }); + + it('counts operand and formula columns for metric formula configs', () => { + const config = makeBuilderConfig({ + metricTables: METRIC_TABLES, + select: [{ valueExpression: 'Value' }, { valueExpression: 'Value' }], + formulas: [{ expression: 'A / B' }], + }); + expect(getBuilderValueColumnCount(config)).toBe(3); + }); + + it('counts only formula columns when operand series are hidden', () => { + const config = makeBuilderConfig({ + metricTables: METRIC_TABLES, + select: [{ valueExpression: 'Value' }, { valueExpression: 'Value' }], + formulas: [{ expression: 'A / B' }, { expression: 'A + B' }], + showOperandSeries: false, + }); + expect(getBuilderValueColumnCount(config)).toBe(2); + }); + + it('returns 0 for raw SQL configs', () => { + expect(getBuilderValueColumnCount(makeRawSqlConfig({}))).toBe(0); + }); }); diff --git a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts index aed30696f9..dd85a1b3b6 100644 --- a/packages/app/src/components/ChartEditor/__tests__/utils.test.ts +++ b/packages/app/src/components/ChartEditor/__tests__/utils.test.ts @@ -1619,3 +1619,233 @@ describe('heatmap round-trip', () => { ); }); }); + +describe('metric formulas (HDX-5080)', () => { + const metricSeriesItem = { + aggFn: 'avg' as const, + valueExpression: 'Value', + aggCondition: '', + aggConditionLanguage: 'lucene' as const, + metricType: MetricsDataType.Gauge, + metricName: 'cpu.usage', + }; + + const makeMetricForm = ( + overrides: Partial, + ): ChartEditorFormState => ({ + displayType: DisplayType.Line, + source: 'source-metric', + where: '', + series: [ + metricSeriesItem, + { ...metricSeriesItem, metricName: 'cpu.limit' }, + ], + ...overrides, + }); + + describe('validateChartForm', () => { + it('accepts a valid formula referencing existing series', () => { + const setError = jest.fn(); + const errors = validateChartForm( + makeMetricForm({ + formulas: [{ expression: 'A / (A + B) * 100' }], + }), + metricSource, + setError, + ); + expect(errors).toHaveLength(0); + expect(setError).not.toHaveBeenCalled(); + }); + + it('reports a malformed formula expression at its field path', () => { + const setError = jest.fn(); + const errors = validateChartForm( + makeMetricForm({ formulas: [{ expression: 'A +' }] }), + metricSource, + setError, + ); + expect(errors).toEqual([ + expect.objectContaining({ path: 'formulas.0.expression' }), + ]); + expect(setError).toHaveBeenCalledWith( + 'formulas.0.expression', + expect.objectContaining({ type: 'manual' }), + ); + }); + + it('reports an unknown series reference', () => { + const setError = jest.fn(); + const errors = validateChartForm( + makeMetricForm({ formulas: [{ expression: 'A + C' }] }), + metricSource, + setError, + ); + expect(errors).toEqual([ + expect.objectContaining({ + path: 'formulas.0.expression', + message: expect.stringContaining('Unknown series "C"'), + }), + ]); + }); + + it('reports an empty formula expression', () => { + const setError = jest.fn(); + const errors = validateChartForm( + makeMetricForm({ formulas: [{ expression: '' }] }), + metricSource, + setError, + ); + expect(errors).toEqual([ + expect.objectContaining({ path: 'formulas.0.expression' }), + ]); + }); + + it('validates each formula independently', () => { + const setError = jest.fn(); + const errors = validateChartForm( + makeMetricForm({ + formulas: [{ expression: 'A + B' }, { expression: 'A *' }], + }), + metricSource, + setError, + ); + expect(errors).toEqual([ + expect.objectContaining({ path: 'formulas.1.expression' }), + ]); + }); + + it('skips formula validation for non-metric sources (formulas are stripped on save)', () => { + const setError = jest.fn(); + const errors = validateChartForm( + makeMetricForm({ + source: 'source-log', + series: [seriesItem], + formulas: [{ expression: 'A +' }], + }), + logSource, + setError, + ); + expect(errors).toHaveLength(0); + }); + + it('skips formula validation for non-formula display types (formulas are stripped on save)', () => { + const setError = jest.fn(); + const errors = validateChartForm( + makeMetricForm({ + displayType: DisplayType.Pie, + series: [metricSeriesItem], + formulas: [{ expression: 'A +' }], + }), + metricSource, + setError, + ); + expect(errors).toHaveLength(0); + }); + + it('lifts the Number chart series cap when formulas are present', () => { + const setError = jest.fn(); + const errors = validateChartForm( + makeMetricForm({ + displayType: DisplayType.Number, + series: [metricSeriesItem, metricSeriesItem, metricSeriesItem], + formulas: [{ expression: 'A / (A + B + C) * 100' }], + showOperandSeries: false, + }), + metricSource, + setError, + ); + expect(errors).toHaveLength(0); + }); + + it('keeps the Number chart series cap without formulas', () => { + const setError = jest.fn(); + const errors = validateChartForm( + makeMetricForm({ + displayType: DisplayType.Number, + series: [metricSeriesItem, metricSeriesItem, metricSeriesItem], + }), + metricSource, + setError, + ); + expect(errors).toEqual([expect.objectContaining({ path: 'series' })]); + }); + }); + + describe('normalization (convertFormStateToSavedChartConfig)', () => { + // Narrow the SavedChartConfig union without an unsafe assertion — every + // form here is a builder config, so anything else is a test failure. + const savedBuilderConfig = ( + form: ChartEditorFormState, + source: TSource, + ): BuilderSavedChartConfig => { + const saved = convertFormStateToSavedChartConfig(form, source); + if (!saved || 'configType' in saved) { + throw new Error('expected a builder saved chart config'); + } + return saved; + }; + + it('retains formulas and showOperandSeries for a metric time series chart', () => { + const saved = savedBuilderConfig( + makeMetricForm({ + formulas: [{ expression: 'A / B', alias: 'Ratio' }], + showOperandSeries: false, + }), + metricSource, + ); + expect(saved.formulas).toEqual([{ expression: 'A / B', alias: 'Ratio' }]); + expect(saved.showOperandSeries).toBe(false); + }); + + it('strips formulas for non-metric sources', () => { + const saved = savedBuilderConfig( + makeMetricForm({ + source: 'source-log', + series: [seriesItem], + formulas: [{ expression: 'A * 100' }], + showOperandSeries: false, + }), + logSource, + ); + expect(saved.formulas).toBeUndefined(); + expect(saved.showOperandSeries).toBeUndefined(); + }); + + it('strips formulas for display types the composed metric query does not render', () => { + const saved = savedBuilderConfig( + makeMetricForm({ + displayType: DisplayType.Pie, + series: [metricSeriesItem], + formulas: [{ expression: 'A * 100' }], + }), + metricSource, + ); + expect(saved.formulas).toBeUndefined(); + expect(saved.showOperandSeries).toBeUndefined(); + }); + + it('round-trips formulas through saved config and form state', () => { + const saved = convertFormStateToSavedChartConfig( + makeMetricForm({ + formulas: [ + { + expression: 'A / (A + B) * 100', + alias: 'Error rate', + numberFormat: { output: 'percent' }, + }, + ], + }), + metricSource, + ); + expect(saved).toBeDefined(); + const restored = convertSavedChartConfigToFormState(saved!); + expect(restored.formulas).toEqual([ + { + expression: 'A / (A + B) * 100', + alias: 'Error rate', + numberFormat: { output: 'percent' }, + }, + ]); + }); + }); +}); diff --git a/packages/app/src/components/ChartEditor/utils.ts b/packages/app/src/components/ChartEditor/utils.ts index 3b67934091..29f7b13eaf 100644 --- a/packages/app/src/components/ChartEditor/utils.ts +++ b/packages/app/src/components/ChartEditor/utils.ts @@ -1,5 +1,6 @@ import { omit, pick } from 'lodash'; import { Path, UseFormSetError } from 'react-hook-form'; +import { validateFormula } from '@hyperdx/common-utils/dist/core/formula'; import { validateRawSqlForAlert } from '@hyperdx/common-utils/dist/core/utils'; import { isBuilderSavedChartConfig, @@ -204,10 +205,26 @@ export function buildRawSqlCompletions({ function normalizeChartConfig< C extends Pick< BuilderSavedChartConfig, - 'select' | 'having' | 'orderBy' | 'displayType' | 'metricTables' | 'onClick' + | 'select' + | 'having' + | 'orderBy' + | 'displayType' + | 'metricTables' + | 'onClick' + | 'formulas' + | 'showOperandSeries' >, >(config: C, source: TSource): C { const isMetricSource = source.kind === SourceKind.Metric; + // Formulas (HDX-5080) only render on metric sources, and only through the + // composed multi-series metric query shapes (time series / table / number). + // Strip them elsewhere so a source or display-type switch can't persist a + // config the renderer would reject. The form state keeps them, so switching + // back restores the formula rows. + const keepFormulas = + isMetricSource && + (config.formulas?.length ?? 0) > 0 && + isFormulaDisplayType(config.displayType); return { ...config, // Strip out metric-specific fields for non-metric sources @@ -216,6 +233,8 @@ function normalizeChartConfig< ? config.select.map(s => omit(s, ['metricName', 'metricType'])) : config.select, metricTables: isMetricSource ? config.metricTables : undefined, + formulas: keepFormulas ? config.formulas : undefined, + showOperandSeries: keepFormulas ? config.showOperandSeries : undefined, // Order By and Having can only be set by the user for table charts having: config.displayType === DisplayType.Table ? config.having : undefined, @@ -279,6 +298,23 @@ const isCustomOrderByDisplayType = ( displayType === DisplayType.Bar || displayType === DisplayType.Pie; +/** + * Display types that can carry metric formulas (HDX-5080) — the shapes the + * composed multi-series metric query renders. Mirrors the "Add Formula" + * gating in ChartEditorControls. + */ +export const isFormulaDisplayType = ( + displayType: DisplayType | undefined, +): displayType is + | DisplayType.Line + | DisplayType.StackedBar + | DisplayType.Table + | DisplayType.Number => + displayType === DisplayType.Line || + displayType === DisplayType.StackedBar || + displayType === DisplayType.Table || + displayType === DisplayType.Number; + export function convertFormStateToSavedChartConfig( form: ChartEditorFormState, source: TSource | undefined, @@ -564,6 +600,30 @@ export const validateChartForm = ( }); } + // Validate metric formulas (HDX-5080) with the structured validator the + // query renderer uses, so a bad expression is caught here rather than at + // render time. Only applies where formulas survive normalization (metric + // source + formula-capable display type). + if ( + !isRawSqlChart && + source?.kind === SourceKind.Metric && + isFormulaDisplayType(form.displayType) && + Array.isArray(form.formulas) + ) { + const seriesCount = Array.isArray(form.series) ? form.series.length : 0; + form.formulas.forEach((formula, index) => { + const result = validateFormula(formula.expression ?? '', { + seriesCount, + }); + if (!result.ok) { + errors.push({ + path: `formulas.${index}.expression`, + message: result.errors.map(e => e.message).join('; '), + }); + } + }); + } + // Validate raw SQL alert has required time filters and interval parameters if (isRawSqlChart && form.alert) { const config = { @@ -616,11 +676,13 @@ export const validateChartForm = ( // Number charts allow a second series only for ratio mode (numerator / // denominator, which can be shown as a percentage via the number format); - // otherwise they show a single value. + // otherwise they show a single value. With formulas the extra series are + // operands (e.g. A / (A + B + C)), so the cap doesn't apply. if ( !isRawSqlChart && Array.isArray(form.series) && form.displayType === DisplayType.Number && + !(form.formulas?.length && source?.kind === SourceKind.Metric) && form.series.length > (form.seriesReturnType === 'ratio' ? 2 : 1) ) { errors.push({ diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx index e7e237530b..6f2a16534a 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx @@ -1,8 +1,9 @@ -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Control, FieldArrayWithId, FieldErrors, + useFieldArray, UseFormClearErrors, UseFormSetValue, useWatch, @@ -19,7 +20,11 @@ import { TSource, } from '@hyperdx/common-utils/dist/types'; import { Box, Button, Divider, Flex, Group, Switch, Text } from '@mantine/core'; -import { IconBell, IconCirclePlus } from '@tabler/icons-react'; +import { + IconBell, + IconCirclePlus, + IconMathFunction, +} from '@tabler/icons-react'; import { ChartEditorFormState, @@ -37,6 +42,7 @@ import { getEventBody, isSingleExpression } from '@/source'; import { DEFAULT_TILE_ALERT } from '@/utils/alerts'; import { OnClickFormButton } from './OnClickForm/OnClickFormButton'; +import { ChartFormulaEditor } from './ChartFormulaEditor'; import { ChartSeriesEditor } from './ChartSeriesEditor'; import { HeatmapSeriesEditor } from './HeatmapSeriesEditor'; import { TileAlertEditor } from './TileAlertEditor'; @@ -101,13 +107,61 @@ export function ChartEditorControls({ openDisplaySettings, openHeatmapSettings, }: ChartEditorControlsProps) { + // Metric formulas (HDX-5080): derived series computed from the chart's + // series via letter-ref arithmetic expressions. Metric sources only, and + // only on display types the composed multi-series metric query renders + // (time series / table / number). + const { + fields: formulaFields, + append: appendFormula, + remove: removeFormula, + } = useFieldArray({ control, name: 'formulas' }); + const hasFormulas = formulaFields.length > 0; + const showOperandSeries = useWatch({ control, name: 'showOperandSeries' }); + const displayTypeSupportsFormulas = + displayType === DisplayType.Line || + displayType === DisplayType.StackedBar || + displayType === DisplayType.Table || + displayType === DisplayType.Number; + const sourceSupportsFormulas = + tableSource?.kind === SourceKind.Metric && displayTypeSupportsFormulas; + // Formulas and the ratio toggle are mutually exclusive (formulas supersede + // ratio in the renderer, so the editor never lets both be set). + const canAddFormula = sourceSupportsFormulas && seriesReturnType !== 'ratio'; + + const handleAddFormula = useCallback(() => { + // On a Number tile the chart displays the first value column, so hide + // the operand series by default — the formula is what the user wants to + // see. (The "Show input series" toggle can bring them back.) + if (displayType === DisplayType.Number && formulaFields.length === 0) { + setValue('showOperandSeries', false); + } + appendFormula({ expression: '', alias: '' }); + }, [appendFormula, displayType, formulaFields.length, setValue]); + + const handleRemoveFormula = useCallback( + (index: number) => { + removeFormula(index); + if (formulaFields.length <= 1) { + // Removing the last formula: clear the keys entirely so saved + // configs don't carry an empty formulas array or a dangling + // showOperandSeries flag. + setValue('formulas', undefined); + setValue('showOperandSeries', undefined); + } + onSubmit(true); + }, + [removeFormula, formulaFields.length, setValue, onSubmit], + ); + const canAddSeries = displayType !== DisplayType.Pie && displayType !== DisplayType.Bar && displayType !== DisplayType.Heatmap && // Number tiles support up to two series (numerator + denominator for - // ratio mode); Line/Table types remain unbounded. - !(displayType === DisplayType.Number && fields.length >= 2); + // ratio mode); Line/Table types remain unbounded. With formulas the + // series are operands (e.g. A / (A + B + C)), so the cap is lifted. + !(displayType === DisplayType.Number && fields.length >= 2 && !hasFormulas); const [isSourceSchemaPreviewOpen, setIsSourceSchemaPreviewOpen] = useState(false); @@ -247,6 +301,18 @@ export function ChartEditorControls({ clearErrors={clearErrors} /> ))} + {sourceSupportsFormulas && + formulaFields.map((field, index) => ( + + ))} {fields.length > 1 && displayType !== DisplayType.Number && ( <> @@ -326,10 +392,42 @@ export function ChartEditorControls({ Add Series )} + {canAddFormula && ( + + )} + {/* Only the formula column(s) vs formula + raw operand series + (renderer: showOperandSeries, default shown). */} + {sourceSupportsFormulas && hasFormulas && ( + { + setValue( + 'showOperandSeries', + showOperandSeries === false ? undefined : false, + ); + onSubmit(); + }} + checked={showOperandSeries !== false} + /> + )} {/* Ratio merges exactly two series via divide(); only Line/StackedBar/Table/Number can reach two series, so gating - on the count alone covers them all (Number included). */} - {fields.length === 2 && ( + on the count alone covers them all (Number included). + Formulas supersede ratio in the renderer, so the toggle is + hidden while any formula exists (mutually exclusive). */} + {fields.length === 2 && !hasFormulas && ( ; + index: number; + namePrefix: `formulas.${number}.`; + onRemoveFormula: (index: number) => void; + onSubmit: () => void; + setValue: UseFormSetValue; +}; + +/** + * Editor row for one metric formula (HDX-5080): a derived series computed + * from the chart's `select` entries via a letter-ref arithmetic expression + * (`A` = series 1, `B` = series 2, ...). See `core/formula.ts` in + * common-utils for the grammar; expressions are validated inline with the + * same structured validator the query renderer uses, so errors surface here + * before they can reach ClickHouse. + */ +export function ChartFormulaEditor({ + control, + index, + namePrefix, + onRemoveFormula, + onSubmit, + setValue, +}: ChartFormulaEditorProps) { + const series = useWatch({ control, name: 'series' }); + const seriesCount = Array.isArray(series) ? series.length : 0; + + const expression = useWatch({ + control, + name: `${namePrefix}expression`, + }); + + // Live structured validation (unknown refs, malformed expressions, ...). + // An empty expression is not flagged red while the user is still composing + // the row — the save-time validation in validateChartForm catches it. + const validationError = useMemo(() => { + if (!expression || expression.trim() === '') { + return undefined; + } + const result = validateFormula(expression, { seriesCount }); + if (result.ok) { + return undefined; + } + return result.errors.map(e => e.message).join('; '); + }, [expression, seriesCount]); + + const numberFormat = useWatch({ + control, + name: `${namePrefix}numberFormat`, + }); + + const [ + isNumberFormatOpen, + { open: openNumberFormat, close: closeNumberFormat }, + ] = useDisclosure(false); + + return ( + <> + + + Formula + + Alias +
+ onSubmit()} + size="xs" + data-testid="formula-alias-input" + /> +
+ + + + {FORMAT_ICONS[numberFormat?.output ?? 'number']} + + + + } + labelPosition="right" + mb={8} + mt="sm" + /> + ( + { + if (e.key === 'Enter') { + onSubmit(); + } + }} + onBlur={() => { + field.onBlur(); + onSubmit(); + }} + data-testid="formula-expression-input" + /> + )} + /> + { + setValue(`${namePrefix}numberFormat`, format.numberFormat); + onSubmit(); + }} + onClose={closeNumberFormat} + /> + + ); +} diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartSeriesEditor.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartSeriesEditor.tsx index 9a887caff9..41561ebc28 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartSeriesEditor.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartSeriesEditor.tsx @@ -6,6 +6,7 @@ import { UseFormSetValue, useWatch, } from 'react-hook-form'; +import { indexToSeriesRef } from '@hyperdx/common-utils/dist/core/formula'; import { DateRange, isChartPaletteToken, @@ -15,6 +16,7 @@ import { } from '@hyperdx/common-utils/dist/types'; import { ActionIcon, + Badge, Button, Divider, Flex, @@ -254,6 +256,23 @@ export function ChartSeriesEditor({ + {/* Formula series reference (HDX-5080): formulas address series + positionally by letter (`A` = series 1, ...), so surface the + letter on each row. Metric sources only — formulas are only + supported there. */} + {tableSource?.kind === SourceKind.Metric && ( + + + {indexToSeriesRef(index) ?? index + 1} + + + )} Alias
diff --git a/packages/app/src/components/DBEditTimeChartForm/__tests__/DBEditTimeChartForm.test.tsx b/packages/app/src/components/DBEditTimeChartForm/__tests__/DBEditTimeChartForm.test.tsx index 7796fdee80..002678f3c2 100644 --- a/packages/app/src/components/DBEditTimeChartForm/__tests__/DBEditTimeChartForm.test.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/__tests__/DBEditTimeChartForm.test.tsx @@ -620,3 +620,235 @@ describe('DBEditTimeChartForm - Column color', () => { expect(screen.getByTestId('series-color-apply')).toBeInTheDocument(); }); }); + +describe('DBEditTimeChartForm - Metric formulas', () => { + const mockUseSourceData = (data: unknown) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const mocked = { data } as ReturnType; + jest.mocked(useSource).mockReturnValue(mocked); + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Earlier describes override the useSource mock with mockReturnValue + // (which survives clearAllMocks), so pin the metric source back. + mockUseSourceData({ + id: 'metric-source', + kind: SourceKind.Metric, + name: 'Test Metric Source', + from: { databaseName: 'default', tableName: '' }, + connection: 'default', + timestampValueExpression: 'Timestamp', + metricTables: { + gauge: 'metrics.gauge', + sum: 'metrics.sum', + histogram: 'metrics.histogram', + }, + }); + }); + + const gaugeSeries = { + aggFn: 'avg' as const, + aggCondition: '', + aggConditionLanguage: 'lucene' as const, + valueExpression: 'Value', + metricType: MetricsDataType.Gauge, + metricName: 'test.metric.gauge', + }; + + const twoSeriesConfig: SavedChartConfig = { + ...defaultChartConfig, + select: [gaugeSeries, { ...gaugeSeries, metricName: 'test.metric.sum' }], + }; + + it('shows the Add Formula button and series letter badges for metric sources', () => { + renderComponent({ chartConfig: twoSeriesConfig }); + + expect(screen.getByTestId('add-formula-button')).toBeInTheDocument(); + const badges = screen.getAllByTestId('series-ref-badge'); + expect(badges.map(b => b.textContent)).toEqual(['A', 'B']); + }); + + it('adds a formula row with an expression input when Add Formula is clicked', async () => { + renderComponent({ chartConfig: twoSeriesConfig }); + + await userEvent.click(screen.getByTestId('add-formula-button')); + + expect(screen.getByTestId('formula-expression-input')).toBeInTheDocument(); + expect(screen.getByTestId('formula-alias-input')).toBeInTheDocument(); + }); + + it('shows an inline validation error for a malformed expression', async () => { + renderComponent({ chartConfig: twoSeriesConfig }); + + await userEvent.click(screen.getByTestId('add-formula-button')); + await userEvent.type(screen.getByTestId('formula-expression-input'), 'A +'); + + await waitFor(() => { + expect( + screen.getByText(/Unexpected end of expression/), + ).toBeInTheDocument(); + }); + }); + + it('shows an inline validation error for an unknown series reference', async () => { + renderComponent({ chartConfig: twoSeriesConfig }); + + await userEvent.click(screen.getByTestId('add-formula-button')); + await userEvent.type(screen.getByTestId('formula-expression-input'), 'C'); + + await waitFor(() => { + expect(screen.getByText(/Unknown series "C"/)).toBeInTheDocument(); + }); + }); + + it('clears the inline error once the expression becomes valid', async () => { + renderComponent({ chartConfig: twoSeriesConfig }); + + await userEvent.click(screen.getByTestId('add-formula-button')); + const input = screen.getByTestId('formula-expression-input'); + await userEvent.type(input, 'C'); + await waitFor(() => { + expect(screen.getByText(/Unknown series "C"/)).toBeInTheDocument(); + }); + + await userEvent.clear(input); + await userEvent.type(input, 'A / (A + B) * 100'); + + await waitFor(() => { + expect(screen.queryByText(/Unknown series/)).not.toBeInTheDocument(); + }); + }); + + it('saves formulas on the chart config', async () => { + const onSave = jest.fn(); + renderComponent({ chartConfig: twoSeriesConfig, onSave }); + + await userEvent.click(screen.getByTestId('add-formula-button')); + await userEvent.type( + screen.getByTestId('formula-expression-input'), + 'A / (A + B) * 100', + ); + await userEvent.type( + screen.getByTestId('formula-alias-input'), + 'Share of gauge', + ); + await userEvent.click(screen.getByTestId('chart-save-button')); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + const saved = onSave.mock.calls[0][0]; + expect(saved.formulas).toEqual([ + { expression: 'A / (A + B) * 100', alias: 'Share of gauge' }, + ]); + }); + + it('blocks save when the formula expression is invalid', async () => { + const onSave = jest.fn(); + renderComponent({ chartConfig: twoSeriesConfig, onSave }); + + await userEvent.click(screen.getByTestId('add-formula-button')); + await userEvent.type(screen.getByTestId('formula-expression-input'), 'Z'); + await userEvent.click(screen.getByTestId('chart-save-button')); + + // Save is rejected by validateChartForm; onSave never fires. + await waitFor(() => { + expect(screen.getAllByText(/Unknown series "Z"/).length).toBeGreaterThan( + 0, + ); + }); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('removes the formula row and clears formulas from the saved config', async () => { + const onSave = jest.fn(); + renderComponent({ + chartConfig: { + ...twoSeriesConfig, + formulas: [{ expression: 'A + B' }], + showOperandSeries: false, + }, + onSave, + }); + + expect(screen.getByTestId('formula-expression-input')).toBeInTheDocument(); + await userEvent.click(screen.getByTestId('formula-remove-button')); + expect( + screen.queryByTestId('formula-expression-input'), + ).not.toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('chart-save-button')); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + const saved = onSave.mock.calls[0][0]; + expect(saved.formulas).toBeUndefined(); + expect(saved.showOperandSeries).toBeUndefined(); + }); + + it('hides the As Ratio toggle while a formula exists', () => { + renderComponent({ + chartConfig: { + ...twoSeriesConfig, + formulas: [{ expression: 'A + B' }], + }, + }); + + expect(screen.queryByLabelText('As Ratio')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Show input series')).toBeInTheDocument(); + }); + + it('hides the Add Formula button while ratio mode is enabled', () => { + renderComponent({ + chartConfig: { ...twoSeriesConfig, seriesReturnType: 'ratio' }, + }); + + expect(screen.getByLabelText('As Ratio')).toBeInTheDocument(); + expect(screen.queryByTestId('add-formula-button')).not.toBeInTheDocument(); + }); + + it('toggles showOperandSeries via the Show input series switch', async () => { + const onSave = jest.fn(); + renderComponent({ + chartConfig: { + ...twoSeriesConfig, + formulas: [{ expression: 'A + B' }], + }, + onSave, + }); + + const toggle = screen.getByLabelText('Show input series'); + expect(toggle).toBeChecked(); + await userEvent.click(toggle); + + await userEvent.click(screen.getByTestId('chart-save-button')); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0][0].showOperandSeries).toBe(false); + }); + + it('does not show formula controls for non-metric sources', () => { + mockUseSourceData({ + id: 'log-source', + kind: SourceKind.Log, + name: 'Logs', + from: { databaseName: 'default', tableName: 'otel_logs' }, + connection: 'default', + timestampValueExpression: 'Timestamp', + }); + + renderComponent({ + chartConfig: { + ...defaultChartConfig, + source: 'log-source', + select: [ + { + aggFn: 'count', + aggCondition: '', + aggConditionLanguage: 'lucene' as const, + valueExpression: '', + }, + ], + }, + }); + + expect(screen.queryByTestId('add-formula-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('series-ref-badge')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app/src/components/DBTableChart.tsx b/packages/app/src/components/DBTableChart.tsx index ac9fa73a28..133e22ab99 100644 --- a/packages/app/src/components/DBTableChart.tsx +++ b/packages/app/src/components/DBTableChart.tsx @@ -21,7 +21,11 @@ import { Table, TableVariant } from '@/HDXMultiSeriesTableChart'; import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation'; import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery'; import { useOnClickLinkBuilder } from '@/hooks/useOnClickLinkBuilder'; -import { useChartNumberFormats, useSource } from '@/source'; +import { + getBuilderValueColumnCount, + useChartNumberFormats, + useSource, +} from '@/source'; import { useIntersectionObserver } from '@/utils'; import ChartContainer from './charts/ChartContainer'; @@ -137,7 +141,11 @@ export default function DBTableChart({ // identically and the columns memo consumes them the same way. Color targets // aggregation (series) columns only; group-by columns are not select items // and never appear here. Ratio configs merge two series into one column, so - // per-column color is skipped (matching the numberFormat treatment). + // per-column color is skipped (matching the numberFormat treatment). Metric + // formula configs with hidden operand series project no per-series columns + // at all (only formula columns, which carry no color config), so they're + // skipped too; with operands shown the positional mapping below still holds + // (formula columns come after the operands and simply get no color). const { colorByColumn, rulesByColumn } = useMemo(() => { const colorByColumn = new Map(); const rulesByColumn = new Map(); @@ -146,7 +154,9 @@ export default function DBTableChart({ !meta || !isBuilderChartConfig(queriedConfig) || !Array.isArray(queriedConfig.select) || - isRatioChartConfig(queriedConfig.select, queriedConfig) + isRatioChartConfig(queriedConfig.select, queriedConfig) || + (queriedConfig.formulas?.length && + queriedConfig.showOperandSeries === false) ) { return { colorByColumn, rulesByColumn }; } @@ -181,8 +191,9 @@ export default function DBTableChart({ isBuilderChartConfig(queriedConfig) && Array.isArray(queriedConfig.select) ) { - const isRatio = isRatioChartConfig(queriedConfig.select, queriedConfig); - const seriesCount = isRatio ? 1 : queriedConfig.select.length; + // Value columns come first (formula-aware: operands + formula columns, + // or one merged ratio column); everything after is a group-by column. + const seriesCount = getBuilderValueColumnCount(queriedConfig); const groupByCount = allKeys.length - seriesCount; groupByKeys = groupByCount > 0 ? allKeys.slice(-groupByCount) : []; } diff --git a/packages/app/src/components/DBTimeChart.tsx b/packages/app/src/components/DBTimeChart.tsx index 99cc456b75..11eefcfdb5 100644 --- a/packages/app/src/components/DBTimeChart.tsx +++ b/packages/app/src/components/DBTimeChart.tsx @@ -734,8 +734,20 @@ function DBTimeChartComponent({ } | undefined; + // Metric formula configs with hidden operand series project only the + // formula column(s), so value columns no longer map positionally onto + // `select` — skip the value-range filter rather than misattributing a + // formula value to an operand's expression. (With operands shown, the + // operand columns still map by index and formula columns fall past the + // `< config.select.length` bound below.) + const operandsHidden = + isBuilderChartConfig(config) && + (config.formulas?.length ?? 0) > 0 && + config.showOperandSeries === false; + if ( seriesValue && + !operandsHidden && Array.isArray(config.select) && config.select.length > 0 ) { diff --git a/packages/app/src/source.ts b/packages/app/src/source.ts index 252fc95a14..a0286bdf3e 100644 --- a/packages/app/src/source.ts +++ b/packages/app/src/source.ts @@ -15,7 +15,10 @@ import { splitAndTrimWithBracket } from '@hyperdx/common-utils/dist/core/utils'; import { isBuilderChartConfig } from '@hyperdx/common-utils/dist/guards'; import { BuilderSavedChartConfig, + ChartConfig, + ChartConfigWithOptDateRange, ChartConfigWithOptTimestamp, + MetricFormula, MetricsDataType, NumberFormat, SourceKind, @@ -594,6 +597,54 @@ interface ResolvedNumberFormats { chartFormat?: NumberFormat; } +/** + * The formula projection settings of a builder metric config, or undefined + * when formulas don't apply (non-builder configs, non-metric sources, or no + * formulas configured). + */ +function getFormulaConfig( + config: ChartConfig | ChartConfigWithOptDateRange, +): { formulas: MetricFormula[]; operandsHidden: boolean } | undefined { + if ( + !isBuilderChartConfig(config) || + config.metricTables == null || + !config.formulas?.length + ) { + return undefined; + } + return { + formulas: config.formulas, + operandsHidden: config.showOperandSeries === false, + }; +} + +/** + * How many value (series) columns a builder chart config's query result + * carries, ahead of any group-by passthrough columns. + * + * Mirrors the projection built by the query renderer + * (renderMultiSeriesMetricChartConfig / ratio merging in common-utils): + * - metric formula configs project the operand series columns (unless + * `showOperandSeries` is false) followed by one column per formula; + * - ratio configs merge their two series into a single column; + * - everything else projects one column per select entry. + */ +export function getBuilderValueColumnCount( + config: ChartConfig | ChartConfigWithOptDateRange, +): number { + if (!isBuilderChartConfig(config) || !Array.isArray(config.select)) { + return 0; + } + const formulaConfig = getFormulaConfig(config); + if (formulaConfig) { + const operandCount = formulaConfig.operandsHidden + ? 0 + : config.select.length; + return operandCount + formulaConfig.formulas.length; + } + return isRatioChartConfig(config.select, config) ? 1 : config.select.length; +} + /** * Returns the number formats to use when formatting chart series values. * @@ -618,15 +669,26 @@ export function useChartNumberFormats( const { data: source } = useSource({ id: config.source }); return useMemo(() => { + const formulaConfig = getFormulaConfig(config); + // The chart-wide number format does not depend on meta, so that it can be // resolved without querying. Further, it prioritizes the config's numberFormat // over series-specific formats, so that the user can specify the y-axis format - // for charts with multiple series-specific formats. + // for charts with multiple series-specific formats. When only formula + // columns render (operands hidden), formula formats take priority over + // formats of series that aren't in the result at all. + const firstFormulaFormat = formulaConfig?.formulas.find( + f => f.numberFormat, + )?.numberFormat; + const firstSeriesFormat = + isBuilderChartConfig(config) && Array.isArray(config.select) + ? getFirstSeriesNumberFormat(config.select, source) + : undefined; const chartFormat = config.numberFormat ?? - (isBuilderChartConfig(config) && Array.isArray(config.select) - ? getFirstSeriesNumberFormat(config.select, source) - : undefined); + (formulaConfig?.operandsHidden + ? (firstFormulaFormat ?? firstSeriesFormat) + : (firstSeriesFormat ?? firstFormulaFormat)); // meta must be provided to map result column names (from meta) to number formats if (!meta) { @@ -638,6 +700,35 @@ export function useChartNumberFormats( return { formatByColumn: new Map(), chartFormat }; } + // Metric formula configs project the operand series columns (unless + // hidden) followed by one column per formula, ahead of any group-by + // passthrough columns (see renderMultiSeriesMetricChartConfig). Map + // formats positionally in that order. Formulas supersede ratio, so this + // takes priority over the ratio branch below. + if (formulaConfig) { + const orderedFormats: (NumberFormat | undefined)[] = [ + ...(formulaConfig.operandsHidden + ? [] + : config.select.map( + series => + series.numberFormat ?? + config.numberFormat ?? + getTraceDurationNumberFormat(source, series), + )), + ...formulaConfig.formulas.map( + formula => formula.numberFormat ?? config.numberFormat, + ), + ]; + const formatByColumn = new Map(); + orderedFormats.forEach((format, i) => { + const key = meta[i]?.name; + if (key != null && format) { + formatByColumn.set(key, format); + } + }); + return { formatByColumn, chartFormat }; + } + // Ratio-based configs have exactly two series, which // are merged into the first result column. if (isRatioChartConfig(config.select, config)) { diff --git a/packages/app/tests/e2e/components/ChartEditorComponent.ts b/packages/app/tests/e2e/components/ChartEditorComponent.ts index 2b1f5199dc..1af991a3d9 100644 --- a/packages/app/tests/e2e/components/ChartEditorComponent.ts +++ b/packages/app/tests/e2e/components/ChartEditorComponent.ts @@ -728,6 +728,73 @@ export class ChartEditorComponent { await this.page.getByTestId('series-duplicate-button').nth(index).click(); } + /** + * Select a metric by name on the series at zero-based `index`. Like + * selectMetric, but disambiguates between the metric selectors of a + * multi-series metric chart. + */ + async selectMetricForSeries( + index: number, + metricName: string, + metricValue?: string, + ) { + const selector = this.page.getByTestId('metric-name-selector').nth(index); + await selector.waitFor({ state: 'visible', timeout: 5000 }); + await selector.click(); + await selector.fill(metricName); + if (metricValue) { + // Every series' select keeps its (hidden) option list mounted, so + // scope to the visible one — the dropdown just opened for this series. + const targetMetricOption = this.page + .locator(`[data-combobox-option="true"][value="${metricValue}"]`) + .filter({ visible: true }); + await targetMetricOption.waitFor({ state: 'visible', timeout: 5000 }); + await targetMetricOption.click({ timeout: 5000 }); + } else { + await this.page.keyboard.press('Enter'); + } + } + + /** + * Click the "Add Formula" button (metric sources only) to append a formula + * row, and fill its expression (and optional alias). Targets the last + * formula row so multiple formulas can be added in sequence. + */ + async addFormula(expression: string, alias?: string) { + await this.page.getByTestId('add-formula-button').click(); + const expressionInput = this.page + .getByTestId('formula-expression-input') + .last(); + await expressionInput.fill(expression); + if (alias !== undefined) { + await this.page.getByTestId('formula-alias-input').last().fill(alias); + } + await expressionInput.blur(); + } + + /** + * Read the inline validation error of the formula row at zero-based + * `index`, or null when the expression is valid. + */ + async getFormulaError(index: number): Promise { + const input = this.page.getByTestId('formula-expression-input').nth(index); + // Mantine renders the error node as a sibling within the input wrapper. + const wrapper = input.locator('..').locator('..'); + const error = wrapper.locator('.mantine-InputWrapper-error'); + if ((await error.count()) === 0) { + return null; + } + return error.textContent(); + } + + /** + * Toggle the "Show input series" switch (visible while a metric formula + * exists) between formula-only and formula + operand series output. + */ + async toggleShowInputSeries() { + await this.page.getByRole('switch', { name: 'Show input series' }).click(); + } + /** * Toggle the "As Ratio" switch. Only visible when the chart has exactly * two series. diff --git a/packages/app/tests/e2e/features/dashboard.spec.ts b/packages/app/tests/e2e/features/dashboard.spec.ts index 50b8c6d069..89a7929585 100644 --- a/packages/app/tests/e2e/features/dashboard.spec.ts +++ b/packages/app/tests/e2e/features/dashboard.spec.ts @@ -2354,6 +2354,137 @@ test.describe('Dashboard', { tag: ['@dashboard'] }, () => { }, ); + test.describe( + 'Metric formulas (HDX-5080)', + { tag: ['@full-stack', '@dashboard'] }, + () => { + test.beforeEach(async () => { + await dashboardPage.createNewDashboard(); + }); + + test('creates a metric tile with two series and a formula, saves, reloads, and renders it', async ({ + page, + }) => { + test.setTimeout(60000); + const ts = Date.now(); + const chartName = `E2E Metric Formula ${ts}`; + + await test.step('Configure a metric Table chart with two gauge series', async () => { + await dashboardPage.addTile(); + await expect(dashboardPage.chartEditor.nameInput).toBeVisible(); + await dashboardPage.chartEditor.waitForDataToLoad(); + // Table display keeps the assertions robust: the formula surfaces + // as a named column header rather than a chart legend entry. + await dashboardPage.chartEditor.setChartType(DisplayType.Table); + await dashboardPage.chartEditor.selectSource( + DEFAULT_METRICS_SOURCE_NAME, + ); + await dashboardPage.chartEditor.setChartName(chartName); + + await dashboardPage.chartEditor.selectMetricForSeries( + 0, + 'container.cpu.utilization', + 'container.cpu.utilization:::::::gauge', + ); + await dashboardPage.chartEditor.setSeriesAlias(0, 'ContainerCpu'); + + await dashboardPage.chartEditor.addSeries(); + await dashboardPage.chartEditor.selectMetricForSeries( + 1, + 'k8s.pod.cpu.utilization', + 'k8s.pod.cpu.utilization:::::::gauge', + ); + await dashboardPage.chartEditor.setSeriesAlias(1, 'PodCpu'); + }); + + await test.step('Series rows expose their formula reference letters', async () => { + const badges = page.getByTestId('series-ref-badge'); + await expect(badges).toHaveCount(2); + await expect(badges.nth(0)).toHaveText('A'); + await expect(badges.nth(1)).toHaveText('B'); + }); + + await test.step('An invalid formula surfaces an inline validation error', async () => { + await dashboardPage.chartEditor.addFormula('A / C'); + expect(await dashboardPage.chartEditor.getFormulaError(0)).toContain( + 'Unknown series "C"', + ); + }); + + await test.step('Fix the formula and run the query', async () => { + await page + .getByTestId('formula-expression-input') + .fill('A / (A + B) * 100'); + await page.getByTestId('formula-alias-input').fill('CpuShare'); + expect(await dashboardPage.chartEditor.getFormulaError(0)).toBeNull(); + await dashboardPage.chartEditor.runQuery(false); + + const headers = + await dashboardPage.chartEditor.getPreviewTableHeaders(); + expect(headers).toContain('ContainerCpu'); + expect(headers).toContain('PodCpu'); + expect(headers).toContain('CpuShare'); + }); + + await test.step('Hide the operand series so only the formula column renders', async () => { + await dashboardPage.chartEditor.toggleShowInputSeries(); + + await expect + .poll( + async () => dashboardPage.chartEditor.getPreviewTableHeaders(), + { timeout: 15000 }, + ) + .toEqual(['CpuShare']); + }); + + await test.step('Save the tile and verify the formula column renders on the dashboard', async () => { + await dashboardPage.saveTile(); + await expect(dashboardPage.chartEditor.nameInput).toBeHidden({ + timeout: 5000, + }); + + const tile = dashboardPage.getTiles().filter({ hasText: chartName }); + await expect(tile.locator('table')).toBeVisible({ timeout: 15000 }); + const tileHeaders = await dashboardPage.getTileTableHeaders(0); + expect(tileHeaders).toEqual(['CpuShare']); + }); + + await test.step('Reload the page and verify the saved formula tile still renders', async () => { + await page.reload(); + + const tile = dashboardPage.getTiles().filter({ hasText: chartName }); + await expect(tile.locator('table')).toBeVisible({ timeout: 15000 }); + const tileHeaders = await dashboardPage.getTileTableHeaders(0); + expect(tileHeaders).toEqual(['CpuShare']); + + // The formula value is a percentage share, so the column must hold + // a finite number — a NaN/empty cell would mean the composed + // formula projection failed. + const cells = await dashboardPage.getTileTableCellTexts(0, 0); + expect(cells.length).toBeGreaterThan(0); + expect(Number.parseFloat(cells[0])).not.toBeNaN(); + }); + + await test.step('Reopen the tile editor and verify the formula round-tripped', async () => { + await dashboardPage.editTile(0); + await expect(dashboardPage.chartEditor.nameInput).toBeVisible(); + + await expect( + dashboardPage.page.getByTestId('formula-expression-input'), + ).toHaveValue('A / (A + B) * 100'); + await expect( + dashboardPage.page.getByTestId('formula-alias-input'), + ).toHaveValue('CpuShare'); + await expect( + dashboardPage.page.getByRole('switch', { + name: 'Show input series', + }), + ).not.toBeChecked(); + }); + }); + }, + ); + test( 'should isolate the fullscreen tile time picker from the dashboard time range', { tag: ['@full-stack', '@dashboard'] },