Skip to content

Commit 43b09a7

Browse files
authored
feat: Add UI for configuring adhoc dashboard filters (#3341)
1 parent f21bdb8 commit 43b09a7

16 files changed

Lines changed: 729 additions & 24 deletions

File tree

‎.changeset/adhoc-filter-ui.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@hyperdx/common-utils': patch
3+
'@hyperdx/app': patch
4+
---
5+
6+
feat: Add UI for configuring adhoc dashboard filters

‎packages/app/.env.development‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:${HDX_DEV_OTEL_HTTP_PO
1313
# NEXT_PUBLIC_OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=
1414
# NEXT_PUBLIC_OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=
1515
NEXT_PUBLIC_ENABLE_PROMQL=true
16+
NEXT_PUBLIC_ENABLE_ADHOC_FILTERS=true
1617
NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true

‎packages/app/playwright.config.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,8 @@ export default defineConfig({
105105
{
106106
// Full UI: Alerts + Dashboards. Not local mode; Alerts enabled;
107107
command: USE_DEV
108-
? `SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true next dev --webpack`
109-
: `SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true yarn build && SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true yarn start`,
108+
? `SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true NEXT_PUBLIC_ENABLE_ADHOC_FILTERS=true next dev --webpack`
109+
: `SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true NEXT_PUBLIC_ENABLE_ADHOC_FILTERS=true yarn build && SERVER_URL=http://localhost:${API_PORT} PORT=${APP_PORT} NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true NEXT_PUBLIC_ENABLE_PROMQL=true NEXT_PUBLIC_ENABLE_ADHOC_FILTERS=true yarn start`,
110110
port: parseInt(APP_PORT, 10),
111111
reuseExistingServer: !process.env.CI,
112112
timeout: APP_SERVER_STARTUP_TIMEOUT_MS,

‎packages/app/src/DBDashboardPage.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3524,7 +3524,7 @@ function DBDashboardPage({
35243524
<DashboardFiltersModal
35253525
opened={showFiltersModal}
35263526
onClose={() => setShowFiltersModal(false)}
3527-
filters={filters}
3527+
filters={dashboard?.filters ?? []}
35283528
onSaveFilter={handleSaveFilter}
35293529
onRemoveFilter={handleRemoveFilter}
35303530
isLoading={isSavingDashboard || isFetchingDashboard}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
const baseConfig = {
55
APP_VERSION: '9.9.9',
66
BASE_PATH: '',
7+
IS_ADHOC_FILTERS_ENABLED: false,
78
IS_ALERT_DETAILS_ENABLED: false,
89
IS_CLICKHOUSE_BUILD: false,
910
IS_LLM_COST_ENABLED: false,
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { Controller, UseFormSetValue, useWatch } from 'react-hook-form';
2+
import { getAdhocFilterSourceKinds } from '@hyperdx/common-utils/dist/filters';
3+
import {
4+
AdhocFilterSourceType,
5+
DashboardFilter,
6+
SourceKind,
7+
} from '@hyperdx/common-utils/dist/types';
8+
import { Box, Divider, SegmentedControl } from '@mantine/core';
9+
10+
import { CheckBoxControlled } from '@/components/InputControlled';
11+
import { SourceMultiSelectControlled } from '@/components/SourceMultiSelect';
12+
import { IS_PROMQL_ENABLED } from '@/config';
13+
import { useSources } from '@/source';
14+
15+
import { CustomInputWrapper } from './CustomInputWrapper';
16+
import { FilterFormControl, FilterFormValues } from './filterFormState';
17+
import { VariableNameInput } from './VariableNameInput';
18+
19+
const SOURCE_TYPE_OPTIONS: { value: AdhocFilterSourceType; label: string }[] = [
20+
{ value: 'sql', label: 'ClickHouse' },
21+
{ value: 'promql', label: 'Prometheus' },
22+
];
23+
24+
interface AdhocFilterEditFormProps {
25+
control: FilterFormControl;
26+
setValue: UseFormSetValue<FilterFormValues>;
27+
/** Filters other than the one being edited, used to keep variable names unique. */
28+
otherFilters: DashboardFilter[];
29+
}
30+
31+
/** Form describing an ad hoc dashboard filter */
32+
export const AdhocFilterEditForm = ({
33+
control,
34+
setValue,
35+
otherFilters,
36+
}: AdhocFilterEditFormProps) => {
37+
const { data: allSources } = useSources();
38+
const [sourceType, isBroadcastEnabled] = useWatch({
39+
control,
40+
name: ['sourceType', 'isBroadcastEnabled'],
41+
});
42+
const allowedSourceKinds = getAdhocFilterSourceKinds(sourceType);
43+
// Kept visible for a saved PromQL filter so its source type can be changed
44+
const showSourceType =
45+
sourceType === 'promql' ||
46+
(IS_PROMQL_ENABLED &&
47+
!!allSources?.some(s => s.kind === SourceKind.Promql));
48+
49+
// The multi-selects hide sources of other kinds, which the form can still
50+
// hold after switching from another filter type.
51+
const validateSourceKinds = (ids: string[] | undefined) => {
52+
const hasOtherKind = ids?.some(id => {
53+
const source = allSources?.find(s => s.id === id);
54+
return source && !allowedSourceKinds.includes(source.kind);
55+
});
56+
return (
57+
!hasOtherKind ||
58+
(sourceType === 'promql'
59+
? 'Select only PromQL sources'
60+
: 'Select only log, trace, or session sources')
61+
);
62+
};
63+
64+
return (
65+
<>
66+
{showSourceType && (
67+
<CustomInputWrapper
68+
label="Source type"
69+
tooltipText="Keys come from the columns and map keys of SQL sources, or from the labels of PromQL sources"
70+
>
71+
<Controller
72+
control={control}
73+
name="sourceType"
74+
render={({ field: { value, onChange } }) => (
75+
<SegmentedControl
76+
size="xs"
77+
value={value}
78+
onChange={next => {
79+
onChange(next);
80+
// Sources of the previous type can't be kept
81+
setValue('sources', [], { shouldDirty: true });
82+
setValue('appliesToSourceIds', [], { shouldDirty: true });
83+
}}
84+
data={SOURCE_TYPE_OPTIONS}
85+
data-testid="adhoc-filter-source-type"
86+
/>
87+
)}
88+
/>
89+
</CustomInputWrapper>
90+
)}
91+
<CustomInputWrapper
92+
label="Data sources"
93+
tooltipText="The sources whose keys and values the filter offers"
94+
>
95+
<SourceMultiSelectControlled
96+
control={control}
97+
name="sources"
98+
data-testid="adhoc-filter-sources"
99+
comboboxProps={{ withinPortal: true }}
100+
placeholder="Select sources"
101+
allowedSourceKinds={allowedSourceKinds}
102+
rules={{
103+
validate: (value: string[] | undefined) =>
104+
!value?.length
105+
? 'Select at least one source'
106+
: validateSourceKinds(value),
107+
}}
108+
/>
109+
</CustomInputWrapper>
110+
111+
<Divider />
112+
<CheckBoxControlled
113+
control={control}
114+
name="isBroadcastEnabled"
115+
size="xs"
116+
label="Broadcast filter conditions"
117+
description={
118+
sourceType === 'promql'
119+
? 'Automatically add the selected conditions as label matchers to every selector in PromQL tiles. Optionally, specify which sources to broadcast to.'
120+
: 'Automatically apply the selected conditions to every tile based on the selected data sources. Optionally, specify which sources to broadcast to.'
121+
}
122+
data-testid="filter-broadcast-checkbox"
123+
/>
124+
{isBroadcastEnabled && (
125+
<Box ml="xl">
126+
<CustomInputWrapper
127+
label="Applies to sources"
128+
tooltipText="Which tiles the broadcast reaches. Leave empty to broadcast to all tiles using the filter's data sources."
129+
>
130+
<SourceMultiSelectControlled
131+
control={control}
132+
name="appliesToSourceIds"
133+
data-testid="applies-to-source-selector"
134+
comboboxProps={{ withinPortal: true }}
135+
placeholder="Same as data sources"
136+
allowedSourceKinds={allowedSourceKinds}
137+
rules={{ validate: validateSourceKinds }}
138+
/>
139+
</CustomInputWrapper>
140+
</Box>
141+
)}
142+
<Divider />
143+
<VariableNameInput control={control} otherFilters={otherFilters} />
144+
</>
145+
);
146+
};

‎packages/app/src/components/DashboardFiltersModal/DashboardFilterEditForm.tsx‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@ import { ErrorBoundary } from '@/components/Error/ErrorBoundary';
2626
import { CheckBoxControlled } from '@/components/InputControlled';
2727
import SelectControlled from '@/components/SelectControlled';
2828
import { SqlVariablesProvider } from '@/components/SQLEditor/variableCompletions';
29-
import { IS_PROMQL_ENABLED } from '@/config';
29+
import { IS_ADHOC_FILTERS_ENABLED, IS_PROMQL_ENABLED } from '@/config';
3030
import { useConfirm } from '@/useConfirm';
3131
import { useZIndex } from '@/zIndex';
3232

33+
import { AdhocFilterEditForm } from './AdhocFilterEditForm';
3334
import { MODAL_SIZE } from './constants';
3435
import { CustomInputWrapper } from './CustomInputWrapper';
3536
import {
@@ -55,6 +56,14 @@ const FILTER_TYPE_OPTIONS = [
5556
},
5657
]
5758
: []),
59+
...(IS_ADHOC_FILTERS_ENABLED
60+
? [
61+
{
62+
value: DashboardFilterType.enum.ADHOC,
63+
label: 'Ad hoc keys and values',
64+
},
65+
]
66+
: []),
5867
];
5968

6069
interface DashboardFilterEditFormProps {
@@ -232,6 +241,12 @@ export const DashboardFilterEditForm = ({
232241
control={control}
233242
otherFilters={otherFilters}
234243
/>
244+
) : formFilterType === 'ADHOC' ? (
245+
<AdhocFilterEditForm
246+
control={control}
247+
setValue={setValue}
248+
otherFilters={otherFilters}
249+
/>
235250
) : formFilterType === 'PROMETHEUS_LABEL' ? (
236251
<SqlVariablesProvider variables={otherVariables}>
237252
<PromqlLabelFilterEditForm
@@ -252,7 +267,7 @@ export const DashboardFilterEditForm = ({
252267
)}
253268
</ErrorBoundary>
254269

255-
{showRequiredFilterOptions && (
270+
{showRequiredFilterOptions && formFilterType !== 'ADHOC' && (
256271
<Stack gap="xs">
257272
<Divider my="xs" />
258273
<CheckBoxControlled

‎packages/app/src/components/DashboardFiltersModal/DashboardFiltersList.tsx‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import {
2+
getAdhocFilterAppliesToSourceIds,
23
getFilterBroadcastTarget,
34
getFilterVariableName,
5+
isAdhocFilter,
6+
isFilterBroadcastEnabled,
47
isFilterGlobalRequirement,
58
isFilterRequired,
69
isFilterVariableEnabled,
@@ -17,6 +20,7 @@ import {
1720
import {
1821
IconAsterisk,
1922
IconBuildingBroadcastTower,
23+
IconFilter,
2024
IconLabel,
2125
IconList,
2226
IconPencil,
@@ -47,6 +51,16 @@ interface DashboardFiltersListProps {
4751
onAddNew: () => void;
4852
}
4953

54+
function getSourceNames(
55+
ids: string[],
56+
sources?: { id: string; name: string }[],
57+
) {
58+
return ids
59+
.map(id => sources?.find(s => s.id === id)?.name)
60+
.filter((name): name is string => !!name)
61+
.join(', ');
62+
}
63+
5064
/** Where the dropdown's values come from: the queried source, or the authored list. */
5165
function getValuesAttribute(
5266
filter: DashboardFilter,
@@ -74,9 +88,12 @@ function getValuesAttribute(
7488
label: `${count} custom option${count === 1 ? '' : 's'}`,
7589
};
7690
}
77-
// Not shown in the dashboard UI yet
7891
case 'ADHOC':
79-
return { icon: <IconSearch size={14} />, label: '' };
92+
return {
93+
icon: <IconFilter size={14} />,
94+
tooltip: 'Sources the condition keys and values come from',
95+
label: getSourceNames(filter.sources, sources),
96+
};
8097
default:
8198
filter satisfies never; // exhaustive check
8299
return { icon: <IconSearch size={14} />, label: '' };
@@ -87,18 +104,20 @@ function getBroadcastTargetDisplay(
87104
filter: DashboardFilter,
88105
sources?: { id: string; name: string }[],
89106
) {
107+
// An ad hoc filter broadcasts to its own sources unless narrowed further
108+
if (isAdhocFilter(filter)) {
109+
if (!isFilterBroadcastEnabled(filter)) return undefined;
110+
return getSourceNames(getAdhocFilterAppliesToSourceIds(filter), sources);
111+
}
112+
90113
const broadcastTarget = getFilterBroadcastTarget(filter);
91114
if (!broadcastTarget) return undefined;
92115

93116
if (!broadcastTarget?.appliesToSourceIds?.length) {
94117
return 'All sources';
95118
}
96119

97-
const appliedSourceNames = broadcastTarget.appliesToSourceIds
98-
.map(id => sources?.find(s => s.id === id)?.name)
99-
.filter((name): name is string => !!name);
100-
101-
return appliedSourceNames.join(', ');
120+
return getSourceNames(broadcastTarget.appliesToSourceIds, sources);
102121
}
103122

104123
export const DashboardFiltersList = ({

0 commit comments

Comments
 (0)