Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/adhoc-filter-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@hyperdx/common-utils': patch
'@hyperdx/app': patch
---

feat: Add UI for configuring adhoc dashboard filters
1 change: 1 addition & 0 deletions packages/app/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:${HDX_DEV_OTEL_HTTP_PO
# NEXT_PUBLIC_OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=
# NEXT_PUBLIC_OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=
NEXT_PUBLIC_ENABLE_PROMQL=true
NEXT_PUBLIC_ENABLE_ADHOC_FILTERS=true
NEXT_PUBLIC_ENABLE_ALERT_DETAILS=true
4 changes: 2 additions & 2 deletions packages/app/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ export default defineConfig({
{
// Full UI: Alerts + Dashboards. Not local mode; Alerts enabled;
command: USE_DEV
? `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`
: `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`,
? `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`
: `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`,
port: parseInt(APP_PORT, 10),
reuseExistingServer: !process.env.CI,
timeout: APP_SERVER_STARTUP_TIMEOUT_MS,
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/DBDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3524,7 +3524,7 @@ function DBDashboardPage({
<DashboardFiltersModal
opened={showFiltersModal}
onClose={() => setShowFiltersModal(false)}
filters={filters}
filters={dashboard?.filters ?? []}
Comment thread
pulpdrew marked this conversation as resolved.
Resolved
Comment thread
pulpdrew marked this conversation as resolved.
Resolved
Comment thread
pulpdrew marked this conversation as resolved.
Resolved
onSaveFilter={handleSaveFilter}
onRemoveFilter={handleRemoveFilter}
isLoading={isSavingDashboard || isFetchingDashboard}
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/__tests__/hdxDebug.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
const baseConfig = {
APP_VERSION: '9.9.9',
BASE_PATH: '',
IS_ADHOC_FILTERS_ENABLED: false,
IS_ALERT_DETAILS_ENABLED: false,
IS_CLICKHOUSE_BUILD: false,
IS_LLM_COST_ENABLED: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { Controller, UseFormSetValue, useWatch } from 'react-hook-form';
import { getAdhocFilterSourceKinds } from '@hyperdx/common-utils/dist/filters';
import {
AdhocFilterSourceType,
DashboardFilter,
SourceKind,
} from '@hyperdx/common-utils/dist/types';
import { Box, Divider, SegmentedControl } from '@mantine/core';

import { CheckBoxControlled } from '@/components/InputControlled';
import { SourceMultiSelectControlled } from '@/components/SourceMultiSelect';
import { IS_PROMQL_ENABLED } from '@/config';
import { useSources } from '@/source';

import { CustomInputWrapper } from './CustomInputWrapper';
import { FilterFormControl, FilterFormValues } from './filterFormState';
import { VariableNameInput } from './VariableNameInput';

const SOURCE_TYPE_OPTIONS: { value: AdhocFilterSourceType; label: string }[] = [
{ value: 'sql', label: 'ClickHouse' },
{ value: 'promql', label: 'Prometheus' },
];

interface AdhocFilterEditFormProps {
control: FilterFormControl;
setValue: UseFormSetValue<FilterFormValues>;
/** Filters other than the one being edited, used to keep variable names unique. */
otherFilters: DashboardFilter[];
}

/** Form describing an ad hoc dashboard filter */
export const AdhocFilterEditForm = ({
control,
setValue,
otherFilters,
}: AdhocFilterEditFormProps) => {
const { data: allSources } = useSources();
const [sourceType, isBroadcastEnabled] = useWatch({
control,
name: ['sourceType', 'isBroadcastEnabled'],
});
const allowedSourceKinds = getAdhocFilterSourceKinds(sourceType);
// Kept visible for a saved PromQL filter so its source type can be changed
const showSourceType =
sourceType === 'promql' ||
(IS_PROMQL_ENABLED &&
!!allSources?.some(s => s.kind === SourceKind.Promql));

// The multi-selects hide sources of other kinds, which the form can still
// hold after switching from another filter type.
const validateSourceKinds = (ids: string[] | undefined) => {
Comment thread
pulpdrew marked this conversation as resolved.
Resolved
const hasOtherKind = ids?.some(id => {
const source = allSources?.find(s => s.id === id);
return source && !allowedSourceKinds.includes(source.kind);
});
return (
!hasOtherKind ||
(sourceType === 'promql'
? 'Select only PromQL sources'
: 'Select only log, trace, or session sources')
);
};

return (
<>
{showSourceType && (
<CustomInputWrapper
label="Source type"
tooltipText="Keys come from the columns and map keys of SQL sources, or from the labels of PromQL sources"
>
<Controller
control={control}
name="sourceType"
render={({ field: { value, onChange } }) => (
<SegmentedControl
size="xs"
value={value}
onChange={next => {
onChange(next);
// Sources of the previous type can't be kept
setValue('sources', [], { shouldDirty: true });
setValue('appliesToSourceIds', [], { shouldDirty: true });
}}
data={SOURCE_TYPE_OPTIONS}
data-testid="adhoc-filter-source-type"
/>
)}
/>
</CustomInputWrapper>
)}
<CustomInputWrapper
label="Data sources"
tooltipText="The sources whose keys and values the filter offers"
>
<SourceMultiSelectControlled
control={control}
name="sources"
data-testid="adhoc-filter-sources"
comboboxProps={{ withinPortal: true }}
placeholder="Select sources"
allowedSourceKinds={allowedSourceKinds}
rules={{
validate: (value: string[] | undefined) =>
!value?.length
? 'Select at least one source'
: validateSourceKinds(value),
}}
/>
</CustomInputWrapper>

<Divider />
<CheckBoxControlled
control={control}
name="isBroadcastEnabled"
size="xs"
label="Broadcast filter conditions"
description={
sourceType === 'promql'
? 'Automatically add the selected conditions as label matchers to every selector in PromQL tiles. Optionally, specify which sources to broadcast to.'
: 'Automatically apply the selected conditions to every tile based on the selected data sources. Optionally, specify which sources to broadcast to.'
}
data-testid="filter-broadcast-checkbox"
/>
{isBroadcastEnabled && (
<Box ml="xl">
Comment thread
pulpdrew marked this conversation as resolved.
Resolved
<CustomInputWrapper
label="Applies to sources"
tooltipText="Which tiles the broadcast reaches. Leave empty to broadcast to all tiles using the filter's data sources."
>
<SourceMultiSelectControlled
control={control}
name="appliesToSourceIds"
data-testid="applies-to-source-selector"
comboboxProps={{ withinPortal: true }}
placeholder="Same as data sources"
allowedSourceKinds={allowedSourceKinds}
rules={{ validate: validateSourceKinds }}
/>
</CustomInputWrapper>
</Box>
)}
<Divider />
<VariableNameInput control={control} otherFilters={otherFilters} />
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ import { ErrorBoundary } from '@/components/Error/ErrorBoundary';
import { CheckBoxControlled } from '@/components/InputControlled';
import SelectControlled from '@/components/SelectControlled';
import { SqlVariablesProvider } from '@/components/SQLEditor/variableCompletions';
import { IS_PROMQL_ENABLED } from '@/config';
import { IS_ADHOC_FILTERS_ENABLED, IS_PROMQL_ENABLED } from '@/config';
import { useConfirm } from '@/useConfirm';
import { useZIndex } from '@/zIndex';

import { AdhocFilterEditForm } from './AdhocFilterEditForm';
import { MODAL_SIZE } from './constants';
import { CustomInputWrapper } from './CustomInputWrapper';
import {
Expand All @@ -55,6 +56,14 @@ const FILTER_TYPE_OPTIONS = [
},
]
: []),
...(IS_ADHOC_FILTERS_ENABLED
Comment thread
pulpdrew marked this conversation as resolved.
Resolved
? [
{
value: DashboardFilterType.enum.ADHOC,
label: 'Ad hoc keys and values',
},
]
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
: []),
];

interface DashboardFilterEditFormProps {
Expand Down Expand Up @@ -232,6 +241,12 @@ export const DashboardFilterEditForm = ({
control={control}
otherFilters={otherFilters}
/>
) : formFilterType === 'ADHOC' ? (
<AdhocFilterEditForm
control={control}
setValue={setValue}
otherFilters={otherFilters}
/>
) : formFilterType === 'PROMETHEUS_LABEL' ? (
<SqlVariablesProvider variables={otherVariables}>
<PromqlLabelFilterEditForm
Expand All @@ -252,7 +267,7 @@ export const DashboardFilterEditForm = ({
)}
</ErrorBoundary>

{showRequiredFilterOptions && (
{showRequiredFilterOptions && formFilterType !== 'ADHOC' && (
<Stack gap="xs">
<Divider my="xs" />
<CheckBoxControlled
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {
getAdhocFilterAppliesToSourceIds,
getFilterBroadcastTarget,
getFilterVariableName,
isAdhocFilter,
isFilterBroadcastEnabled,
isFilterGlobalRequirement,
isFilterRequired,
isFilterVariableEnabled,
Expand All @@ -17,6 +20,7 @@ import {
import {
IconAsterisk,
IconBuildingBroadcastTower,
IconFilter,
IconLabel,
IconList,
IconPencil,
Expand Down Expand Up @@ -47,6 +51,16 @@ interface DashboardFiltersListProps {
onAddNew: () => void;
}

function getSourceNames(
ids: string[],
sources?: { id: string; name: string }[],
) {
return ids
.map(id => sources?.find(s => s.id === id)?.name)
.filter((name): name is string => !!name)
.join(', ');
}

/** Where the dropdown's values come from: the queried source, or the authored list. */
function getValuesAttribute(
filter: DashboardFilter,
Expand Down Expand Up @@ -74,9 +88,12 @@ function getValuesAttribute(
label: `${count} custom option${count === 1 ? '' : 's'}`,
};
}
// Not shown in the dashboard UI yet
case 'ADHOC':
return { icon: <IconSearch size={14} />, label: '' };
return {
icon: <IconFilter size={14} />,
tooltip: 'Sources the condition keys and values come from',
label: getSourceNames(filter.sources, sources),
};
default:
filter satisfies never; // exhaustive check
return { icon: <IconSearch size={14} />, label: '' };
Expand All @@ -87,18 +104,20 @@ function getBroadcastTargetDisplay(
filter: DashboardFilter,
sources?: { id: string; name: string }[],
) {
// An ad hoc filter broadcasts to its own sources unless narrowed further
if (isAdhocFilter(filter)) {
if (!isFilterBroadcastEnabled(filter)) return undefined;
return getSourceNames(getAdhocFilterAppliesToSourceIds(filter), sources);
}

const broadcastTarget = getFilterBroadcastTarget(filter);
if (!broadcastTarget) return undefined;

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

const appliedSourceNames = broadcastTarget.appliesToSourceIds
.map(id => sources?.find(s => s.id === id)?.name)
.filter((name): name is string => !!name);

return appliedSourceNames.join(', ');
return getSourceNames(broadcastTarget.appliesToSourceIds, sources);
}

export const DashboardFiltersList = ({
Expand Down
Loading
Loading