From 0eae6c9e4dd920c8409b6dd5bf80f9e19a7b9a1f Mon Sep 17 00:00:00 2001 From: Drew Davis Date: Fri, 13 Nov 2026 14:38:45 -0500 Subject: [PATCH] feat: add connection variables for raw SQL tiles and connection-scoped data source variables --- .changeset/source-vars-5.md | 5 + packages/app/src/DBDashboardImportPage.tsx | 92 ++++++++++++++++--- packages/app/src/DashboardFilters.tsx | 34 ++++--- .../__tests__/DBDashboardImportPage.test.tsx | 64 +++++++++++++ .../ChartEditor/RawSqlChartEditor.tsx | 28 +++++- .../app/src/components/ConnectionSelect.tsx | 48 ++++++---- .../DashboardFilterEditForm.tsx | 9 +- .../DataSourceFilterEditForm.tsx | 15 +++ .../DataSourceFilterEditForm.test.tsx | 4 + .../ReferenceVariableDashboardFilter.tsx | 32 ++++++- packages/app/src/components/SourceSelect.tsx | 7 ++ .../ReferenceVariableDashboardFilter.test.tsx | 43 ++++++++- .../__tests__/SourceSelect.test.tsx | 30 ++++++ .../app/src/components/sourceSelectUtils.tsx | 45 +++++++-- packages/app/src/hooks/useConnectionItems.ts | 17 ++++ .../e2e/components/ChartEditorComponent.ts | 13 +++ .../dashboard-source-variables.spec.ts | 75 ++++++++++++++- .../tests/e2e/page-objects/DashboardPage.ts | 26 +++++- 18 files changed, 520 insertions(+), 67 deletions(-) create mode 100644 .changeset/source-vars-5.md create mode 100644 packages/app/src/hooks/useConnectionItems.ts diff --git a/.changeset/source-vars-5.md b/.changeset/source-vars-5.md new file mode 100644 index 0000000000..0704548e10 --- /dev/null +++ b/.changeset/source-vars-5.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': minor +--- + +feat: add connection variables for raw SQL tiles and connection-scoped data source variables diff --git a/packages/app/src/DBDashboardImportPage.tsx b/packages/app/src/DBDashboardImportPage.tsx index eff35446bf..bbbf60d078 100644 --- a/packages/app/src/DBDashboardImportPage.tsx +++ b/packages/app/src/DBDashboardImportPage.tsx @@ -20,6 +20,7 @@ import { type DashboardFilterQueryIssue, getAdhocFilterSourceKinds, isAdhocFilter, + isDataSourceFilter, isPrometheusLabelFilter, isQueryExpressionFilter, QUERY_EXPRESSION_FILTER_SOURCE_KINDS, @@ -75,6 +76,7 @@ import { import SelectControlled from './components/SelectControlled'; import { SourceMultiSelectControlled } from './components/SourceMultiSelect'; +import { useConnectionItems } from './hooks/useConnectionItems'; import api from './api'; import { usePageTitle } from './brand'; import { useConnections } from './connection'; @@ -392,8 +394,8 @@ function resolveAppliesToSources( } /** - * A data source variable reference (`$name`) is portable as written, so - * import neither maps it nor asks the user to. + * A data source or connection variable reference (`$name`) is portable as + * written, so import neither maps it nor asks the user to. */ const isVariableRef = (name: string | undefined) => parseVariableReference(name) != null; @@ -411,6 +413,15 @@ const isSourceBackedTemplateFilter = ( (isQueryExpressionFilter(filter) || isPrometheusLabelFilter(filter)) && !isVariableRef(filter.source); +/** A data source variable limited to a connection that import must map. */ +const connectionBackedTemplateFilter = (filter: DashboardFilter | undefined) => + filter && + isDataSourceFilter(filter) && + filter.connection && + !isVariableRef(filter.connection) + ? filter + : undefined; + /** Returns the filter if it names a source, or `undefined` if it doesn't. */ const sourceBackedTemplateFilter = (filter: DashboardFilter | undefined) => filter && isSourceBackedTemplateFilter(filter) ? filter : undefined; @@ -473,6 +484,8 @@ export function buildMappingFormSchema( connectionMappings: z.array(MappingValueSchema), /** A list of filter source mappings, ordered by input filter index */ filterSourceMappings: z.array(MappingValueSchema).optional(), + /** Connection mappings of data source variables, ordered by input filter index */ + filterConnectionMappings: z.array(MappingValueSchema).optional(), /** * Per-filter applies-to source mappings. Each entry is an array of mapped * source IDs in the same order as the filter's `appliesToSourceIds` names @@ -514,7 +527,11 @@ export function buildMappingFormSchema( } // A raw SQL tile always gets a connection row, and its connection is // not optional — leaving it unpicked used to throw on `connection!.id`. - if (isRawSqlSavedChartConfig(config) && !data.connectionMappings[idx]) { + if ( + isRawSqlSavedChartConfig(config) && + !isVariableRef(config.connection) && + !data.connectionMappings[idx] + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['connectionMappings', idx], @@ -530,6 +547,16 @@ export function buildMappingFormSchema( }); input.filters?.forEach((filter, idx) => { + if ( + connectionBackedTemplateFilter(filter) && + !data.filterConnectionMappings?.[idx] + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['filterConnectionMappings', idx], + message: 'Select a connection for this filter', + }); + } if (isAdhocFilter(filter)) { const kinds = getAdhocFilterSourceKinds(filter.sourceType); const kindMessage = @@ -601,6 +628,7 @@ export function Mapping({ input }: { input: DashboardTemplate }) { const router = useRouter(); const { data: sources } = useSources(); const { data: connections } = useConnections(); + const connectionItems = useConnectionItems(); const { data: dashboards } = useDashboards(); const { data: existingTags } = api.useTags(); const [dashboardId] = useQueryState('dashboardId', parseAsString); @@ -620,6 +648,7 @@ export function Mapping({ input }: { input: DashboardTemplate }) { tileSourceMappings: input.tiles.map(() => ''), connectionMappings: input.tiles.map(() => ''), filterSourceMappings: input.filters?.map(() => '') ?? [], + filterConnectionMappings: input.filters?.map(() => '') ?? [], filterAppliesToSourceMappings: input.filters?.map(() => []) ?? [], filterAdhocSourceMappings: input.filters?.map(() => []) ?? [], onClickSourceMappings: input.tiles.map(() => ''), @@ -650,13 +679,20 @@ export function Mapping({ input }: { input: DashboardTemplate }) { return match?.id || ''; }); + const findConnectionIdByName = (name: string) => + connections.find(conn => conn.name.toLowerCase() === name.toLowerCase()) + ?.id ?? ''; + const connectionMappings = input.tiles.map(tile => { const config = tile.config as SavedChartConfig; - if (!isRawSqlSavedChartConfig(config)) return ''; - const match = connections.find( - conn => conn.name.toLowerCase() === config.connection.toLowerCase(), - ); - return match?.id || ''; + if (!isRawSqlSavedChartConfig(config) || isVariableRef(config.connection)) + return ''; + return findConnectionIdByName(config.connection); + }); + + const filterConnectionMappings = input.filters?.map(filter => { + const connection = connectionBackedTemplateFilter(filter)?.connection; + return connection ? findConnectionIdByName(connection) : ''; }); const filterSourceMappings = input.filters?.map(filter => { @@ -716,6 +752,7 @@ export function Mapping({ input }: { input: DashboardTemplate }) { setValue('tileSourceMappings', tileSourceMappings); setValue('connectionMappings', connectionMappings); + setValue('filterConnectionMappings', filterConnectionMappings); setValue('filterSourceMappings', filterSourceMappings); multiMappingSlotsRef.current.clear(); filterAppliesToSourceMappings?.forEach((mapping, idx) => { @@ -1035,12 +1072,14 @@ export function Mapping({ input }: { input: DashboardTemplate }) { }; if (isRawSqlSavedChartConfig(tile.config)) { - const connection = findConnection(data.connectionMappings[idx]); + const connection = isVariableRef(tile.config.connection) + ? tile.config.connection + : findConnection(data.connectionMappings[idx])!.id; return { ...tile, config: applyOnClick({ ...tile.config, - connection: connection!.id, + connection, ...(source ? { source: source.id } : {}), }), }; @@ -1082,6 +1121,13 @@ export function Mapping({ input }: { input: DashboardTemplate }) { appliesToSourceIds, }; } + if (connectionBackedTemplateFilter(filter)) { + return { + ...filter, + connection: findConnection(data.filterConnectionMappings?.[idx]) + ?.id, + }; + } const source = findSource(data.filterSourceMappings?.[idx]); return { ...filter, @@ -1192,7 +1238,7 @@ export function Mapping({ input }: { input: DashboardTemplate }) { )} {/** Mapping for the tile's connection, if it's a raw sql tile */} - {isRawSql && ( + {isRawSql && !isVariableRef(config.connection) && ( {tile.config.name} Data connection @@ -1201,10 +1247,7 @@ export function Mapping({ input }: { input: DashboardTemplate }) { ({ - value: conn.id, - label: conn.name, - }))} + data={connectionItems} placeholder="Select a connection" /> @@ -1264,6 +1307,25 @@ export function Mapping({ input }: { input: DashboardTemplate }) { : undefined; return ( + {connectionBackedTemplateFilter(filter) && ( + + {filter.name} (Filter) + Data connection + + {connectionBackedTemplateFilter(filter)?.connection} + + + + + + + + )} {isSourceBackedTemplateFilter(filter) && ( {filter.name} (Filter) diff --git a/packages/app/src/DashboardFilters.tsx b/packages/app/src/DashboardFilters.tsx index 1ef6ffdbfc..2edf22862c 100644 --- a/packages/app/src/DashboardFilters.tsx +++ b/packages/app/src/DashboardFilters.tsx @@ -10,6 +10,7 @@ import { isFilterRequired, isFilterVariableEnabled, isQueryExpressionFilter, + isReferenceVariableFilter, isStaticListFilter, } from '@hyperdx/common-utils/dist/filters'; import { ReferenceVariableValues } from '@hyperdx/common-utils/dist/referenceVariables'; @@ -30,7 +31,10 @@ import { getPendingVariablesTooltip, } from './components/DashboardFilterLabel'; import { FilterLinkToggle } from './components/FilterLinkToggle'; -import { DataSourceDashboardFilter } from './components/ReferenceVariableDashboardFilter'; +import { + ConnectionDashboardFilter, + DataSourceDashboardFilter, +} from './components/ReferenceVariableDashboardFilter'; import { VirtualMultiSelect } from './components/VirtualMultiSelect/VirtualMultiSelect'; import { useDashboardFilterValues } from './hooks/useDashboardFilterValues'; @@ -237,20 +241,28 @@ const DashboardFilters = ({ {filters.map(filter => { const unresolvedVariables = unresolvedReferenceVariablesByFilterId?.get(filter.id) ?? []; - if (isDataSourceFilter(filter)) { - return ( + if (isReferenceVariableFilter(filter)) { + const referenceProps = { + value: referenceVariableValues?.get( + getFilterVariableName(filter) ?? '', + ), + onChange: (value: string | undefined) => + onSetFilterValue(filter.id, value ? [value] : []), + effect: getFilterEffect(filter), + requiredTooltip: getRequiredFilterTooltip(filter), + }; + return isDataSourceFilter(filter) ? ( - onSetFilterValue(filter.id, value ? [value] : []) - } - effect={getFilterEffect(filter)} - requiredTooltip={getRequiredFilterTooltip(filter)} + {...referenceProps} + /> + ) : ( + ); } diff --git a/packages/app/src/__tests__/DBDashboardImportPage.test.tsx b/packages/app/src/__tests__/DBDashboardImportPage.test.tsx index 3003cbc402..df45aac60c 100644 --- a/packages/app/src/__tests__/DBDashboardImportPage.test.tsx +++ b/packages/app/src/__tests__/DBDashboardImportPage.test.tsx @@ -720,3 +720,67 @@ describe('Dashboard import - data source variables', () => { expect(payload.filters[2].sources).toEqual(['$logs', 'src-traces']); }); }); + +describe('Dashboard import - connection variables', () => { + const connectionVariableTemplate = DashboardTemplateSchema.parse({ + version: '0.1.0', + name: 'Connection Variables', + tiles: [ + { + id: 'tile-rawsql', + x: 0, + y: 0, + w: 6, + h: 3, + config: { + name: 'tile-rawsql', + displayType: 'table', + configType: 'sql', + connection: '$conn', + sqlTemplate: 'SELECT 1', + }, + }, + ], + filters: [ + { + id: 'conn', + type: 'CONNECTION', + name: 'Cluster', + variableName: 'conn', + minSelections: 1, + maxSelections: 1, + isBroadcastEnabled: false, + isVariableEnabled: true, + }, + { + id: 'ds', + type: 'DATA_SOURCE', + name: 'Logs source', + variableName: 'logs', + allowedSourceKinds: [SourceKind.Log], + connection: 'Default', + minSelections: 1, + maxSelections: 1, + isBroadcastEnabled: false, + isVariableEnabled: true, + }, + ], + }); + + it('keeps connection references and maps a data source variable connection', async () => { + renderWithMantine(); + + const finish = await screen.findByRole('button', { + name: /finish import/i, + }); + expect(screen.queryByText('$conn')).not.toBeInTheDocument(); + await act(async () => { + fireEvent.click(finish); + }); + + await waitFor(() => expect(mockMutateAsync).toHaveBeenCalledTimes(1)); + const payload = mockMutateAsync.mock.calls[0][0]; + expect(payload.tiles[0].config.connection).toBe('$conn'); + expect(payload.filters[1].connection).toBe('conn-default'); + }); +}); diff --git a/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx b/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx index 5019732ec9..5b2ec63541 100644 --- a/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx +++ b/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx @@ -9,6 +9,7 @@ import { validateRawSqlChartConfig, validateRawSqlForAlert, } from '@hyperdx/common-utils/dist/core/utils'; +import { parseVariableReference } from '@hyperdx/common-utils/dist/referenceVariables'; import { ChartVariable, RawSqlChartConfig, @@ -148,9 +149,10 @@ export default function RawSqlChartEditor({ const { data: sources } = useSources(); const displayType = useWatch({ control, name: 'displayType' }); - const connection = useWatch({ control, name: 'connection' }); - // A data source variable reference syncs and expands macros as the source - // it currently selects. + // Source and connection variable references sync, autocomplete and expand + // macros as the source and connection they currently select. + const storedConnection = useWatch({ control, name: 'connection' }); + const connection = useResolvedReference(storedConnection); const source = useResolvedReference(useWatch({ control, name: 'source' })); const sqlTemplate = useWatch({ control, name: 'sqlTemplate' }); const chartName = useWatch({ control, name: 'name' }); @@ -210,12 +212,25 @@ export default function RawSqlChartEditor({ prevConnection, sources, }); - if (update?.field === 'connection') { + // A connection variable stays as picked; the tile shows a mismatch + // instead of having its reference replaced + if ( + update?.field === 'connection' && + parseVariableReference(storedConnection) == null + ) { setValue('connection', update.value); } else if (update?.field === 'source') { setValue('source', update.value); } - }, [connection, prevConnection, prevSource, setValue, source, sources]); + }, [ + connection, + storedConnection, + prevConnection, + prevSource, + setValue, + source, + sources, + ]); const placeholderSQl = SQL_PLACEHOLDERS[displayType ?? DisplayType.Table]; @@ -277,6 +292,8 @@ export default function RawSqlChartEditor({ control={control} name="connection" size="xs" + allowConnectionVariables + data-testid="connection-selector" /> @@ -298,6 +315,7 @@ export default function RawSqlChartEditor({ allowedSourceKinds={allowedSourceKinds} allowSourceVariables connectionId={connection} + variableConnection={storedConnection} size="xs" clearable placeholder="None" diff --git a/packages/app/src/components/ConnectionSelect.tsx b/packages/app/src/components/ConnectionSelect.tsx index 2422377cfb..926c91e563 100644 --- a/packages/app/src/components/ConnectionSelect.tsx +++ b/packages/app/src/components/ConnectionSelect.tsx @@ -1,35 +1,51 @@ -import { useMemo } from 'react'; -import { UseControllerProps } from 'react-hook-form'; +import { UseControllerProps, useWatch } from 'react-hook-form'; +import { parseVariableReference } from '@hyperdx/common-utils/dist/referenceVariables'; +import { SelectProps } from '@mantine/core'; import { IconServer } from '@tabler/icons-react'; import SelectControlled from '@/components/SelectControlled'; -import { useConnections } from '@/connection'; +import { + useWithConnectionVariableItems, + VARIABLE_ICON, +} from '@/components/sourceSelectUtils'; +import { useConnectionItems } from '@/hooks/useConnectionItems'; export function ConnectionSelectControlled({ size, + allowConnectionVariables = false, + placeholder = 'Connection', + clearable = false, ...props -}: { size?: string } & UseControllerProps) { - const { data } = useConnections(); - - const values = useMemo( - () => - data?.map(d => ({ - value: d.id, - label: d.name, - })), - [data], +}: { + size?: string; + /** Whether to offer the dashboard's connection variables. */ + allowConnectionVariables?: boolean; +} & Pick & + UseControllerProps) { + const selected = useWatch({ control: props.control, name: props.name }); + const connectionItems = useConnectionItems(); + const items = useWithConnectionVariableItems( + connectionItems, + allowConnectionVariables, ); return ( } + placeholder={placeholder} + leftSection={ + parseVariableReference(selected) != null ? ( + VARIABLE_ICON + ) : ( + + ) + } maxDropdownHeight={280} size={size} /> diff --git a/packages/app/src/components/DashboardFiltersModal/DashboardFilterEditForm.tsx b/packages/app/src/components/DashboardFiltersModal/DashboardFilterEditForm.tsx index df85623589..0d4fd1035d 100644 --- a/packages/app/src/components/DashboardFiltersModal/DashboardFilterEditForm.tsx +++ b/packages/app/src/components/DashboardFiltersModal/DashboardFilterEditForm.tsx @@ -43,6 +43,7 @@ import { import { PromqlLabelFilterEditForm } from './PromqlLabelFilterEditForm'; import { QueryExpressionFilterEditForm } from './QueryExpressionFilterEditForm'; import { StaticListFilterEditForm } from './StaticListFilterEditForm'; +import { VariableNameInput } from './VariableNameInput'; const FILTER_TYPE_OPTIONS = [ { @@ -67,6 +68,7 @@ const FILTER_TYPE_OPTIONS = [ ] : []), { value: DashboardFilterType.enum.DATA_SOURCE, label: 'Data source' }, + { value: DashboardFilterType.enum.CONNECTION, label: 'Connection' }, ]; interface DashboardFilterEditFormProps { @@ -245,7 +247,12 @@ export const DashboardFilterEditForm = ({ message="Failed to load filter" showErrorMessage > - {formFilterType === 'DATA_SOURCE' ? ( + {formFilterType === 'CONNECTION' ? ( + + ) : formFilterType === 'DATA_SOURCE' ? ( + + + + {hasMixedKinds && ( ({ + useConnections: () => ({ data: [{ id: 'conn-1', name: 'Primary' }] }), +})); + function Harness({ allowedSourceKinds }: { allowedSourceKinds: SourceKind[] }) { const { control } = useForm({ defaultValues: { diff --git a/packages/app/src/components/ReferenceVariableDashboardFilter.tsx b/packages/app/src/components/ReferenceVariableDashboardFilter.tsx index 173ddf309c..fa7a6ecccf 100644 --- a/packages/app/src/components/ReferenceVariableDashboardFilter.tsx +++ b/packages/app/src/components/ReferenceVariableDashboardFilter.tsx @@ -1,7 +1,10 @@ import { useCallback, useMemo } from 'react'; -import { DataSourceDashboardFilter as DataSourceFilter } from '@hyperdx/common-utils/dist/types'; +import { + ConnectionDashboardFilter as ConnectionFilter, + DataSourceDashboardFilter as DataSourceFilter, +} from '@hyperdx/common-utils/dist/types'; import { ComboboxItem, Group, Select, Stack } from '@mantine/core'; -import { IconStack } from '@tabler/icons-react'; +import { IconServer, IconStack } from '@tabler/icons-react'; import { getSourceOptionIcon, @@ -10,6 +13,7 @@ import { useFilteredSortedSourceItems, useSourceKindMap, } from '@/components/sourceSelectUtils'; +import { useConnectionItems } from '@/hooks/useConnectionItems'; import { useSources } from '@/source'; import { @@ -27,8 +31,8 @@ type ReferenceVariableFilterProps = { }; /** - * The filter bar control for a reference variable: a single picker over the - * IDs the variable can select. + * The filter bar control shared by data source and connection variables: a + * single picker over the IDs the variable can select. */ function ReferenceVariableSelect({ name, @@ -143,3 +147,23 @@ export const DataSourceDashboardFilter = ({ /> ); }; + +const renderConnectionIcon = () => ; + +/** A connection variable's picker. */ +export const ConnectionDashboardFilter = ({ + filter, + ...props +}: ReferenceVariableFilterProps & { filter: ConnectionFilter }) => { + const items = useConnectionItems({ sorted: true }); + + return ( + + ); +}; diff --git a/packages/app/src/components/SourceSelect.tsx b/packages/app/src/components/SourceSelect.tsx index 2dd72a808f..845b7e1cfb 100644 --- a/packages/app/src/components/SourceSelect.tsx +++ b/packages/app/src/components/SourceSelect.tsx @@ -163,6 +163,7 @@ function SourceSelectControlledComponent({ connectionId, isSourceAllowed, allowSourceVariables = false, + variableConnection, comboboxProps, ...props }: { @@ -178,6 +179,11 @@ function SourceSelectControlledComponent({ isSourceAllowed?: (source: TSource) => boolean; /** Whether to offer the dashboard's data source variables. */ allowSourceVariables?: boolean; + /** + * The connection data source variables must be limited to, as stored (it + * may be a connection variable reference). Defaults to `connectionId`. + */ + variableConnection?: string; } & UseControllerProps & SelectProps) { const { data } = useSources(); @@ -221,6 +227,7 @@ function SourceSelectControlledComponent({ const items = useWithSourceVariableItems(sourceItems, { enabled: allowSourceVariables, allowedSourceKinds, + connection: variableConnection ?? connectionId, }); const hasSelection = !!selectedSourceId; diff --git a/packages/app/src/components/__tests__/ReferenceVariableDashboardFilter.test.tsx b/packages/app/src/components/__tests__/ReferenceVariableDashboardFilter.test.tsx index 10ab344641..b09fe5a5ba 100644 --- a/packages/app/src/components/__tests__/ReferenceVariableDashboardFilter.test.tsx +++ b/packages/app/src/components/__tests__/ReferenceVariableDashboardFilter.test.tsx @@ -5,11 +5,22 @@ import { import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { DataSourceDashboardFilter } from '@/components/ReferenceVariableDashboardFilter'; +import { + ConnectionDashboardFilter, + DataSourceDashboardFilter, +} from '@/components/ReferenceVariableDashboardFilter'; jest.mock('@/source', () => ({ useSources: () => ({ data: mockSources }), })); +jest.mock('@/connection', () => ({ + useConnections: () => ({ + data: [ + { id: 'conn-b', name: 'Secondary' }, + { id: 'conn-a', name: 'Primary' }, + ], + }), +})); // Mantine's Combobox calls scrollIntoView when its dropdown opens; jsdom lacks it. window.HTMLElement.prototype.scrollIntoView = jest.fn(); @@ -106,3 +117,33 @@ describe('DataSourceDashboardFilter', () => { ).toBeInTheDocument(); }); }); + +describe('ConnectionDashboardFilter', () => { + it('offers the connections and reports the picked one', async () => { + const onChange = jest.fn(); + renderWithMantine( + , + ); + + await userEvent.click( + screen.getByTestId('dashboard-filter-select-Cluster'), + ); + await userEvent.click(await screen.findByText('Secondary')); + expect(onChange).toHaveBeenCalledWith('conn-b'); + }); +}); diff --git a/packages/app/src/components/__tests__/SourceSelect.test.tsx b/packages/app/src/components/__tests__/SourceSelect.test.tsx index ba3d129db2..eccada7a72 100644 --- a/packages/app/src/components/__tests__/SourceSelect.test.tsx +++ b/packages/app/src/components/__tests__/SourceSelect.test.tsx @@ -322,6 +322,36 @@ describe('SourceSelectControlled (data source variables)', () => { expect(screen.queryByText('$any_source')).not.toBeInTheDocument(); }); + it('offers only variables unlimited or limited to its connection', async () => { + asMock(useSources).mockReturnValue({ data: SOURCES }); + function ConnectionHarness() { + const { control } = useForm(); + return ( + + + + ); + } + renderWithMantine(); + await userEvent.click(screen.getByPlaceholderText('Data Source')); + + expect(await screen.findByText('$any')).toBeInTheDocument(); + expect(screen.getByText('$mine')).toBeInTheDocument(); + expect(screen.queryByText('$other')).not.toBeInTheDocument(); + }); + it('offers no variables unless the picker opts in', async () => { asMock(useSources).mockReturnValue({ data: SOURCES }); renderWithMantine(); diff --git a/packages/app/src/components/sourceSelectUtils.tsx b/packages/app/src/components/sourceSelectUtils.tsx index 36d1606ab5..f2acd591cf 100644 --- a/packages/app/src/components/sourceSelectUtils.tsx +++ b/packages/app/src/components/sourceSelectUtils.tsx @@ -1,5 +1,6 @@ import { createContext, use, useMemo } from 'react'; import { + getConnectionVariableNames, getSourceVariableDeclarations, isSourceVariableAccepted, parseVariableReference, @@ -30,7 +31,7 @@ export const SOURCE_KIND_ICONS: Record = { [SourceKind.Promql]: , }; -const VARIABLE_ICON = ; +export const VARIABLE_ICON = ; export function getSourceOptionIcon( value: string | undefined, @@ -42,16 +43,17 @@ export function getSourceOptionIcon( type ReferenceVariablesContextValue = { sourceVariables: SourceVariableDeclaration[]; + connectionVariables: string[]; values: ReferenceVariableValues; }; const ReferenceVariablesContext = createContext( - { sourceVariables: [], values: new Map() }, + { sourceVariables: [], connectionVariables: [], values: new Map() }, ); /** - * Offers the data source variables `filters` declare to the pickers below it - * that opt in, and their selections to `useResolvedReference`. + * Offers the data source and connection variables `filters` declare to the + * pickers below it that opt in, and their selections to `useResolvedReference`. */ export function ReferenceVariablesProvider({ filters, @@ -65,6 +67,7 @@ export function ReferenceVariablesProvider({ const value = useMemo( () => ({ sourceVariables: getSourceVariableDeclarations(filters), + connectionVariables: getConnectionVariableNames(filters), values, }), [filters, values], @@ -77,8 +80,8 @@ export function ReferenceVariablesProvider({ } /** - * The ID a source field currently queries: an ID as is, or the selection of - * the variable it references. + * The ID a source or connection field currently queries: an ID as is, or the + * selection of the variable it references. */ export function useResolvedReference( value: string | undefined, @@ -103,16 +106,20 @@ const toVariableItem = (name: string): ComboboxItem => { /** * A source picker's items, preceded by a "Variables" group of the data source * variables it can offer. A variable is offered only if every kind it can - * resolve to is allowed, so no selection can produce an unusable source. + * resolve to is allowed, so no selection can produce an unusable source, and, + * for a picker limited to `connection`, only if it is unlimited or limited to + * that same connection (ID or variable reference). */ export function useWithSourceVariableItems( sourceItems: ComboboxItem[] | SourceSelectGroup[], { enabled, allowedSourceKinds, + connection, }: { enabled: boolean; allowedSourceKinds?: SourceKind[]; + connection?: string; }, ): (ComboboxItem | SourceSelectGroup)[] { const { sourceVariables } = use(ReferenceVariablesContext); @@ -121,14 +128,32 @@ export function useWithSourceVariableItems( const items = sourceVariables .filter( variable => - !allowedSourceKinds || - isSourceVariableAccepted(variable, allowedSourceKinds), + (!allowedSourceKinds || + isSourceVariableAccepted(variable, allowedSourceKinds)) && + (!connection || + !variable.connection || + variable.connection === connection), ) .map(variable => toVariableItem(variable.name)); return items.length > 0 ? [{ group: 'Variables', items }, ...sourceItems] : sourceItems; - }, [enabled, sourceVariables, allowedSourceKinds, sourceItems]); + }, [enabled, sourceVariables, allowedSourceKinds, connection, sourceItems]); +} + +/** A connection picker's items, preceded by a "Variables" group of connection variables. */ +export function useWithConnectionVariableItems( + connectionItems: ComboboxItem[], + enabled: boolean, +): (ComboboxItem | SourceSelectGroup)[] { + const { connectionVariables } = use(ReferenceVariablesContext); + return useMemo(() => { + if (!enabled || connectionVariables.length === 0) return connectionItems; + return [ + { group: 'Variables', items: connectionVariables.map(toVariableItem) }, + { group: 'Connections', items: connectionItems }, + ]; + }, [enabled, connectionVariables, connectionItems]); } /** Header for sources that have no section assigned. Internal to this module. */ diff --git a/packages/app/src/hooks/useConnectionItems.ts b/packages/app/src/hooks/useConnectionItems.ts new file mode 100644 index 0000000000..811bb0b16c --- /dev/null +++ b/packages/app/src/hooks/useConnectionItems.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react'; + +import { useConnections } from '@/connection'; + +/** The team's connections as select options, optionally sorted by name. */ +export function useConnectionItems({ sorted = false } = {}) { + const { data } = useConnections(); + return useMemo(() => { + const items = (data ?? []).map(connection => ({ + value: connection.id, + label: connection.name, + })); + return sorted + ? items.sort((a, b) => a.label.localeCompare(b.label)) + : items; + }, [data, sorted]); +} diff --git a/packages/app/tests/e2e/components/ChartEditorComponent.ts b/packages/app/tests/e2e/components/ChartEditorComponent.ts index fb24092b78..25e68e9bd6 100644 --- a/packages/app/tests/e2e/components/ChartEditorComponent.ts +++ b/packages/app/tests/e2e/components/ChartEditorComponent.ts @@ -446,6 +446,19 @@ export class ChartEditorComponent { } } + /** The raw SQL editor's connection picker input. */ + get connection(): Locator { + return this.editorForm().getByTestId('connection-selector'); + } + + /** Pick the raw SQL editor's connection, by option label. */ + async selectConnection(connectionName: string) { + await this.connection.click(); + await this.page + .getByRole('option', { name: connectionName, exact: true }) + .click(); + } + /** Open the Data Source dropdown. */ async openSourcePicker() { await this.sourceSelector.click(); diff --git a/packages/app/tests/e2e/features/dashboard-source-variables.spec.ts b/packages/app/tests/e2e/features/dashboard-source-variables.spec.ts index 5ee0cf0339..79d9d4637f 100644 --- a/packages/app/tests/e2e/features/dashboard-source-variables.spec.ts +++ b/packages/app/tests/e2e/features/dashboard-source-variables.spec.ts @@ -1,12 +1,16 @@ /** - * A `DATA_SOURCE` dashboard filter is a single-select variable whose value is - * a source. Tiles and other filters reference it as `$name` in place of a - * source, block until it has a selection, and cannot carry alerts. + * `DATA_SOURCE` and `CONNECTION` dashboard filters are single-select variables + * whose value is a source or a connection. Tiles and other filters reference + * them as `$name` in place of an ID, block until they have a selection, and + * cannot carry alerts. */ +import { DisplayType } from '@hyperdx/common-utils/dist/types'; + import { DashboardPage } from '../page-objects/DashboardPage'; import { SERVICES } from '../seed-clickhouse'; import { expect, test } from '../utils/base-test'; import { + DEFAULT_CONNECTION_NAME, DEFAULT_LOGS_SOURCE_NAME, DEFAULT_TRACES_SOURCE_NAME, } from '../utils/constants'; @@ -144,5 +148,70 @@ test.describe( ).toContainText('Logs source'); }); }); + + test('drives a raw SQL tile from connection and source variables', async () => { + const editor = dashboardPage.chartEditor; + + await test.step('Create a connection variable and a source variable limited to it', async () => { + await dashboardPage.createNewDashboard(); + await dashboardPage.openEditFiltersModal(); + await dashboardPage.addConnectionFilterToDashboard( + 'Cluster', + 'cluster', + ); + await dashboardPage.addDataSourceFilterToDashboard( + 'Logs source', + ['Logs'], + 'logs', + '$cluster', + ); + await expect( + dashboardPage.getFilterItemByName('Logs source'), + ).toContainText('$cluster'); + await dashboardPage.closeFiltersModal(); + await expect( + dashboardPage.getFilterPendingVariableWarning('Logs source'), + ).toBeVisible(); + }); + + await test.step('A raw SQL tile on the variables cannot have an alert', async () => { + await dashboardPage.addTile(); + await expect(editor.nameInput).toBeVisible(); + await editor.waitForDataToLoad(); + await editor.setChartType(DisplayType.Number); + await editor.setChartName('Variable count'); + await editor.switchToSqlMode(); + await expect(editor.alertButton).toBeVisible(); + await editor.selectConnection('$cluster'); + await editor.selectSource('$logs'); + await expect(editor.alertButton).toHaveCount(0); + await editor.typeSqlQuery('SELECT count() FROM $__sourceTable'); + await editor.save(); + await expect( + dashboardPage.getTileMissingRequiredFilters(0), + ).toContainText('Cluster'); + }); + + await test.step('Selecting a connection, then a source on it, loads the tile', async () => { + await dashboardPage.selectReferenceFilterValue( + 'Cluster', + DEFAULT_CONNECTION_NAME, + ); + await dashboardPage.selectReferenceFilterValue( + 'Logs source', + DEFAULT_LOGS_SOURCE_NAME, + ); + await expect( + dashboardPage.getTileMissingRequiredFilters(0), + ).toHaveCount(0); + await expect(dashboardPage.getNumberTileValue(0)).toBeVisible(); + }); + + await test.step('The editor keeps both references', async () => { + await dashboardPage.editTile(0); + await expect(editor.connection).toHaveValue('$cluster'); + await expect(editor.source).toHaveValue('$logs'); + }); + }); }, ); diff --git a/packages/app/tests/e2e/page-objects/DashboardPage.ts b/packages/app/tests/e2e/page-objects/DashboardPage.ts index b032a50a63..725fdd3429 100644 --- a/packages/app/tests/e2e/page-objects/DashboardPage.ts +++ b/packages/app/tests/e2e/page-objects/DashboardPage.ts @@ -1404,7 +1404,8 @@ export class DashboardPage { | 'Static values' | 'PromQL label values' | 'Ad hoc keys and values' - | 'Data source', + | 'Data source' + | 'Connection', ) { await this.getFilterTypePicker().click(); await this.getFilterOption(label).click(); @@ -1493,6 +1494,7 @@ export class DashboardPage { name: string, kindLabels: string[], variableName: string, + connectionLabel?: string, ) { await this.addFiltersButton.click(); await this.selectFilterType('Data source'); @@ -1504,6 +1506,28 @@ export class DashboardPage { await kindsInput.click(); await this.getFilterOption(label).click(); } + if (connectionLabel !== undefined) { + // Close the kinds dropdown, which covers this input; Escape would close + // the modal instead + await this.getFilterNameInput().click(); + await this.page + .getByTestId('filter-data-source-connection-input') + .click(); + await this.getFilterOption(connectionLabel).click(); + } + await this.variableNameInput.fill(variableName); + await this.page.getByTestId('save-filter-button').click(); + await this.getFilterItemByName(name).waitFor({ + state: 'visible', + timeout: 10000, + }); + } + + /** Add a connection variable and wait for it in the filters list. */ + async addConnectionFilterToDashboard(name: string, variableName: string) { + await this.addFiltersButton.click(); + await this.selectFilterType('Connection'); + await this.getFilterNameInput().fill(name); await this.variableNameInput.fill(variableName); await this.page.getByTestId('save-filter-button').click(); await this.getFilterItemByName(name).waitFor({