Skip to content

Commit 68d2ed2

Browse files
authored
feat: Support dependent variable value queries (#2923)
1 parent 40ec085 commit 68d2ed2

16 files changed

Lines changed: 1427 additions & 84 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@hyperdx/app': patch
3+
'@hyperdx/common-utils': patch
4+
---
5+
6+
feat: Support dependent variable value queries

‎packages/app/src/DBDashboardPage.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3178,6 +3178,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
31783178
filterValues={filterValues}
31793179
onSetFilterValue={setFilterValue}
31803180
dateRange={searchedTimeRange}
3181+
variables={showFilterVariableOptions ? variables : undefined}
31813182
/>
31823183
)}
31833184
{/* Selection indicator */}
@@ -3354,6 +3355,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
33543355
onRemoveFilter={handleRemoveFilter}
33553356
isLoading={isSavingDashboard || isFetchingDashboard}
33563357
showVariableOptions={showFilterVariableOptions}
3358+
variables={showFilterVariableOptions ? variables : undefined}
33573359
/>
33583360
)}
33593361
</>

‎packages/app/src/DashboardFilters.tsx‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ import { useMemo, useState } from 'react';
22
import {
33
FilterState,
44
getFilterVariableName,
5+
getPendingFilterValuesVariables,
56
isFilterBroadcastEnabled,
67
isFilterVariableEnabled,
78
} from '@hyperdx/common-utils/dist/filters';
8-
import { DashboardFilter } from '@hyperdx/common-utils/dist/types';
9+
import {
10+
ChartVariable,
11+
DashboardFilter,
12+
} from '@hyperdx/common-utils/dist/types';
913
import { Group, Stack, Text, Tooltip } from '@mantine/core';
1014
import { IconAlertTriangle, IconHelp, IconRefresh } from '@tabler/icons-react';
1115

@@ -20,8 +24,29 @@ interface DashboardFilterSelectProps {
2024
values?: string[];
2125
isLoading?: boolean;
2226
isError?: boolean;
27+
/** Shown instead of the generic message when the query's failure is known. */
28+
errorMessage?: string;
29+
/**
30+
* Variables this filter's dropdown query needs a selection for before it can
31+
* match any rows.
32+
*/
33+
pendingVariables?: string[];
2334
}
2435

36+
/**
37+
* Explain a dropdown query that may list nothing until one of the variables it
38+
* depends on is selected. Only the bare SQL reference form gets here — the
39+
* macros and Lucene both have an empty state that lists every value.
40+
*/
41+
export const getPendingVariablesTooltip = (
42+
pendingVariables: string[],
43+
): string => {
44+
const names = pendingVariables.map(name => `$${name}`).join(', ');
45+
return `Filter depends on ${names}, which ${
46+
pendingVariables.length === 1 ? 'has' : 'have'
47+
} no selected value.`;
48+
};
49+
2550
/**
2651
* Describe what a filter does with the value you pick: broadcast it as a
2752
* condition, expose it as a variable, both, or neither.
@@ -66,6 +91,8 @@ const DashboardFilterSelect = ({
6691
values,
6792
isLoading,
6893
isError,
94+
errorMessage,
95+
pendingVariables,
6996
}: DashboardFilterSelectProps) => {
7097
const valuesOrEmptyMemo = useMemo(() => values ?? [], [values]);
7198
const effect = getFilterEffect(filter);
@@ -91,10 +118,29 @@ const DashboardFilterSelect = ({
91118
/>
92119
)}
93120
</Tooltip>
121+
{!!pendingVariables?.length && (
122+
<Tooltip
123+
label={getPendingVariablesTooltip(pendingVariables)}
124+
withinPortal
125+
multiline
126+
maw={400}
127+
>
128+
<IconAlertTriangle
129+
size={12}
130+
color="var(--color-text-warning)"
131+
data-testid={`dashboard-filter-pending-variable-${filter.name}`}
132+
/>
133+
</Tooltip>
134+
)}
94135
{isError && (
95136
<Tooltip
96-
label="Filter values query failed. The filter's query may be invalid."
137+
label={
138+
errorMessage ??
139+
"Filter values query failed. The filter's query may be invalid."
140+
}
97141
withinPortal
142+
multiline
143+
maw={400}
98144
>
99145
<IconAlertTriangle
100146
size={12}
@@ -126,13 +172,20 @@ interface DashboardFilterProps {
126172
filterValues: FilterState;
127173
onSetFilterValue: (expression: string, values: string[]) => void;
128174
dateRange: [Date, Date];
175+
/**
176+
* The dashboard's variables and their current selections. Defined only when
177+
* filters on this dashboard can be exposed as variables at all, so it doubles
178+
* as the gate for variable-specific copy.
179+
*/
180+
variables?: ChartVariable[];
129181
}
130182

