diff --git a/.changeset/multi-source-filters.md b/.changeset/multi-source-filters.md
index 7bb2603e91..d933bb2629 100644
--- a/.changeset/multi-source-filters.md
+++ b/.changeset/multi-source-filters.md
@@ -2,9 +2,11 @@
'@hyperdx/app': minor
---
-The filters sidebar now works when searching multiple sources. Facet fields
-and values merge across the selected sources, and checking a value filters
-every source that has the field. A source whose table lacks a filtered column
-is excluded from the results with a visible reason on its status chip instead
-of silently returning unfiltered rows. Filter pills and add-to-filter from the
-row side panel work in multi-source mode too.
+The filters sidebar works across every selected source. Facet fields and
+values merge across sources, value counts are summed, "load more" fans out,
+and pins (personal and team-shared) read as a union and apply to the whole
+selection. Checking a value filters every source that has the field; a source
+whose table lacks a filtered column is excluded from the results with a
+visible reason on its status chip instead of silently returning unfiltered
+rows. Filter pills and add-to-filter from the row side panel work across
+sources too.
diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx
index 0a08d25e9e..caa99bf8df 100644
--- a/packages/app/src/DBSearchPage.tsx
+++ b/packages/app/src/DBSearchPage.tsx
@@ -93,25 +93,24 @@ import { AlertStatusIcon } from '@/components/AlertStatusIcon';
import { ContactSupportText } from '@/components/ContactSupportText';
import { DBSearchPageFilters } from '@/components/DBSearchPageFilters';
import { cleanClickHouseExpression } from '@/components/DBSearchPageFilters/utils';
-import { DBTimeChart, type SeriesGroupFilter } from '@/components/DBTimeChart';
+import { type SeriesGroupFilter } from '@/components/DBTimeChart';
import EmptyState from '@/components/EmptyState';
import { ErrorBoundary } from '@/components/Error/ErrorBoundary';
import { FavoriteButton } from '@/components/FavoriteButton';
import ResourceTerraformPopover from '@/components/Iac/ResourceTerraformPopover';
import { InputControlled } from '@/components/InputControlled';
import MultiSourceColumnPicker from '@/components/MultiSourceColumnPicker';
-import MultiSourceSearchFilters from '@/components/MultiSourceSearchFilters';
-import {
- MultiSourceTimeChart,
- MultiSourceTotalCountChart,
-} from '@/components/MultiSourceTimeChart';
import OnboardingModal from '@/components/OnboardingModal';
+import {
+ SearchHistogram,
+ type SearchHistogramSpec,
+ SearchTotalCount,
+} from '@/components/SearchHistogram';
import SearchWhereInput, {
getStoredLanguage,
} from '@/components/SearchInput/SearchWhereInput';
import SearchPageActionBar from '@/components/SearchPageActionBar';
import SearchResultsTable from '@/components/SearchResultsTable';
-import SearchTotalCountChart from '@/components/SearchTotalCountChart';
import { SourceMultiSelectControlled } from '@/components/SourceMultiSelect';
import { TableSourceForm } from '@/components/Sources/SourceForm';
import { SourceSelectControlled } from '@/components/SourceSelect';
@@ -349,12 +348,12 @@ function ExpandFiltersButton({ onExpand }: { onExpand: () => void }) {
function SearchResultsCountGroup({
isFilterSidebarCollapsed,
onExpandFilters,
- histogramTimeChartConfig,
+ histogramSpecs,
enableParallelQueries,
}: {
isFilterSidebarCollapsed: boolean;
onExpandFilters: () => void;
- histogramTimeChartConfig: BuilderChartConfigWithDateRange;
+ histogramSpecs: SearchHistogramSpec[];
enableParallelQueries?: boolean;
}) {
return (
@@ -362,8 +361,8 @@ function SearchResultsCountGroup({
{isFilterSidebarCollapsed && (
)}
-
@@ -2195,6 +2194,20 @@ export function DBSearchPage() {
searchedConfig.select,
]);
+ // The chart/count query plan for however many sources are selected: one
+ // source keeps its severity-grouped histogram, several get one count()
+ // series each (stacked by source).
+ const histogramSpecs = useMemo(() => {
+ if (isMultiSource) return multiHistogramSpecs;
+ if (searchedSource == null || histogramTimeChartConfig == null) return [];
+ return [{ source: searchedSource, config: histogramTimeChartConfig }];
+ }, [
+ isMultiSource,
+ multiHistogramSpecs,
+ searchedSource,
+ histogramTimeChartConfig,
+ ]);
+
const onFormSubmit = useCallback>(
e => {
e.preventDefault();
@@ -2276,6 +2289,22 @@ export function DBSearchPage() {
};
}, [chartConfig, searchedTimeRange, aliasWith]);
+ // The sidebar reads facets, values, and pins across everything selected;
+ // with one source that is exactly the single-source sidebar.
+ const filterSidebarSources = useMemo(() => {
+ if (isMultiSource) {
+ // Facet queries want each source's search shape (its own FROM,
+ // connection, and WHERE), not the aggregated histogram config.
+ return searchStreamSpecs.map(({ source, config }) => ({
+ source,
+ config: { ...config, orderBy: undefined },
+ }));
+ }
+ return searchedSource != null
+ ? [{ source: searchedSource, config: filtersChartConfig }]
+ : [];
+ }, [isMultiSource, searchStreamSpecs, searchedSource, filtersChartConfig]);
+
const openNewSourceModal = useCallback(() => {
setNewSourceModalOpened(true);
}, []);
@@ -2834,21 +2863,7 @@ export function DBSearchPage() {
height: '100%',
}}
>
- {!isFilterSidebarCollapsed &&
- isMultiSource &&
- !isMultiSourceSqlBlocked && (
-
- setIsFilterSidebarCollapsed(true)}
- />
-
- )}
- {!isFilterSidebarCollapsed && !isMultiSource && (
+ {!isFilterSidebarCollapsed && (
setIsFilterSidebarCollapsed(false)
}
- histogramTimeChartConfig={histogramTimeChartConfig}
+ histogramSpecs={histogramSpecs}
/>
-
)}
-
-
setIsFilterSidebarCollapsed(false)
}
- histogramTimeChartConfig={histogramTimeChartConfig}
+ histogramSpecs={histogramSpecs}
enableParallelQueries
/>
@@ -3086,14 +3095,9 @@ export function DBSearchPage() {
className={searchPageStyles.timeChartContainer}
mih="0"
>
- () => );
// Multi-source components pull in DBRowSidePanel (and its deep import graph),
// which this test isolates away just like DBSqlRowTableWithSidebar above.
jest.mock('../components/SearchResultsTable', () => () => );
-jest.mock('../components/MultiSourceTimeChart', () => ({
- MultiSourceTimeChart: () => ,
- MultiSourceTotalCountChart: () => ,
+jest.mock('../components/SearchHistogram', () => ({
+ SearchHistogram: () => ,
+ SearchTotalCount: () => ,
}));
jest.mock('../components/PatternTable', () => () => );
jest.mock('../components/Search/DBSearchHeatmapChart', () => ({
diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx
index 67a154aae7..55a60c41f7 100644
--- a/packages/app/src/components/DBSearchPageFilters.tsx
+++ b/packages/app/src/components/DBSearchPageFilters.tsx
@@ -8,6 +8,7 @@ import { FilterState } from '@hyperdx/common-utils/dist/filters';
import {
BuilderChartConfigWithDateRange,
SourceKind,
+ TSource,
} from '@hyperdx/common-utils/dist/types';
import {
Accordion,
@@ -51,22 +52,23 @@ import {
import { IS_CLICKHOUSE_BUILD } from '@/config';
import {
useColumns,
- useGetValuesDistribution,
useJsonColumns,
+ useMergedValuesDistribution,
useTableMetadata,
} from '@/hooks/useMetadata';
+import { useMultiSourceColumns } from '@/hooks/useMultiSourceSearch';
import useResizable from '@/hooks/useResizable';
import { usePinnedFiltersApi } from '@/pinnedFilters';
import {
FilterStateHook,
IS_ROOT_SPAN_COLUMN_NAME,
- usePinnedFilters,
+ usePinnedFiltersForSources,
} from '@/searchFilters';
import { useSource } from '@/source';
import { useLocalStorage } from '@/utils';
import { FilterSettingsPanel } from './DBSearchPageFilters/FilterSettingsPopover';
-import { useFetchFacets } from './DBSearchPageFilters/hooks';
+import { useFetchFacetsForSources } from './DBSearchPageFilters/hooks';
import { NestedFilterGroup } from './DBSearchPageFilters/NestedFilterGroup';
import {
PinShareIndicator,
@@ -82,6 +84,17 @@ import {
import resizeStyles from '@styles/ResizablePanel.module.scss';
import classes from '@styles/SearchPage.module.scss';
+// Placeholder used when no source is selected yet; nothing queries with it.
+const EMPTY_FILTER_CHART_CONFIG = {
+ connection: '',
+ from: { databaseName: '', tableName: '' },
+ timestampValueExpression: '',
+ select: '',
+ where: '',
+ whereLanguage: 'sql' as const,
+ dateRange: [new Date(0), new Date(0)] as [Date, Date],
+};
+
/* The initial number of values per filter to render */
const INITIAL_MAX_VALUES_DISPLAYED = 10;
@@ -402,7 +415,8 @@ export type FilterGroupProps = {
isDefaultExpanded?: boolean;
showFilterCounts?: boolean;
'data-testid'?: string;
- chartConfig: BuilderChartConfigWithDateRange;
+ /** One config per selected source; value counts are summed across them. */
+ chartConfigs: BuilderChartConfigWithDateRange[];
isLive?: boolean;
onRangeChange?: (range: { min: number; max: number }) => void;
distributionKey?: string;
@@ -428,7 +442,7 @@ const FilterGroupBody = ({
onLoadMore,
loadMoreLoading,
hasLoadedMore,
- chartConfig,
+ chartConfigs,
isLive,
distributionKey,
showDistributions,
@@ -449,7 +463,7 @@ const FilterGroupBody = ({
onLoadMore: (key: string) => void;
loadMoreLoading: boolean;
hasLoadedMore: boolean;
- chartConfig: BuilderChartConfigWithDateRange;
+ chartConfigs: BuilderChartConfigWithDateRange[];
isLive?: boolean;
distributionKey?: string;
showDistributions: boolean;
@@ -463,16 +477,19 @@ const FilterGroupBody = ({
const [recentlyMoved, setRecentlyMoved] = useState>(
new Set(),
);
- // For live searches, don't refresh percentages when date range changes
+ // For live searches, don't refresh percentages when date range changes.
+ // Every source's config carries the same searched range, so the first one
+ // speaks for all of them.
+ const primaryDateRange = chartConfigs[0]?.dateRange;
const [dateRange, setDateRange] = useState<[Date, Date]>(
- chartConfig.dateRange,
+ primaryDateRange ?? EMPTY_FILTER_CHART_CONFIG.dateRange,
);
useEffect(() => {
- if (!isLive) {
- setDateRange(chartConfig.dateRange);
+ if (!isLive && primaryDateRange != null) {
+ setDateRange(primaryDateRange);
}
- }, [chartConfig.dateRange, isLive]);
+ }, [primaryDateRange, isLive]);
const handleSetSearch = useCallback(
(value: string) => {
@@ -484,25 +501,23 @@ const FilterGroupBody = ({
[hasLoadedMore, name, onLoadMore],
);
+ const distributionConfigs = useMemo(
+ () => chartConfigs.map(config => ({ ...config, dateRange })),
+ [chartConfigs, dateRange],
+ );
const {
data: distributionData,
isFetching: isFetchingDistribution,
error: distributionError,
- } = useGetValuesDistribution(
+ } = useMergedValuesDistribution(
{
- chartConfig: { ...chartConfig, dateRange },
+ chartConfigs: distributionConfigs,
key: distributionKey || name,
limit: 100, // The 100 most common values are enough to find any values that are present in at least 1% of rows
},
- {
- enabled: showDistributions,
- },
+ { enabled: showDistributions },
);
- useEffect(() => {
- onFetchingDistributionChange(isFetchingDistribution);
- }, [isFetchingDistribution, onFetchingDistributionChange]);
-
useEffect(() => {
if (distributionError) {
notifications.show({
@@ -515,6 +530,10 @@ const FilterGroupBody = ({
}
}, [distributionError, onDistributionError]);
+ useEffect(() => {
+ onFetchingDistributionChange(isFetchingDistribution);
+ }, [isFetchingDistribution, onFetchingDistributionChange]);
+
const totalAppliedFiltersSize =
selectedValues.included.size +
selectedValues.excluded.size +
@@ -900,7 +919,7 @@ export const FilterGroup = ({
isDefaultExpanded,
showFilterCounts,
'data-testid': dataTestId,
- chartConfig,
+ chartConfigs,
isLive,
distributionKey,
onRangeChange,
@@ -1040,7 +1059,7 @@ export const FilterGroup = ({
onLoadMore={onLoadMore}
loadMoreLoading={loadMoreLoading}
hasLoadedMore={hasLoadedMore}
- chartConfig={chartConfig}
+ chartConfigs={chartConfigs}
isLive={isLive}
distributionKey={distributionKey}
showDistributions={showDistributions}
@@ -1061,10 +1080,9 @@ const DBSearchPageFiltersComponent = ({
clearFilter,
setFilterValue: _setFilterValue,
isLive,
- chartConfig,
+ sources,
analysisMode,
setAnalysisMode,
- sourceId,
showDelta,
denoiseResults,
setDenoiseResults,
@@ -1076,8 +1094,11 @@ const DBSearchPageFiltersComponent = ({
analysisMode: 'results' | 'delta' | 'pattern';
setAnalysisMode: (mode: 'results' | 'delta' | 'pattern') => void;
isLive: boolean;
- chartConfig: BuilderChartConfigWithDateRange;
- sourceId?: string;
+ /**
+ * One entry per selected source. Facets, values, and pins merge across all
+ * of them; a single source behaves exactly as before.
+ */
+ sources: { source: TSource; config: BuilderChartConfigWithDateRange }[];
showDelta: boolean;
denoiseResults: boolean;
setDenoiseResults: (denoiseResults: boolean) => void;
@@ -1096,6 +1117,23 @@ const DBSearchPageFiltersComponent = ({
},
[_setFilterValue],
);
+ // The first selected source is the "primary": it anchors the things that
+ // are inherently about one table (schema preview, the analysis-mode tabs).
+ // Everything users read or click in the list itself merges across sources.
+ const primarySource = sources[0]?.source;
+ const sourceId = primarySource?.id;
+ const chartConfig = sources[0]?.config ?? EMPTY_FILTER_CHART_CONFIG;
+ const sourceIds = useMemo(() => sources.map(s => s.source.id), [sources]);
+ const chartConfigs = useMemo(() => sources.map(s => s.config), [sources]);
+ const facetSpecs = useMemo(
+ () =>
+ sources.map(({ source, config }) => ({
+ sourceId: source.id,
+ chartConfig: config,
+ })),
+ [sources],
+ );
+
const {
toggleFilterPin,
toggleFieldPin,
@@ -1111,7 +1149,7 @@ const DBSearchPageFiltersComponent = ({
resetSharedFilters,
hasPersonalPins,
hasSharedPins,
- } = usePinnedFilters(sourceId ?? null);
+ } = usePinnedFiltersForSources(sourceIds);
const { data: pinnedFiltersApiData } = usePinnedFiltersApi(sourceId ?? null);
const [isSharedFiltersVisible, setSharedFiltersVisible] = useLocalStorage(
'hdx-shared-filters-visible',
@@ -1144,6 +1182,11 @@ const DBSearchPageFiltersComponent = ({
chartConfig.dateRange,
);
+ // Filter keys can come from any selected source's schema, so escaping is
+ // resolved against the union of their columns.
+ const { columnsBySourceId } = useMultiSourceColumns(
+ useMemo(() => sources.map(s => s.source), [sources]),
+ );
const { data: columns } = useColumns({
databaseName: chartConfig.from.databaseName,
tableName: chartConfig.from.tableName,
@@ -1164,10 +1207,13 @@ const DBSearchPageFiltersComponent = ({
// Conditionally backtick-quote facet keys that contain special characters and
// match known column names, so they can be used in the ClickHouse query to get
// key values.
- const knownColumns = useMemo(
- () => (columns ? new Set(columns.map(c => c.name)) : new Set()),
- [columns],
- );
+ const knownColumns = useMemo(() => {
+ const names = new Set(columns?.map(c => c.name) ?? []);
+ for (const sourceColumns of columnsBySourceId.values()) {
+ for (const name of sourceColumns) names.add(name);
+ }
+ return names;
+ }, [columns, columnsBySourceId]);
const [showMoreFields, setShowMoreFields] = useState(false);
const {
@@ -1178,9 +1224,8 @@ const DBSearchPageFiltersComponent = ({
loadMoreFacetsForKey,
loadMoreLoadingKeys,
extraFacetKeys,
- } = useFetchFacets({
- chartConfig,
- sourceId: sourceId ?? null,
+ } = useFetchFacetsForSources({
+ specs: facetSpecs,
dateRange,
mode: showAllValues ? 'all' : 'exact',
filterState,
@@ -1516,7 +1561,7 @@ const DBSearchPageFiltersComponent = ({
);
})
}
- chartConfig={chartConfig}
+ chartConfigs={chartConfigs}
isLive={isLive}
/>
))}
@@ -1571,7 +1616,7 @@ const DBSearchPageFiltersComponent = ({
entry.range != null)))
);
})()}
- chartConfig={chartConfig}
+ chartConfigs={chartConfigs}
isLive={isLive}
onRangeChange={range => setFilterRange(facet.key, range)}
/>
@@ -1598,7 +1643,7 @@ const DBSearchPageFiltersComponent = ({
loadMoreLoadingKeys,
showFilterCounts,
isFacetsLoading,
- chartConfig,
+ chartConfigs,
isLive,
setFilterRange,
tableMetadata,
diff --git a/packages/app/src/components/DBSearchPageFilters/NestedFilterGroup.tsx b/packages/app/src/components/DBSearchPageFilters/NestedFilterGroup.tsx
index 4145d8e917..6291a6136c 100644
--- a/packages/app/src/components/DBSearchPageFilters/NestedFilterGroup.tsx
+++ b/packages/app/src/components/DBSearchPageFilters/NestedFilterGroup.tsx
@@ -39,7 +39,8 @@ type NestedFilterGroupProps = {
hasLoadedMore: Record;
isDefaultExpanded?: boolean;
'data-testid'?: string;
- chartConfig: any; // Using any to avoid importing ChartConfigWithDateRange
+ /** One config per selected source; counts are summed across them. */
+ chartConfigs: any[]; // `any` avoids importing ChartConfigWithDateRange
isLive?: boolean;
};
@@ -70,7 +71,7 @@ export const NestedFilterGroup = ({
hasLoadedMore,
isDefaultExpanded,
'data-testid': dataTestId,
- chartConfig,
+ chartConfigs,
isLive,
}: NestedFilterGroupProps) => {
const selectedValues: FilterState = useMemo(
@@ -253,7 +254,7 @@ export const NestedFilterGroup = ({
hasLoadedMore={hasLoadedMore[child.key] || false}
showFilterCounts={showFilterCounts}
isDefaultExpanded={childHasSelections}
- chartConfig={chartConfig}
+ chartConfigs={chartConfigs}
isLive={isLive}
/>
diff --git a/packages/app/src/components/DBSearchPageFilters/hooks.ts b/packages/app/src/components/DBSearchPageFilters/hooks.ts
index a9d676102a..d9dd4319e6 100644
--- a/packages/app/src/components/DBSearchPageFilters/hooks.ts
+++ b/packages/app/src/components/DBSearchPageFilters/hooks.ts
@@ -17,6 +17,7 @@ import {
useMapColumns,
useMetadataWithSettings,
} from '@/hooks/useMetadata';
+import { useMultiSourceSlots } from '@/hooks/useSourceSlots';
import { escapeFilterStateKeys, usePinnedFilters } from '@/searchFilters';
import { useSource } from '@/source';
import { mergePath } from '@/utils';
@@ -372,3 +373,147 @@ export function useFetchFacets({
extraFacetKeys,
};
}
+
+export type SourceFacetSpec = {
+ sourceId: string;
+ chartConfig: BuilderChartConfigWithDateRange;
+};
+
+/** Slot hook: the full facet pipeline for one selected source. */
+function useSourceFacetsSlot(
+ spec: SourceFacetSpec | undefined,
+ opts: {
+ dateRange: [Date, Date];
+ mode: 'all' | 'exact';
+ filterState?: FilterState;
+ showMoreFields?: boolean;
+ },
+) {
+ const query = useFetchFacets({
+ chartConfig: spec?.chartConfig ?? STUB_FACET_CONFIG,
+ sourceId: spec?.sourceId ?? null,
+ dateRange: opts.dateRange,
+ mode: opts.mode,
+ filterState: opts.filterState,
+ showMoreFields: opts.showMoreFields,
+ enabled: spec != null,
+ });
+ return query;
+}
+
+const STUB_FACET_CONFIG: BuilderChartConfigWithDateRange = {
+ connection: '',
+ from: { databaseName: '', tableName: '' },
+ timestampValueExpression: '',
+ select: '',
+ where: '',
+ whereLanguage: 'sql',
+ dateRange: [new Date(0), new Date(0)],
+};
+
+/**
+ * Facets across every selected source: fields and values merged by field
+ * path, values unioned in first-seen order. "Load more" fans out to each
+ * source and unions what comes back, so a high-cardinality field expands
+ * across the whole search rather than one table.
+ *
+ * With a single source this is `useFetchFacets` for that source, unchanged.
+ */
+export function useFetchFacetsForSources({
+ specs,
+ dateRange,
+ mode,
+ filterState,
+ showMoreFields,
+}: {
+ specs: SourceFacetSpec[];
+ dateRange: [Date, Date];
+ mode: 'all' | 'exact';
+ filterState?: FilterState;
+ showMoreFields?: boolean;
+}) {
+ const slots = useMultiSourceSlots(specs, useSourceFacetsSlot, {
+ dateRange,
+ mode,
+ filterState,
+ showMoreFields,
+ });
+
+ const merged = useMemo(() => {
+ const byKey = new Map<
+ string,
+ { values: (string | boolean)[]; seen: Set }
+ >();
+ let sawAny = false;
+ for (const slot of slots) {
+ const facets = slot.data.keyValues;
+ if (facets == null) continue;
+ sawAny = true;
+ for (const facet of facets) {
+ let entry = byKey.get(facet.key);
+ if (entry == null) {
+ entry = { values: [], seen: new Set() };
+ byKey.set(facet.key, entry);
+ }
+ for (const value of facet.value) {
+ if (!entry.seen.has(value)) {
+ entry.seen.add(value);
+ entry.values.push(value);
+ }
+ }
+ }
+ }
+ const keyValues = sawAny
+ ? [...byKey.entries()].map(([key, entry]) => ({
+ key,
+ value: entry.values,
+ }))
+ : undefined;
+
+ const keys = slots.flatMap(slot => slot.data.keys ?? []);
+ const seenPaths = new Set();
+ const mergedKeys = keys.filter(field => {
+ const id = `${field.path.join('.')}|${field.type}`;
+ if (seenPaths.has(id)) return false;
+ seenPaths.add(id);
+ return true;
+ });
+
+ return { keys: mergedKeys.length > 0 ? mergedKeys : undefined, keyValues };
+ }, [slots]);
+
+ const loadMoreFacetsForKey = useCallback(
+ async (key: string) => {
+ await Promise.all(slots.map(slot => slot.loadMoreFacetsForKey(key)));
+ },
+ [slots],
+ );
+
+ const loadMoreLoadingKeys = useMemo(() => {
+ const keys = new Set();
+ for (const slot of slots) {
+ for (const key of slot.loadMoreLoadingKeys) keys.add(key);
+ }
+ return keys;
+ }, [slots]);
+
+ const extraFacetKeys = useMemo(() => {
+ const keys = new Set();
+ for (const slot of slots) {
+ for (const key of slot.extraFacetKeys) keys.add(key);
+ }
+ return keys;
+ }, [slots]);
+
+ return {
+ data: merged,
+ isLoading: slots.some(s => s.isLoading),
+ isFetching: slots.some(s => s.isFetching),
+ // A single failing source shouldn't blank the sidebar; surface the first.
+ error: slots.find(s => s.error != null)?.error,
+ loadMoreFacetsForKey,
+ loadMoreLoadingKeys,
+ extraFacetKeys,
+ areExtraFacetsLoading: slots.some(s => s.areExtraFacetsLoading),
+ };
+}
diff --git a/packages/app/src/components/MultiSourceSearchFilters.tsx b/packages/app/src/components/MultiSourceSearchFilters.tsx
deleted file mode 100644
index a909e28441..0000000000
--- a/packages/app/src/components/MultiSourceSearchFilters.tsx
+++ /dev/null
@@ -1,315 +0,0 @@
-import { useMemo } from 'react';
-import { FilterState } from '@hyperdx/common-utils/dist/filters';
-import {
- BuilderChartConfigWithDateRange,
- TSource,
-} from '@hyperdx/common-utils/dist/types';
-import {
- ActionIcon,
- Box,
- Flex,
- Group,
- ScrollArea,
- Stack,
- Text,
- Tooltip,
-} from '@mantine/core';
-import { IconArrowBarToLeft, IconFilterOff } from '@tabler/icons-react';
-
-import {
- cleanedFacetName,
- FilterGroup,
-} from '@/components/DBSearchPageFilters';
-import { useFetchFacets } from '@/components/DBSearchPageFilters/hooks';
-import { NestedFilterGroup } from '@/components/DBSearchPageFilters/NestedFilterGroup';
-import {
- getFilterStateEntry,
- groupFacetsByBaseName,
- toQuotedClickHouseKeyExpression,
-} from '@/components/DBSearchPageFilters/utils';
-import { useMultiSourceSlots } from '@/hooks/useMultiSourceSearch';
-import useResizable from '@/hooks/useResizable';
-import { FilterStateHook } from '@/searchFilters';
-
-import resizeStyles from '@styles/ResizablePanel.module.scss';
-import classes from '@styles/SearchPage.module.scss';
-
-export type MultiSourceFilterSpec = {
- source: TSource;
- /** Per-source config carrying connection/from/where/filters/dateRange. */
- config: BuilderChartConfigWithDateRange;
-};
-
-// Placeholder for unused hook slots; never fetched (enabled: false).
-const STUB_CONFIG: BuilderChartConfigWithDateRange = {
- connection: '',
- from: { databaseName: '', tableName: '' },
- timestampValueExpression: '',
- select: '',
- where: '',
- whereLanguage: 'sql',
- dateRange: [new Date(0), new Date(0)],
-};
-
-type FacetSlotState = {
- facets: { key: string; value: string[] }[] | undefined;
- isLoading: boolean;
- isFetching: boolean;
-};
-
-/** Slot hook: the full single-source facet pipeline for one selected source. */
-function useSourceFacetsSlot(
- spec: MultiSourceFilterSpec | undefined,
- {
- dateRange,
- filterState,
- }: {
- dateRange: [Date, Date];
- filterState: FilterStateHook['filters'];
- },
-): FacetSlotState {
- const { data, isLoading, isFetching } = useFetchFacets({
- chartConfig: spec?.config ?? STUB_CONFIG,
- sourceId: spec?.source.id ?? null,
- dateRange,
- // Value lists show everything in range (not narrowed by the current
- // query), matching the sidebar's default "show all values" behavior.
- mode: 'all',
- filterState,
- enabled: spec != null,
- });
-
- return useMemo(
- () => ({ facets: data.keyValues, isLoading, isFetching }),
- [data.keyValues, isLoading, isFetching],
- );
-}
-
-const NOOP = () => {
- /* pins and load-more are single-source affordances; no-op in multi mode */
-};
-const VALUE_PINS = { onPinClick: NOOP, isPinned: () => false };
-
-/**
- * Multi-source variant of the search filters sidebar: one facet pipeline per
- * selected source, merged by field path with values unioned. Filters apply
- * per source; a source that lacks a filtered column is excluded from the
- * search (surfaced as a chip on the results table).
- *
- * Single-source-only affordances (pins, shared filters, value counts,
- * load-more, analysis-mode tabs, denoising) are intentionally absent.
- */
-export default function MultiSourceSearchFilters({
- specs,
- dateRange,
- isLive,
- knownColumns,
- searchFilters,
- onCollapse,
-}: {
- specs: MultiSourceFilterSpec[];
- dateRange: [Date, Date];
- isLive: boolean;
- /** Union of the selected sources' top-level column names (for escaping). */
- knownColumns: Set;
- searchFilters: FilterStateHook;
- onCollapse?: () => void;
-}) {
- const { size, startResize } = useResizable(16, 'left');
- const {
- filters: filterState,
- setFilterValue,
- clearFilter,
- clearAllFilters,
- setFilterRange,
- } = searchFilters;
-
- const slots = useMultiSourceSlots(specs, useSourceFacetsSlot, {
- dateRange,
- filterState,
- });
-
- const isFetching = slots.some(s => s.isFetching);
- const isLoading = slots.some(s => s.isLoading);
-
- // Merge facets across sources: union values per field path, in first-seen
- // order (the first selected source's ordering wins).
- const mergedFacets = useMemo(() => {
- const byKey = new Map }>();
- for (const slot of slots) {
- for (const facet of slot.facets ?? []) {
- let entry = byKey.get(facet.key);
- if (entry == null) {
- entry = { values: [], seen: new Set() };
- byKey.set(facet.key, entry);
- }
- for (const value of facet.value) {
- if (!entry.seen.has(value)) {
- entry.seen.add(value);
- entry.values.push(value);
- }
- }
- }
- }
- return [...byKey.entries()].map(([key, entry]) => ({
- key,
- value: entry.values,
- }));
- }, [slots]);
-
- const hasSelections = Object.keys(filterState).length > 0;
- const firstConfig = specs[0]?.config ?? STUB_CONFIG;
- const { grouped, nonGrouped } = useMemo(
- () => groupFacetsByBaseName(mergedFacets),
- [mergedFacets],
- );
-
- return (
-
-
-
-
-
-
- Filters {isFetching && '···'}
-
-
- {hasSelections && (
-
-
-
-
-
- )}
- {onCollapse && (
-
-
-
-
-
- )}
-
-
-
- Values across all selected sources. A filter on a field a source
- doesn't have excludes that source from the results.
-
- {grouped.map(group => (
- ({
- ...child,
- sqlKey: toQuotedClickHouseKeyExpression(
- child.key,
- knownColumns,
- ),
- }))}
- selectedValues={group.children.reduce((acc, child) => {
- acc[child.key] = getFilterStateEntry(
- filterState,
- child.key,
- ) ?? {
- included: new Set(),
- excluded: new Set(),
- };
- return acc;
- }, {} as FilterState)}
- onChange={(key, value) => setFilterValue(key, value)}
- onClearClick={key => clearFilter(key)}
- onOnlyClick={(key, value) => setFilterValue(key, value, 'only')}
- onExcludeClick={(key, value) =>
- setFilterValue(key, value, 'exclude')
- }
- onPinClick={NOOP}
- isPinned={() => false}
- showFilterCounts={false}
- onLoadMore={NOOP}
- loadMoreLoading={{}}
- hasLoadedMore={{}}
- isDefaultExpanded={group.children.some(child => {
- const entry = getFilterStateEntry(filterState, child.key);
- return (
- entry != null &&
- (entry.included.size > 0 || entry.excluded.size > 0)
- );
- })}
- chartConfig={firstConfig}
- isLive={isLive}
- />
- ))}
- {nonGrouped.map(facet => {
- const facetSqlKey = toQuotedClickHouseKeyExpression(
- facet.key,
- knownColumns,
- );
- const entry = getFilterStateEntry(filterState, facet.key);
- return (
- ({
- value,
- label: value.toString(),
- }))}
- optionsLoading={isLoading}
- selectedValues={
- entry ?? { included: new Set(), excluded: new Set() }
- }
- onChange={value => setFilterValue(facet.key, value)}
- onClearClick={() => clearFilter(facet.key)}
- onOnlyClick={value => setFilterValue(facet.key, value, 'only')}
- onExcludeClick={value =>
- setFilterValue(facet.key, value, 'exclude')
- }
- valuePins={VALUE_PINS}
- onLoadMore={NOOP}
- loadMoreLoading={false}
- hasLoadedMore={false}
- isDefaultExpanded={
- entry != null &&
- (entry.included.size > 0 ||
- entry.excluded.size > 0 ||
- entry.range != null)
- }
- chartConfig={firstConfig}
- isLive={isLive}
- onRangeChange={range => setFilterRange(facet.key, range)}
- />
- );
- })}
- {!isLoading && mergedFacets.length === 0 && (
-
- No filterable fields found.
-
- )}
-
-
-
- );
-}
diff --git a/packages/app/src/components/MultiSourceTimeChart.tsx b/packages/app/src/components/SearchHistogram.tsx
similarity index 76%
rename from packages/app/src/components/MultiSourceTimeChart.tsx
rename to packages/app/src/components/SearchHistogram.tsx
index 122c937c49..55dbaf1481 100644
--- a/packages/app/src/components/MultiSourceTimeChart.tsx
+++ b/packages/app/src/components/SearchHistogram.tsx
@@ -23,15 +23,17 @@ import ChartContainer from '@/components/charts/ChartContainer';
import ChartErrorState from '@/components/charts/ChartErrorState';
import { type ActiveClickPayload, MemoChart } from '@/HDXMultiSeriesTimeChart';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
-import { useMultiSourceSlots } from '@/hooks/useMultiSourceSearch';
+import { useMultiSourceSlots } from '@/hooks/useSourceSlots';
import type { NumberFormat } from '@/types';
+import { DBTimeChart, type SeriesGroupFilter } from './DBTimeChart';
import { getMultiSourceColor } from './MultiSourceBadge';
+import SearchTotalCountChart from './SearchTotalCountChart';
/** Synthetic group column tagged onto each source's histogram rows. */
const SOURCE_GROUP_COLUMN = '__hdx_source';
-export type MultiSourceChartSpec = {
+export type SearchHistogramSpec = {
source: TSource;
/** Per-source count() histogram config (canonical WHERE, no groupBy). */
config: BuilderChartConfigWithDateRange;
@@ -58,7 +60,7 @@ type HistogramSlotState = {
};
function useHistogramSlot(
- spec: MultiSourceChartSpec | undefined,
+ spec: SearchHistogramSpec | undefined,
{
enabled,
queryKeyPrefix,
@@ -113,7 +115,7 @@ function useHistogramSlot(
* time-chart transform naturally yields one series per source.
*/
function useMultiSourceHistogram(
- specs: MultiSourceChartSpec[],
+ specs: SearchHistogramSpec[],
{
enabled = true,
queryKeyPrefix,
@@ -171,7 +173,7 @@ const EMPTY_NUMBER_FORMATS = new Map();
* counterpart to DBTimeChart — drag-to-zoom and the legend work; per-series
* drill-down/pinned tooltips are single-source features and are omitted.
*/
-export function MultiSourceTimeChart({
+function MergedSourcesTimeChart({
specs,
enabled = true,
queryKeyPrefix,
@@ -179,7 +181,7 @@ export function MultiSourceTimeChart({
onTimeRangeSelect,
showLegend = true,
}: {
- specs: MultiSourceChartSpec[];
+ specs: SearchHistogramSpec[];
enabled?: boolean;
queryKeyPrefix: string;
enableParallelQueries?: boolean;
@@ -275,13 +277,13 @@ export function MultiSourceTimeChart({
* Summed "N Results" across every selected source, sharing the histogram's
* per-source queries (identical query keys) so it adds no ClickHouse load.
*/
-export function MultiSourceTotalCountChart({
+function MergedSourcesTotalCount({
specs,
enabled = true,
queryKeyPrefix,
enableParallelQueries,
}: {
- specs: MultiSourceChartSpec[];
+ specs: SearchHistogramSpec[];
enabled?: boolean;
queryKeyPrefix: string;
enableParallelQueries?: boolean;
@@ -323,3 +325,97 @@ export function MultiSourceTotalCountChart({
);
}
+
+/**
+ * The search page's histogram, for any number of selected sources.
+ *
+ * One source keeps the full-featured DBTimeChart — series drill-down, focus,
+ * the pinned tooltip, MV optimization — grouped by severity/status, which is
+ * what a single source's chart has always shown. Several sources can't share
+ * a severity vocabulary, so they stack one count() series per source instead,
+ * and the merged chart trades the per-series drill-down for that.
+ */
+export function SearchHistogram({
+ specs,
+ enabled = true,
+ queryKeyPrefix,
+ enableParallelQueries,
+ onTimeRangeSelect,
+ onFocusSeries,
+ showLegend,
+}: {
+ /** One spec per selected source; N=1 is the single-source histogram. */
+ specs: SearchHistogramSpec[];
+ enabled?: boolean;
+ queryKeyPrefix: string;
+ enableParallelQueries?: boolean;
+ onTimeRangeSelect?: (start: Date, end: Date) => void;
+ /** Focus a severity/status series into the search (single source only). */
+ onFocusSeries?: (filters: SeriesGroupFilter[]) => void;
+ showLegend?: boolean;
+}) {
+ if (specs.length === 1) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+/**
+ * "N Results" for any number of sources: the single-source count query, or the
+ * sum across sources. Shares the histogram's per-source queries either way, so
+ * it adds no ClickHouse load.
+ */
+export function SearchTotalCount({
+ specs,
+ enabled = true,
+ queryKeyPrefix,
+ enableParallelQueries,
+}: {
+ specs: SearchHistogramSpec[];
+ enabled?: boolean;
+ queryKeyPrefix: string;
+ enableParallelQueries?: boolean;
+}) {
+ if (specs.length === 1) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/app/src/components/SearchResultsTable.tsx b/packages/app/src/components/SearchResultsTable.tsx
index 428f55ba27..4129828b92 100644
--- a/packages/app/src/components/SearchResultsTable.tsx
+++ b/packages/app/src/components/SearchResultsTable.tsx
@@ -20,9 +20,9 @@ import { SortingState } from '@tanstack/react-table';
import api from '@/api';
import { searchChartConfigDefaults } from '@/defaults';
-import { useMultiSourceSlots } from '@/hooks/useMultiSourceSearch';
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
import useRowWhere, { RowWhereResult, WithClause } from '@/hooks/useRowWhere';
+import { useMultiSourceSlots } from '@/hooks/useSourceSlots';
import {
mergeStreams,
MULTI_SOURCE_ROW_FIELDS,
diff --git a/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx b/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx
index 0353c68d4f..a1d4359c93 100644
--- a/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx
+++ b/packages/app/src/components/__tests__/DBSearchPageFilters.test.tsx
@@ -1,4 +1,3 @@
-import { UseQueryResult } from '@tanstack/react-query';
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -13,7 +12,7 @@ import {
groupFacetsByBaseName,
parseMapFieldName,
} from '@/components/DBSearchPageFilters/utils';
-import { useGetValuesDistribution } from '@/hooks/useMetadata';
+import { useMergedValuesDistribution } from '@/hooks/useMetadata';
describe('cleanClickHouseExpression', () => {
it('should remove toString wrapper', () => {
@@ -50,9 +49,9 @@ describe('cleanClickHouseExpression', () => {
});
jest.mock('@/hooks/useMetadata', () => ({
- useGetValuesDistribution: jest
+ useMergedValuesDistribution: jest
.fn()
- .mockReturnValue({ data: undefined, isFetching: false, error: undefined }),
+ .mockReturnValue({ data: undefined, isFetching: false, error: null }),
}));
describe('cleanedFacetName', () => {
@@ -396,18 +395,20 @@ describe('FilterGroup', () => {
loadMoreLoading: false,
hasLoadedMore: false,
isDefaultExpanded: true,
- chartConfig: {
- from: {
- databaseName: 'test_db',
- tableName: 'test_table',
+ chartConfigs: [
+ {
+ from: {
+ databaseName: 'test_db',
+ tableName: 'test_table',
+ },
+ select: '',
+ where: '',
+ whereLanguage: 'sql',
+ timestampValueExpression: '',
+ connection: 'test_connection',
+ dateRange: [new Date('2024-01-01'), new Date('2024-01-02')],
},
- select: '',
- where: '',
- whereLanguage: 'sql',
- timestampValueExpression: '',
- connection: 'test_connection',
- dateRange: [new Date('2024-01-01'), new Date('2024-01-02')],
- },
+ ],
};
it('should sort options alphabetically by default', () => {
@@ -441,7 +442,7 @@ describe('FilterGroup', () => {
});
it('should show selected items first, then sort by counts, if percentages when they are enabled', () => {
- jest.mocked(useGetValuesDistribution).mockReturnValue({
+ jest.mocked(useMergedValuesDistribution).mockReturnValue({
data: new Map([
['apple', 30],
['banana', 20],
@@ -449,7 +450,7 @@ describe('FilterGroup', () => {
]),
isFetching: false,
error: null,
- } as UseQueryResult