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-click-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@hyperdx/common-utils': patch
'@hyperdx/app': patch
---

feat: Filter search tile cells into adhoc dashboard filters
64 changes: 63 additions & 1 deletion packages/app/src/DBDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ import {
Granularity,
isTimeSeriesDisplayType,
} from '@hyperdx/common-utils/dist/core/utils';
import { getBlockingRequiredFilterNames } from '@hyperdx/common-utils/dist/dashboardFilterValues';
import {
getAdhocFilterTargetsForTile,
getBlockingRequiredFilterNames,
} from '@hyperdx/common-utils/dist/dashboardFilterValues';
import {
getPromqlFilters,
getSqlFilters,
Expand Down Expand Up @@ -127,6 +130,7 @@ import {
} from '@tabler/icons-react';

import { IsolatedChartSyncProvider } from '@/chartSync';
import { getAllowedSourceKinds } from '@/components/ChartEditor/utils';
import { mergeAnnotations } from '@/components/charts/chartAnnotations';
import { ContactSupportText } from '@/components/ContactSupportText';
import {
Expand All @@ -148,6 +152,7 @@ import { DashboardKioskHeader } from '@/components/DashboardKioskHeader';
import DashboardTableOfContents from '@/components/DashboardTableOfContents';
import EditTimeChartForm from '@/components/DBEditTimeChartForm';
import DBNumberChart from '@/components/DBNumberChart';
import type { RowSidePanelContextProps } from '@/components/DBRowSidePanel';
import DBTableChart from '@/components/DBTableChart';
import { DBTimeChart } from '@/components/DBTimeChart';
import { FavoriteButton } from '@/components/FavoriteButton';
Expand Down Expand Up @@ -177,6 +182,7 @@ import useDashboardContainers, {
import { useDashboardKioskMode } from '@/hooks/useDashboardKioskMode';
import { useReleaseAnnotations } from '@/hooks/useReleaseAnnotations';
import { QueryAttributionProvider } from '@/queryAttribution';
import type { TileFilterHandler } from '@/types';
import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning';

import ChartContainer, {
Expand Down Expand Up @@ -447,6 +453,7 @@ const Tile = ({
showReleaseAnnotations,
isLive,
readOnly,
onFilter,

// Properties forwarded by grid layout
className,
Expand Down Expand Up @@ -482,6 +489,8 @@ const Tile = ({
showReleaseAnnotations?: boolean;
isLive?: boolean;
readOnly?: boolean;
/** Set when an ad hoc filter broadcasts to this tile; clicked values update it. */
onFilter?: TileFilterHandler;

// Properties forwarded by grid layout
className?: string;
Expand Down Expand Up @@ -659,6 +668,30 @@ const Tile = ({
() => (filters ? getSqlFilters(filters) : undefined),
[filters],
);

// `source` keeps the handler off rows from other sources, e.g. a linked trace
Comment thread
pulpdrew marked this conversation as resolved.
const searchRowContext = useMemo<RowSidePanelContextProps | undefined>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 major — Side panel's surrounding-context table writes unshaped cells (DateTime, Map, aliases) into ad hoc filters

searchRowContext reaches every table under the tile's RowSidePanelContext, not just the tile's own table. DBRowSidePanel passes it down to ContextSubpanel (components/ContextSidePanel.tsx:313), which renders a separate DBSqlRowTable with the source's default select and without isAdhocFilterTarget. That table skips getAdhocCellCondition. Clicking include on its Timestamp cell adds Timestamp = '2026-…Z'. Per isAdhocComparableDataType, that never equals toString(Timestamp), so every tile on the source goes empty. A Map cell or an aliased column also produces a condition that can't match or that refers to a missing column. Fix: put the shaping in the context itself, e.g. a flag on RowSidePanelContextProps that DBRowTableFieldWithPopover reads, instead of a prop on one table. Or drop onPropertyAddClick for nested tables.

Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to reproduce this but couldn't

Comment thread
pulpdrew marked this conversation as resolved.
Comment thread
pulpdrew marked this conversation as resolved.
() =>
onFilter &&
source &&
getAllowedSourceKinds({
configType: 'builder',
displayType: DisplayType.Search,
}).includes(source.kind)
? {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Side panel "Add to Filters" removes an existing ad hoc condition while the toast says "Added"

DBRowJsonViewer.tsx:553-563 calls onPropertyAddClick with no action and then always shows Added "key = value" to filters. Here, no action is mapped to include. mergeAdhocConditions then toggles the condition off when it already exists, e.g. after a cell click already included ServiceName = frontend. The user sees "Added" while the condition disappears. Fix: map a missing action to a non-toggling add (skip entries already applied), or only toggle when the action comes from the cell popover.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

// Only the cell popover passes an action; the side panel's
// "Add to filters" passes none and shouldn't toggle a condition off.
onPropertyAddClick: (key, value, action) =>
Comment thread
pulpdrew marked this conversation as resolved.
Comment thread
pulpdrew marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — The side panel's "Add to Filters" skips the DateTime guard that table cells use

Table cells go through getAdhocCellCondition, which never offers a filter on a DateTime cell. The side panel does not. DBRowJsonViewer.tsx:503-512 only blocks fields named exactly Timestamp and TimestampTime, and this handler forwards whatever key and value it receives. So clicking "Add to Filters" on any other DateTime column (for example a custom EventTime or ObservedTimestamp) adds a condition like toString(EventTime) = '2026-10-15T12:00:00Z'. That condition can never match, and it empties every tile the filter reaches. Fix: in the side panel path, check the field's ClickHouse type with isAdhocComparableDataType and drop the action for DateTime fields, the same as getAdhocCellCondition does.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

onFilter(
[{ key, value: String(value) }],
action === 'include' || action === 'exclude' ? action : 'add',
),
source,
isAdhocFilterTarget: true,
}
: undefined,
[onFilter, source],
);
const promqlFilters = useMemo(
() => (filters ? getPromqlFilters(filters) : undefined),
[filters],
Expand Down Expand Up @@ -1379,6 +1412,7 @@ const Tile = ({
queryKeyPrefix={'search'}
variant="default"
errorVariant="collapsible"
context={searchRowContext}
/>
</ChartContainer>
)}
Expand Down Expand Up @@ -1460,6 +1494,7 @@ const Tile = ({
readOnly,
tileVariables,
variableNames,
searchRowContext,
],
);

Expand Down Expand Up @@ -1910,6 +1945,7 @@ function DBDashboardPage({
setFilterValue,
adhocConditionsByFilterId,
setAdhocConditions,
applyAdhocAction,
setFilterValueEntries,
filterValueEntries,
ignoredFilterExpressions,
Expand Down Expand Up @@ -2348,6 +2384,30 @@ function DBDashboardPage({
[where, whereLanguage, getFilterQueriesForSource],
);

// Built once per tile list so each tile's `onFilter` keeps its identity
// across renders; a fresh closure would rebuild the tile's row-panel context
// and re-render every cell in its search table.
const tileFilterHandlers = useMemo(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — PromQL, raw SQL and multi-entry targeting are built but nothing in this PR can use them

Only the Search tile passes onFilter on (as searchRowContext), and Search tiles are always builder configs on Log, Trace or Session sources. Every call also passes exactly one entry. That leaves several things unreachable in this PR: the PromQL branch and the raw SQL $__filters branch of getAdhocFilterTargetsForTile, the multi-entry "some already apply" path of mergeAdhocConditions, and the handlers tileFilterHandlers builds for every non-Search tile. Fix: either limit tileFilterHandlers to DisplayType.Search tiles and drop the PromQL and raw SQL branches until a chart tile actually uses them, or wire them up in this PR.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

const handlers = new Map<string, TileFilterHandler>();
// The filter bar is hidden in kiosk mode, so a click would add conditions
// the viewer can't see or remove.
if (isKioskMode) return handlers;
for (const tile of dashboard?.tiles ?? []) {
const sourceId = 'source' in tile.config ? tile.config.source : undefined;
const targetIds = getAdhocFilterTargetsForTile(
Comment thread
pulpdrew marked this conversation as resolved.
filters,
tile.config,
sourceId,
).map(filter => filter.id);
if (targetIds.length > 0) {
handlers.set(tile.id, (entries, action) =>
applyAdhocAction(targetIds, entries, action),
);
}
}
return handlers;
}, [isKioskMode, dashboard?.tiles, filters, applyAdhocAction]);

const renderTileComponent = useCallback(
(chart: Tile) => {
// Resolve the tile's source ID so per-source-scoped filters can be
Expand All @@ -2367,6 +2427,7 @@ function DBDashboardPage({
isRefreshEnabled ? granularityOverride : (granularity ?? undefined)
}
filters={getTileFilters(tileSourceId)}
onFilter={tileFilterHandlers.get(chart.id)}
variables={variables}
unsatisfiedRequiredFilters={unsatisfiedRequiredFilters}
onTimeRangeSelect={onTimeRangeSelect}
Expand Down Expand Up @@ -2469,6 +2530,7 @@ function DBDashboardPage({
showAlertAnnotations,
showReleaseAnnotations,
getTileFilters,
tileFilterHandlers,
variables,
unsatisfiedRequiredFilters,
moveTargetContainers,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
Text,
Tooltip,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconPlus } from '@tabler/icons-react';

import {
Expand All @@ -25,6 +24,10 @@ import {
import { FilterPill, getFilterOperatorDisplay } from '@/components/FilterPill';

import { AdhocConditionEditor } from './AdhocConditionEditor';
import {
notifyAdhocConditionTooLong,
notifyAdhocFilterFull,
} from './notifications';

type AdhocDashboardFilterProps = {
filter: AdhocDashboardFilterType;
Expand All @@ -47,12 +50,7 @@ export function AdhocDashboardFilter({

const toggleAdding = () => {
if (isAtLimit) {
notifications.show({
id: `adhoc-filter-full-${filter.id}`,
color: 'yellow',
title: `${filter.name} is full`,
message: `A filter holds up to ${ADHOC_FILTER_MAX_CONDITIONS} conditions. Remove one to add another.`,
});
notifyAdhocFilterFull(filter);
return;
}
setIsAdding(!isAdding);
Expand All @@ -61,13 +59,7 @@ export function AdhocDashboardFilter({
// lose the filter's others on reload. Keep the editor open to fix it instead.
const isStorable = (condition: AdhocFilterCondition) => {
if (AdhocFilterConditionSchema.safeParse(condition).success) return true;
const maxLength = AdhocFilterConditionSchema.shape.value.maxLength;
notifications.show({
id: `adhoc-filter-too-long-${filter.id}`,
color: 'yellow',
title: 'Condition is too long',
message: `Keys and values can be up to ${maxLength?.toLocaleString()} characters.`,
});
notifyAdhocConditionTooLong(filter);
return false;
};
const replace = (index: number, condition: AdhocFilterCondition) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,27 @@ describe('getSqlSourceKeys', () => {
),
).toEqual(['ServiceName', "LogAttributes['1']", 'Body.`user`.`id`']);
});

it('leaves out DateTime keys, whose values never match toString()', () => {
expect(
getSqlSourceKeys(
[
{
path: ['Timestamp'],
type: 'DateTime64(9)',
jsType: JSDataType.Date,
},
{ path: ['Day'], type: 'Date', jsType: JSDataType.Date },
{ path: ['Body', 'ts'], type: 'DateTime', jsType: JSDataType.Date },
],
[
column('Timestamp', 'DateTime64(9)'),
column('Day', 'Date'),
column('Body', 'JSON'),
],
),
).toEqual(['Day']);
});
});

describe('getSqlSourceColumnKeys', () => {
Expand All @@ -87,6 +108,15 @@ describe('getSqlSourceColumnKeys', () => {
).toEqual(['ServiceName']);
});

it('leaves out DateTime columns', () => {
expect(
getSqlSourceColumnKeys([
column('Timestamp', "DateTime64(9, 'UTC')"),
column('Day', 'Date'),
]),
).toEqual(['Day']);
});

it('quotes column names that are not bare identifiers', () => {
expect(getSqlSourceColumnKeys([column('service-name', 'String')])).toEqual([
'`service-name`',
Expand Down
29 changes: 29 additions & 0 deletions packages/app/src/components/AdhocDashboardFilter/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
ADHOC_FILTER_MAX_CONDITIONS,
AdhocDashboardFilter,
AdhocFilterConditionSchema,
} from '@hyperdx/common-utils/dist/types';
import { notifications } from '@mantine/notifications';

export function notifyAdhocFilterFull(
filter: Pick<AdhocDashboardFilter, 'id' | 'name'>,
) {
notifications.show({
id: `adhoc-filter-full-${filter.id}`,
color: 'yellow',
title: `${filter.name} is full`,
message: `A filter holds up to ${ADHOC_FILTER_MAX_CONDITIONS} conditions. Remove one to add another.`,
});
}

export function notifyAdhocConditionTooLong(
filter: Pick<AdhocDashboardFilter, 'id'>,
) {
const maxLength = AdhocFilterConditionSchema.shape.value.maxLength;
notifications.show({
id: `adhoc-filter-too-long-${filter.id}`,
color: 'yellow',
title: 'Condition is too long',
message: `Keys and values can be up to ${maxLength?.toLocaleString()} characters.`,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
QueryAttribution,
} from '@hyperdx/common-utils/dist/clickhouse';
import { Field, tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
import { getAdhocFilterSourceKinds } from '@hyperdx/common-utils/dist/filters';
import {
getAdhocFilterSourceKinds,
isAdhocComparableDataType,
} from '@hyperdx/common-utils/dist/filters';
import {
AdhocDashboardFilter,
TSource,
Expand Down Expand Up @@ -63,16 +66,22 @@ export function getSqlSourceKeys(fields: Field[], columns: ColumnMeta[]) {
const jsonColumns = columnNames([JSDataType.JSON]);
const mapColumns = columnNames([JSDataType.Map]);
const keys = fields
.filter(field => field.path.length > 1 || isJSDataTypeScalar(field.jsType))
.filter(
field =>
(field.path.length > 1 || isJSDataTypeScalar(field.jsType)) &&
isAdhocComparableDataType(field.type),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — DateTime keys are now missing from the condition editor's key suggestions, though conditions on them still work

The reason given (query output is ISO UTC, so it never equals toString()) applies to values clicked in the table. It does not apply to conditions typed in the editor. The editor offers LIKE and =~, and a condition such as Timestamp LIKE '2026-10-15%', or = with toString's YYYY-MM-DD hh:mm:ss format, matches correctly. With this change, users have to type DateTime keys by hand. Fix: keep the isAdhocComparableDataType filter in the click-to-filter path (getAdhocCellCondition) and remove it from getSqlSourceKeys and getSqlSourceColumnKeys. If DateTime values are the real concern, filter only the =/!= value suggestions.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

)
.map(field => mergePath(field.path, jsonColumns, mapColumns));
return toQuotedKeys(keys, columns);
}

/** A SQL source's scalar columns, the only keys known without field metadata. */
export function getSqlSourceColumnKeys(columns: ColumnMeta[]) {
const keys = columns
.filter(column =>
isJSDataTypeScalar(convertCHDataTypeToJSType(column.type)),
.filter(
column =>
isJSDataTypeScalar(convertCHDataTypeToJSType(column.type)) &&
isAdhocComparableDataType(column.type),
)
.map(column => column.name);
return toQuotedKeys(keys, columns);
Expand Down
7 changes: 6 additions & 1 deletion packages/app/src/components/DBRowSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,13 @@ export type RowSidePanelContextProps = {
dbSqlRowTableConfig?: BuilderChartConfigWithDateRange;
isChildModalOpen?: boolean;
setChildModalOpen?: (open: boolean) => void;
source?: TLogSource | TTraceSource;
source?: TSource;
onOpenLinkedTrace?: (link: SpanLinkData) => void;
/**
* onPropertyAddClick updates ad hoc dashboard filters. Tables shape
* each cell's key and value for that with getAdhocCellCondition.
*/
isAdhocFilterTarget?: boolean;
};

export const RowSidePanelContext = createContext<RowSidePanelContextProps>({});
Expand Down
Loading
Loading