131183
const DashboardFilters = ({
132184
filters,
133185
dateRange,
134186
filterValues,
135187
onSetFilterValue,
188+
variables,
136189
}: DashboardFilterProps) => {
137190
// "Link" mode (opt-in, off by default): each dropdown's values are narrowed by
138191
// the others' selections. Off by default because contingent value lookups
@@ -143,10 +196,12 @@ const DashboardFilters = ({
143196
const {
144197
data: filterValuesById,
145198
erroredFilterIds,
199+
filterErrorMessages,
146200
isFetching,
147201
} = useDashboardFilterValues({
148202
filters,
149203
dateRange,
204+
variables,
150205
// Only narrow by sibling selections when linked.
151206
filterValues: linked ? filterValues : {},
152207
});
@@ -171,6 +226,11 @@ const DashboardFilters = ({
171226
filter={filter}
172227
isLoading={isLoadingValues}
173228
isError={erroredFilterIds?.has(filter.id) ?? false}
229+
errorMessage={filterErrorMessages?.get(filter.id)}
230+
pendingVariables={getPendingFilterValuesVariables(
231+
filter,
232+
variables,
233+
)}
174234
onChange={values => onSetFilterValue(filter.expression, values)}
175235
values={queriedFilterValues?.values}
176236
value={selectedValues}
@@ -186,6 +246,7 @@ const DashboardFilters = ({
186246
<FilterLinkToggle
187247
linked={linked}
188248
onChange={setLinked}
249+
showVariableNote={variables !== undefined}
189250
data-testid="dashboard-filters-link-toggle"
190251
/>
191252
</Stack>

‎packages/app/src/DashboardFiltersModal.tsx‎

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import {
1010
validateVariableName,
1111
} from '@hyperdx/common-utils/dist/filters';
1212
import {
13+
ChartVariable,
1314
DashboardFilter,
1415
MetricsDataType,
1516
SourceKind,
1617
TSource,
1718
} from '@hyperdx/common-utils/dist/types';
1819
import {
20+
Alert,
1921
Box,
2022
Button,
2123
Center,
@@ -33,6 +35,7 @@ import {
3335
UnstyledButton,
3436
} from '@mantine/core';
3537
import {
38+
IconAlertTriangle,
3639
IconFilter,
3740
IconInfoCircle,
3841
IconPencil,
@@ -46,6 +49,7 @@ import SearchWhereInput, {
4649
getStoredLanguage,
4750
} from '@/components/SearchInput/SearchWhereInput';
4851
import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor';
52+
import { SqlVariablesProvider } from '@/components/SQLEditor/variableCompletions';
4953

5054
import { SourceMultiSelectControlled } from './components/SourceMultiSelect';
5155
import SourceSchemaPreview, {
@@ -158,11 +162,30 @@ const DashboardFilterEditForm = ({
158162
[filters, filter.id],
159163
);
160164

161-
const [formFilterName, formIsBroadCastEnabled, formIsVariableEnabled] =
162-
useWatch({
163-
control,
164-
name: ['name', 'isBroadcastEnabled', 'isVariableEnabled'],
165-
});
165+
const [
166+
formFilterName,
167+
formIsBroadCastEnabled,
168+
formIsVariableEnabled,
169+
formAppliesToSourceIds,
170+
] = useWatch({
171+
control,
172+
name: [
173+
'name',
174+
'isBroadcastEnabled',
175+
'isVariableEnabled',
176+
'appliesToSourceIds',
177+
],
178+
});
179+
180+
// Both modes on with an unrestricted broadcast is almost always a mistake:
181+
// broadcast already reaches every tile, so the variable adds nothing and the
182+
// tiles that reference it get filtered twice over. Scoping the broadcast is
183+
// what makes the pair meaningful, so nudge toward "Applies to sources".
184+
const showUnscopedBroadcastWarning =
185+
showVariableOptions &&
186+
formIsBroadCastEnabled !== false &&
187+
formIsVariableEnabled === true &&
188+
!formAppliesToSourceIds?.some(id => !!id?.length);
166189

167190
const derivedVariableName = deriveVariableName(formFilterName ?? '');
168191

@@ -359,7 +382,7 @@ const DashboardFilterEditForm = ({
359382

360383
<CustomInputWrapper
361384
label="Dropdown values filter"
362-
tooltipText="Optional condition used to filter the rows from which available filter values are queried"
385+
tooltipText="Optional condition used to filter the rows from which available filter values are queried. May reference the dashboard's other variables."
363386
>
364387
<SearchWhereInput
365388
tableConnection={tableConnection}
@@ -371,6 +394,7 @@ const DashboardFilterEditForm = ({
371394
allowMultiline={true}
372395
sqlPlaceholder="Filter for dropdown values"
373396
lucenePlaceholder="Filter for dropdown values"
397+
enableVariables={showVariableOptions}
374398
/>
375399
</CustomInputWrapper>
376400

@@ -382,7 +406,7 @@ const DashboardFilterEditForm = ({
382406
name="isBroadcastEnabled"
383407
size="xs"
384408
label="Broadcast filter condition"
385-
description="Apply the selected value as a filter condition on each tile."
409+
description="Automatically apply the selected value to every query builder tile, and to every Raw SQL tile that uses the $__filters macro. Optionally, narrow to tiles that use specific sources."
386410
data-testid="filter-broadcast-checkbox"
387411
/>
388412
</>
@@ -396,7 +420,7 @@ const DashboardFilterEditForm = ({
396420
<Box ml={showVariableOptions ? 'xl' : undefined}>
397421
<CustomInputWrapper
398422
label="Applies to sources"
399-
tooltipText="Leave empty to apply to all tiles. Selecting one or more sources restricts the filter to only tiles using those sources."
423+
tooltipText="Which tiles the broadcast reaches. Leave empty to broadcast to all tiles. Selecting one or more sources restricts the broadcast to tiles using those sources."
400424
>
401425
<SourceMultiSelectControlled
402426
control={control}
@@ -422,7 +446,7 @@ const DashboardFilterEditForm = ({
422446
name="isVariableEnabled"
423447
size="xs"
424448
label="Available as variable"
425-
description="Expose the selected value a $variable."
449+
description="Expose the selected value as a $variable. Selections only affect tiles that reference the variable explicitly, typically via the $__filter or $__conditionalAll macros."
426450
data-testid="filter-variable-enabled-checkbox"
427451
rules={{ validate: validateFilterModes }}
428452
/>
@@ -444,6 +468,19 @@ const DashboardFilterEditForm = ({
444468
</CustomInputWrapper>
445469
</Box>
446470
)}
471+
{showUnscopedBroadcastWarning && (
472+
<Alert
473+
variant="warning"
474+
icon={<IconAlertTriangle size={16} />}
475+
data-testid="filter-unscoped-broadcast-warning"
476+
>
477+
Broadcast already applies this filter to every tile,
478+
including the ones that reference the variable. Set “Applies
479+
to sources” to limit which tiles the broadcast reaches, or
480+
turn off broadcast so only tiles that reference the variable
481+
are filtered.
482+
</Alert>
483+
)}
447484
</>
448485
)}
449486

@@ -652,6 +689,8 @@ interface DashboardFiltersEditModalProps {
652689
source?: TSource;
653690
/** Whether to offer the broadcast / variable controls. */
654691
showVariableOptions: boolean;
692+
/** The dashboard's variables, if any */
693+
variables?: ChartVariable[];
655694
onClose: () => void;
656695
onSaveFilter: (filter: DashboardFilter) => void;
657696
onRemoveFilter: (id: string) => void;
@@ -665,6 +704,7 @@ const DashboardFiltersModal = ({
665704
isLoading,
666705
source,
667706
showVariableOptions,
707+
variables,
668708
onClose,
669709
onSaveFilter,
670710
onRemoveFilter,
@@ -694,10 +734,7 @@ const DashboardFiltersModal = ({
694734
where: '',
695735
whereLanguage: getStoredLanguage() ?? 'sql',
696736
isBroadcastEnabled: true,
697-
// New filters are variable-enabled wherever the controls are available, so
698-
// the checkbox is controlled from the first render. Callers that hide the
699-
// controls get false, since their UI offers no way to turn it back off.
700-
isVariableEnabled: showVariableOptions,
737+
isVariableEnabled: false,
701738
});
702739
};
703740

@@ -719,16 +756,18 @@ const DashboardFiltersModal = ({
719756
return <EmptyState onCreateFilter={handleAddNewFilter} onClose={onClose} />;
720757
} else if (selectedFilter) {
721758
return (
722-
<DashboardFilterEditForm
723-
filter={selectedFilter}
724-
onSave={handleSaveFilter}
725-
onCancel={() => setSelectedFilter(undefined)}
726-
onClose={onClose}
727-
isNew={selectedFilter.id === NEW_FILTER_ID}
728-
source={source}
729-
filters={filters}
730-
showVariableOptions={showVariableOptions}
731-
/>
759+
<SqlVariablesProvider variables={variables}>
760+
<DashboardFilterEditForm
761+
filter={selectedFilter}
762+
onSave={handleSaveFilter}
763+
onCancel={() => setSelectedFilter(undefined)}
764+
onClose={onClose}
765+
isNew={selectedFilter.id === NEW_FILTER_ID}
766+
source={source}
767+
filters={filters}
768+
showVariableOptions={showVariableOptions}
769+
/>
770+
</SqlVariablesProvider>
732771
);
733772
} else {
734773
return (

‎packages/app/src/__tests__/DashboardFilters.test.ts‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import { DashboardFilter } from '@hyperdx/common-utils/dist/types';
22

3-
import { getFilterEffect } from '@/DashboardFilters';
3+
import {
4+
getFilterEffect,
5+
getPendingVariablesTooltip,
6+
} from '@/DashboardFilters';
47

5-
describe('getFilterEffect', () => {
6-
const baseFilter: DashboardFilter = {
7-
id: 'filter1',
8-
type: 'QUERY_EXPRESSION',
9-
name: 'Service Name',
10-
expression: 'ServiceName',
11-
source: 'logs',
12-
};
8+
const baseFilter: DashboardFilter = {
9+
id: 'filter1',
10+
type: 'QUERY_EXPRESSION',
11+
name: 'Service Name',
12+
expression: 'ServiceName',
13+
source: 'logs',
14+
};
1315

16+
describe('getFilterEffect', () => {
1417
it('describes both effects when broadcast and variable are on', () => {
1518
expect(
1619
getFilterEffect({
@@ -97,3 +100,17 @@ describe('getFilterEffect', () => {
97100
).toEqual('Available as variable ($svc)');
98101
});
99102
});
103+
104+
describe('getPendingVariablesTooltip', () => {
105+
it('names the variable that has no selected value', () => {
106+
expect(getPendingVariablesTooltip(['svc'])).toBe(
107+
'Filter depends on $svc, which has no selected value.',
108+
);
109+
});
110+
111+
it('agrees with more than one pending variable', () => {
112+
expect(getPendingVariablesTooltip(['svc', 'env'])).toContain(
113+
'$svc, $env, which have no selected value',
114+
);
115+
});
116+
});

0 commit comments

Comments
 (0)