diff --git a/.changeset/source-vars-5.md b/.changeset/source-vars-5.md
new file mode 100644
index 000000000..0704548e1
--- /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 eff35446b..bbbf60d07 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 1ef6ffdbf..2edf22862 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 3003cbc40..df45aac60 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 5019732ec..5b2ec6354 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 2422377cf..926c91e56 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 df8562358..0d4fd1035 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 173ddf309..fa7a6eccc 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 2dd72a808..845b7e1cf 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 10ab34464..b09fe5a5b 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 ba3d129db..eccada7a7 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 36d1606ab..f2acd591c 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 000000000..811bb0b16
--- /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 fb24092b7..25e68e9bd 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 5ee0cf033..79d9d4637 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 b032a50a6..725fdd342 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({