From d5963c6a89726ca9aabcc5a6ec54c4bf0c884324 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Sun, 19 Jul 2026 19:25:33 +0100 Subject: [PATCH 01/31] prototype: search page view switcher (list/timeseries/number/table/bar/pie/treemap/heatmap/patterns) - Add global DisplayType.Treemap + DBTreemapChart renderer (recharts), wired into Chart Explorer tabs/preview and raw-SQL/query-param maps - Unify search analysisMode into a single URL-backed view switcher with legacy aliasing (results->list, delta->heatmap, pattern->patterns) - Add inline aggregation config strip (aggFn/value/groupBy/topN) with smart defaults - Refactor DBSearchPage render into a per-view switch; hide histogram for aggregated views; move mode selection out of the filter sidebar into a top toolbar Co-authored-by: Cursor --- packages/app/src/DBSearchPage.tsx | 384 +++++++++++------- .../src/components/ChartEditor/constants.tsx | 1 + .../app/src/components/ChartEditor/utils.ts | 2 + .../DBEditTimeChartForm/ChartPreviewPanel.tsx | 10 + .../DBEditTimeChartForm/EditTimeChartForm.tsx | 7 + .../components/DBEditTimeChartForm/utils.ts | 2 + .../src/components/DBSearchPageFilters.tsx | 34 +- .../app/src/components/DBTreemapChart.tsx | 154 +++++++ .../components/Search/SearchAggControls.tsx | 186 +++++++++ .../app/src/components/Search/searchViews.tsx | 208 ++++++++++ .../tests/e2e/page-objects/DashboardPage.ts | 3 +- .../common-utils/src/core/builderToRawSql.ts | 5 +- packages/common-utils/src/rawSqlParams.ts | 5 + packages/common-utils/src/types.ts | 1 + 14 files changed, 827 insertions(+), 175 deletions(-) create mode 100644 packages/app/src/components/DBTreemapChart.tsx create mode 100644 packages/app/src/components/Search/SearchAggControls.tsx create mode 100644 packages/app/src/components/Search/searchViews.tsx diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 42c2715d32..48315f4a76 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -131,10 +131,26 @@ import { } from '@/utils'; import ChartSQLPreview, { SQLPreview } from './components/ChartSQLPreview'; +import { DBBarChart } from './components/DBBarChart'; +import DBNumberChart from './components/DBNumberChart'; +import { DBPieChart } from './components/DBPieChart'; import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; +import DBTableChart from './components/DBTableChart'; +import { DBTreemapChart } from './components/DBTreemapChart'; import PatternTable from './components/PatternTable'; import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart'; import DirectTraceSidePanel from './components/Search/DirectTraceSidePanel'; +import { + SearchAggControls, + useSearchAggConfig, +} from './components/Search/SearchAggControls'; +import { + isAggregatedSearchView, + SearchViewSwitcher, + searchViewToDisplayType, + useSearchView, + viewShowsHistogram, +} from './components/Search/searchViews'; import SourceSchemaPreview, { isSourceSchemaPreviewEnabled, } from './components/SourceSchemaPreview'; @@ -992,14 +1008,14 @@ export function DBSearchPage() { ? '' : (searchedConfig.source ?? ''); - const [analysisMode, setAnalysisMode] = useQueryState( - 'mode', - parseAsStringEnum<'results' | 'delta' | 'pattern'>([ - 'results', - 'delta', - 'pattern', - ]).withDefault('results'), - ); + const [view, setView] = useSearchView(); + const [aggConfig, setAggConfig] = useSearchAggConfig(); + + // Legacy 3-mode value still consumed by the filters sidebar (denoise gating) + // and a few source-capability checks below. New view types collapse onto + // 'results' for those purposes. + const analysisMode: 'results' | 'delta' | 'pattern' = + view === 'patterns' ? 'pattern' : view === 'heatmap' ? 'delta' : 'results'; const [patternColumn, setPatternColumn] = useQueryState( 'patternColumn', @@ -1018,10 +1034,11 @@ export function DBSearchPage() { ); useEffect(() => { - if (analysisMode === 'delta' || analysisMode === 'pattern') { + // Only the raw List view supports live tail. + if (view !== 'list') { setIsLive(false); } - }, [analysisMode, setIsLive]); + }, [view, setIsLive]); const [isFilterSidebarCollapsed, setIsFilterSidebarCollapsed] = useLocalStorage('isFilterSidebarCollapsed', false); @@ -1763,6 +1780,63 @@ export function DBSearchPage() { searchedConfig.select, ]); + // Default group-by for aggregated views, mirroring the histogram's grouping. + const defaultAggGroupBy = useMemo(() => { + switch (searchedSource?.kind) { + case SourceKind.Log: + return searchedSource?.severityTextExpression; + case SourceKind.Trace: + return ( + searchedSource?.statusCodeExpression ?? + searchedSource?.serviceNameExpression + ); + default: + return undefined; + } + }, [searchedSource]); + + // Builds the chart config for an aggregated view (time series / number / + // table / bar / pie / treemap) from the current search config plus the + // inline aggregation controls. The renderers apply their own display-type + // conversion (convertToCategoricalChartConfig, etc.). + const aggViewChartConfig = useMemo(() => { + if (chartConfig == null || !isAggregatedSearchView(view)) { + return undefined; + } + const valueExpression = + aggConfig.aggFn === 'count' ? '' : aggConfig.aggExpr.trim(); + const groupBy = + view === 'number' + ? undefined + : aggConfig.groupBy.trim() || defaultAggGroupBy || undefined; + return { + ...chartConfig, + select: [ + { + aggFn: aggConfig.aggFn, + aggCondition: '', + valueExpression, + }, + ], + groupBy, + orderBy: undefined, + granularity: view === 'timeseries' ? 'auto' : undefined, + dateRange: searchedTimeRange, + displayType: searchViewToDisplayType(view), + with: aliasWith, + seriesLimit: view === 'timeseries' ? undefined : aggConfig.limit, + alignDateRangeToGranularity: false, + dateRangeEndInclusive: true, + } as BuilderChartConfigWithDateRange; + }, [ + chartConfig, + view, + aggConfig, + defaultAggGroupBy, + searchedTimeRange, + aliasWith, + ]); + const onFormSubmit = useCallback>( e => { e.preventDefault(); @@ -2241,7 +2315,7 @@ export function DBSearchPage() { setInputValue={setDisplayedTimeInputValue} onSearch={onTimePickerSearch} onRelativeSearch={onTimePickerRelativeSearch} - showLive={analysisMode === 'results'} + showLive={view === 'list'} isLiveMode={isLive} // Default to relative time mode if the user has made changes to interval and reloaded. defaultRelativeTimeMode={ @@ -2326,7 +2400,6 @@ export function DBSearchPage() { setDenoiseResults={setDenoiseResults} isLive={isLive} analysisMode={analysisMode} - setAnalysisMode={setAnalysisMode} chartConfig={filtersChartConfig} sourceId={inputSourceObj?.id} showDelta={ @@ -2341,22 +2414,37 @@ export function DBSearchPage() { /> )} - {analysisMode === 'pattern' && - histogramTimeChartConfig != null && ( - - - + {chartConfig && histogramTimeChartConfig && ( + + + + setIsFilterSidebarCollapsed(false) } histogramTimeChartConfig={histogramTimeChartConfig} + enableParallelQueries + /> + + + + {view === 'list' && + shouldShowLiveModeHint && + denoiseResults != true && ( + + )} - - {!hasQueryError && ( - - - - )} - - + + {isAggregatedSearchView(view) && ( + + )} + {viewShowsHistogram(view) && !hasQueryError && ( + + - - )} - {analysisMode === 'delta' && - searchedSource != null && - isTraceSource(searchedSource) && ( - - )} - {analysisMode === 'results' && ( - - {chartConfig && histogramTimeChartConfig && ( - <> - - - - setIsFilterSidebarCollapsed(false) - } - histogramTimeChartConfig={histogramTimeChartConfig} - enableParallelQueries - /> - - {shouldShowLiveModeHint && - denoiseResults != true && ( - - )} - - - - - {!hasQueryError && ( - - - - )} - )} {hasQueryError && queryError ? ( <> @@ -2612,6 +2608,112 @@ export function DBSearchPage() { )} + ) : view === 'patterns' ? ( + + + + ) : view === 'heatmap' ? ( + searchedSource != null && isTraceSource(searchedSource) ? ( + + + + ) : ( + + + Event deltas are only available for trace sources. + + + ) + ) : isAggregatedSearchView(view) ? ( + + {view === 'timeseries' && aggViewChartConfig && ( + + )} + {view === 'number' && aggViewChartConfig && ( + + )} + {view === 'table' && aggViewChartConfig && ( + + )} + {view === 'bar' && aggViewChartConfig && ( + + )} + {view === 'pie' && aggViewChartConfig && ( + + )} + {view === 'treemap' && aggViewChartConfig && ( + + )} + ) : ( {chartConfig && diff --git a/packages/app/src/components/ChartEditor/constants.tsx b/packages/app/src/components/ChartEditor/constants.tsx index 73b3adcfd1..cc3d86cea6 100644 --- a/packages/app/src/components/ChartEditor/constants.tsx +++ b/packages/app/src/components/ChartEditor/constants.tsx @@ -39,6 +39,7 @@ LIMIT `, [DisplayType.Pie]: SINGLE_SERIES_GROUPED_PLACEHOLDER_SQL, [DisplayType.Bar]: SINGLE_SERIES_GROUPED_PLACEHOLDER_SQL, + [DisplayType.Treemap]: SINGLE_SERIES_GROUPED_PLACEHOLDER_SQL, [DisplayType.Number]: `SELECT count() FROM diff --git a/packages/app/src/components/ChartEditor/utils.ts b/packages/app/src/components/ChartEditor/utils.ts index 8c322ae9e9..dc475b034c 100644 --- a/packages/app/src/components/ChartEditor/utils.ts +++ b/packages/app/src/components/ChartEditor/utils.ts @@ -64,12 +64,14 @@ export const isRawSqlDisplayType = ( | DisplayType.StackedBar | DisplayType.Pie | DisplayType.Bar + | DisplayType.Treemap | DisplayType.Number => displayType === DisplayType.Table || displayType === DisplayType.Line || displayType === DisplayType.StackedBar || displayType === DisplayType.Pie || displayType === DisplayType.Bar || + displayType === DisplayType.Treemap || displayType === DisplayType.Number; /** diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx index f7cacf916f..c14ca066b6 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx @@ -30,6 +30,7 @@ import { DBPieChart } from '@/components/DBPieChart'; import DBSqlRowTableWithSideBar from '@/components/DBSqlRowTableWithSidebar'; import DBTableChart from '@/components/DBTableChart'; import { DBTimeChart } from '@/components/DBTimeChart'; +import { DBTreemapChart } from '@/components/DBTreemapChart'; import EmptyState from '@/components/EmptyState'; import PatternTable from '@/components/PatternTable'; import { @@ -249,6 +250,15 @@ export function ChartPreviewPanel({ /> )} + {queryReady && queriedConfig != null && activeTab === 'treemap' && ( +
+ +
+ )} {queryReady && queriedConfig != null && activeTab === 'number' && (
Pie + } + > + Treemap + } diff --git a/packages/app/src/components/DBEditTimeChartForm/utils.ts b/packages/app/src/components/DBEditTimeChartForm/utils.ts index dc49ad17e8..4490af8bc5 100644 --- a/packages/app/src/components/DBEditTimeChartForm/utils.ts +++ b/packages/app/src/components/DBEditTimeChartForm/utils.ts @@ -104,6 +104,8 @@ export function displayTypeToActiveTab(displayType: DisplayType): string { return 'pie'; case DisplayType.Bar: return 'bar'; + case DisplayType.Treemap: + return 'treemap'; case DisplayType.Number: return 'number'; case DisplayType.Heatmap: diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx index 9a7feddcd2..d29db231d0 100644 --- a/packages/app/src/components/DBSearchPageFilters.tsx +++ b/packages/app/src/components/DBSearchPageFilters.tsx @@ -25,7 +25,6 @@ import { NumberInput, ScrollArea, Stack, - Tabs, Text, TextInput, Tooltip, @@ -48,7 +47,6 @@ import { IconSitemap, } from '@tabler/icons-react'; -import { IS_CLICKHOUSE_BUILD } from '@/config'; import { useColumns, useGetValuesDistribution, @@ -1063,9 +1061,7 @@ const DBSearchPageFiltersComponent = ({ isLive, chartConfig, analysisMode, - setAnalysisMode, sourceId, - showDelta, denoiseResults, setDenoiseResults, setFilterRange, @@ -1074,11 +1070,11 @@ const DBSearchPageFiltersComponent = ({ onCollapse, }: { analysisMode: 'results' | 'delta' | 'pattern'; - setAnalysisMode: (mode: 'results' | 'delta' | 'pattern') => void; + setAnalysisMode?: (mode: 'results' | 'delta' | 'pattern') => void; isLive: boolean; chartConfig: BuilderChartConfigWithDateRange; sourceId?: string; - showDelta: boolean; + showDelta?: boolean; denoiseResults: boolean; setDenoiseResults: (denoiseResults: boolean) => void; setFilterRange: (key: string, range: { min: number; max: number }) => void; @@ -1661,32 +1657,6 @@ const DBSearchPageFiltersComponent = ({ )} - - setAnalysisMode(value as 'results' | 'delta' | 'pattern') - } - orientation="vertical" - w="100%" - placement="right" - > - - - Results Table - - {showDelta && ( - - Event Deltas - - )} - {!IS_CLICKHOUSE_BUILD && ( - - Event Patterns - - )} - - - {isSharedFiltersVisible && ( 0} diff --git a/packages/app/src/components/DBTreemapChart.tsx b/packages/app/src/components/DBTreemapChart.tsx new file mode 100644 index 0000000000..8985c6ef10 --- /dev/null +++ b/packages/app/src/components/DBTreemapChart.tsx @@ -0,0 +1,154 @@ +import { memo, useMemo } from 'react'; +import { ResponsiveContainer, Tooltip, Treemap } from 'recharts'; + +import type { NumberFormat } from '@/types'; +import { truncateMiddle } from '@/utils'; + +import { + CategoricalChartProps, + useCategoricalChart, +} from './charts/CategoricalChart'; +import ChartContainer from './charts/ChartContainer'; +import ChartErrorState from './charts/ChartErrorState'; +import { ChartTooltipContainer, ChartTooltipItem } from './charts/ChartTooltip'; + +type TreemapDatum = { + name: string; + size: number; + color: string; +}; + +const TreemapChartTooltip = memo( + ({ + active, + payload, + numberFormat, + }: { + active?: boolean; + payload?: { payload: TreemapDatum }[]; + numberFormat?: NumberFormat; + }) => { + if (!active || !payload?.length) return null; + const datum = payload[0].payload; + return ( + + + + ); + }, +); + +// Recharts calls this for every node; it receives the geometry plus our datum +// fields (color/name/size) spread from the data array. +function TreemapCell({ + x, + y, + width, + height, + name, + color, +}: { + x?: number; + y?: number; + width?: number; + height?: number; + name?: string; + color?: string; +}) { + if (x == null || y == null || width == null || height == null) return null; + const showLabel = width > 48 && height > 22; + return ( + + + {showLabel && name ? ( + + {truncateMiddle(name, Math.floor(width / 7))} + + ) : null} + + ); +} + +export const DBTreemapChart = (props: CategoricalChartProps) => { + const { title, errorVariant } = props; + const { + resolvedNumberFormat, + toolbarItems, + data, + isLoading, + isError, + error, + chartData, + responseFormatError, + } = useCategoricalChart(props); + + const treemapData = useMemo( + () => + chartData.map(entry => ({ + name: entry.label, + size: entry.value, + color: entry.color, + })), + [chartData], + ); + + return ( + + {isLoading && !data ? ( +
+ Loading Chart Data... +
+ ) : isError && error ? ( + + ) : responseFormatError ? ( + + ) : data?.data.length === 0 ? ( +
+ No data found within time range. +
+ ) : ( + + } + > + + } + /> + + + )} +
+ ); +}; diff --git a/packages/app/src/components/Search/SearchAggControls.tsx b/packages/app/src/components/Search/SearchAggControls.tsx new file mode 100644 index 0000000000..bd14bcc7a9 --- /dev/null +++ b/packages/app/src/components/Search/SearchAggControls.tsx @@ -0,0 +1,186 @@ +import { useCallback, useMemo } from 'react'; +import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'; +import { Group, NumberInput, Select, Text, TextInput } from '@mantine/core'; + +import type { SearchView } from './searchViews'; + +type AggFn = + | 'count' + | 'count_distinct' + | 'sum' + | 'avg' + | 'min' + | 'max' + | 'p50' + | 'p90' + | 'p95' + | 'p99'; + +const AGG_FN_OPTIONS: { value: AggFn; label: string }[] = [ + { value: 'count', label: 'Count' }, + { value: 'count_distinct', label: 'Count distinct' }, + { value: 'sum', label: 'Sum' }, + { value: 'avg', label: 'Avg' }, + { value: 'min', label: 'Min' }, + { value: 'max', label: 'Max' }, + { value: 'p50', label: 'p50' }, + { value: 'p90', label: 'p90' }, + { value: 'p95', label: 'p95' }, + { value: 'p99', label: 'p99' }, +]; + +const DEFAULT_AGG_LIMIT = 20; + +export interface SearchAggConfig { + aggFn: AggFn; + aggExpr: string; + groupBy: string; + limit: number; +} + +/** URL-backed aggregation config for the search view switcher. */ +export function useSearchAggConfig(): [ + SearchAggConfig, + (patch: Partial) => void, +] { + const [state, setState] = useQueryStates({ + agg: parseAsString.withDefault('count'), + aggExpr: parseAsString.withDefault(''), + groupBy: parseAsString.withDefault(''), + limit: parseAsInteger.withDefault(DEFAULT_AGG_LIMIT), + }); + + const config = useMemo( + () => ({ + aggFn: state.agg as AggFn, + aggExpr: state.aggExpr, + groupBy: state.groupBy, + limit: state.limit, + }), + [state.agg, state.aggExpr, state.groupBy, state.limit], + ); + + const setConfig = useCallback( + (patch: Partial) => { + setState({ + ...(patch.aggFn != null ? { agg: patch.aggFn } : {}), + ...(patch.aggExpr != null ? { aggExpr: patch.aggExpr } : {}), + ...(patch.groupBy != null ? { groupBy: patch.groupBy } : {}), + ...(patch.limit != null ? { limit: patch.limit } : {}), + }); + }, + [setState], + ); + + return [config, setConfig]; +} + +export function SearchAggControls({ + view, + config, + onChange, + defaultGroupBy, + onSubmit, +}: { + view: SearchView; + config: SearchAggConfig; + onChange: (patch: Partial) => void; + defaultGroupBy?: string; + onSubmit: () => void; +}) { + const needsExpr = config.aggFn !== 'count'; + // Number collapses to a single value: no group-by / limit. + const showGroupBy = view !== 'number'; + const showLimit = + view === 'table' || view === 'bar' || view === 'pie' || view === 'treemap'; + + return ( + + + Aggregate + + - setRefreshFrequency(value ? parseInt(value, 10) : null) - } - allowDeselect={false} - comboboxProps={{ - withinPortal: true, - zIndex: 1000, - }} - /> - - - )} diff --git a/packages/app/src/components/TimePicker/TimePicker.tsx b/packages/app/src/components/TimePicker/TimePicker.tsx index 6d446b9bf7..4743fe4b11 100644 --- a/packages/app/src/components/TimePicker/TimePicker.tsx +++ b/packages/app/src/components/TimePicker/TimePicker.tsx @@ -4,7 +4,6 @@ import { useAtom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; import { useHotkeys } from 'react-hotkeys-hook'; import { - ActionIcon, Button, Card, CloseButton, @@ -108,7 +107,8 @@ const TimePickerComponent = ({ defaultRelativeTimeMode = false, width = 350, size = 'sm', - onToggleLive, + rightSection, + rightSectionWidth, }: { inputValue: string; setInputValue: (str: string) => any; @@ -121,11 +121,12 @@ const TimePickerComponent = ({ width?: number | string; size?: 'xs' | 'sm'; /** - * When provided, renders a live-tail toggle inside the input's right section. - * Called to start (when paused) or stop (when live) streaming. `isLiveMode` - * drives the toggle's on/off appearance. + * Custom content for the input's right section (e.g. a live-tail control). + * When set, it replaces the default keyboard-hint affordance and the live + * indicator moves off the left icon to avoid a duplicate. */ - onToggleLive?: () => void; + rightSection?: React.ReactNode; + rightSectionWidth?: number | string; }) => { const { userPreferences: { timeFormat }, @@ -281,7 +282,7 @@ const TimePickerComponent = ({ ) : ( @@ -295,34 +296,14 @@ const TimePickerComponent = ({ }, }} rightSection={ - onToggleLive ? ( - - { - e.stopPropagation(); - onToggleLive(); - }} - > - - - - ) : opened ? ( + rightSection ?? + (opened ? ( d - ) : null + ) : null) } + rightSectionWidth={rightSectionWidth} rightSectionPointerEvents="auto" value={value} onChange={event => onChange(event.currentTarget.value)} From 8bff888f403136e5cdbf9e3242cb50963e86160a Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Mon, 20 Jul 2026 15:42:43 +0100 Subject: [PATCH 11/31] Polish live-tail control: labeled Live toggle + clock cadence Co-authored-by: Cursor --- packages/app/src/DBSearchPage.tsx | 54 +++++++++++++++++++------------ 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index ffe4ded0c0..5a78a8f7eb 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -76,6 +76,7 @@ import { IconBolt, IconCheck, IconChevronDown, + IconClock, IconCode, IconPlayerPlay, IconPlus, @@ -304,41 +305,48 @@ function SearchLiveControl({ return ( e.stopPropagation()} onMouseDown={e => e.stopPropagation()} > - } aria-pressed={isLive} onClick={onToggle} > - - + Live + {isLive && ( - + - - - {cadenceLabel} - - - + + + + + {cadenceLabel} + + + + Refresh every @@ -2425,7 +2433,11 @@ export function DBSearchPage() { ) : undefined } rightSectionWidth={ - view === 'list' && denoiseResults != true && isLive ? 76 : 34 + view === 'list' && denoiseResults != true + ? isLive + ? 138 + : 72 + : undefined } // Default to relative time mode if the user has made changes to interval and reloaded. defaultRelativeTimeMode={ From a2e8d1c16c27c86a458b2f31daa353e21a1c5a09 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Mon, 20 Jul 2026 15:47:55 +0100 Subject: [PATCH 12/31] Move live control out of input into a connected Live + cadence pill Co-authored-by: Cursor --- packages/app/src/DBSearchPage.tsx | 95 ++++++++----------- .../src/components/TimePicker/TimePicker.tsx | 18 +--- 2 files changed, 44 insertions(+), 69 deletions(-) diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 5a78a8f7eb..6d3d58cb82 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -63,7 +63,6 @@ import { Stack, Text, Tooltip, - UnstyledButton, } from '@mantine/core'; import { useDebouncedCallback, @@ -303,25 +302,30 @@ function SearchLiveControl({ o => o.value === String(refreshFrequency), )?.label ?? `${Math.round(refreshFrequency / 1000)}s`; + // Active state is signalled with brand-colored content (matching the "Live + // Tail" treatment in the datetime input) rather than a fill, so both segments + // stay the same variant and read as one connected control. + const activeContentStyles = isLive + ? { + label: { color: 'var(--color-text-brand)' }, + section: { color: 'var(--color-text-brand)' }, + } + : undefined; + return ( - e.stopPropagation()} - onMouseDown={e => e.stopPropagation()} - > + @@ -329,24 +333,16 @@ function SearchLiveControl({ {isLive && ( - - - - - {cadenceLabel} - - - - + Refresh every @@ -366,7 +362,7 @@ function SearchLiveControl({ )} - + ); } @@ -2415,30 +2411,6 @@ export function DBSearchPage() { onRelativeSearch={onTimePickerRelativeSearch} showLive={view === 'list'} isLiveMode={isLive} - rightSection={ - view === 'list' && denoiseResults != true ? ( - - isLive ? setIsLive(false) : handleResumeLiveTail() - } - onSelectCadence={ms => { - setRefreshFrequency(ms); - if (!isLive) { - handleResumeLiveTail(); - } - }} - /> - ) : undefined - } - rightSectionWidth={ - view === 'list' && denoiseResults != true - ? isLive - ? 138 - : 72 - : undefined - } // Default to relative time mode if the user has made changes to interval and reloaded. defaultRelativeTimeMode={ isLive && interval !== LIVE_TAIL_DURATION_MS @@ -2446,6 +2418,21 @@ export function DBSearchPage() { width="100%" size="xs" /> + {view === 'list' && denoiseResults != true && ( + + isLive ? setIsLive(false) : handleResumeLiveTail() + } + onSelectCadence={ms => { + setRefreshFrequency(ms); + if (!isLive) { + handleResumeLiveTail(); + } + }} + /> + )} diff --git a/packages/app/src/components/TimePicker/TimePicker.tsx b/packages/app/src/components/TimePicker/TimePicker.tsx index 4743fe4b11..18f14f6e54 100644 --- a/packages/app/src/components/TimePicker/TimePicker.tsx +++ b/packages/app/src/components/TimePicker/TimePicker.tsx @@ -107,8 +107,6 @@ const TimePickerComponent = ({ defaultRelativeTimeMode = false, width = 350, size = 'sm', - rightSection, - rightSectionWidth, }: { inputValue: string; setInputValue: (str: string) => any; @@ -120,13 +118,6 @@ const TimePickerComponent = ({ defaultRelativeTimeMode?: boolean; width?: number | string; size?: 'xs' | 'sm'; - /** - * Custom content for the input's right section (e.g. a live-tail control). - * When set, it replaces the default keyboard-hint affordance and the live - * indicator moves off the left icon to avoid a duplicate. - */ - rightSection?: React.ReactNode; - rightSectionWidth?: number | string; }) => { const { userPreferences: { timeFormat }, @@ -282,7 +273,7 @@ const TimePickerComponent = ({ ) : ( @@ -296,15 +287,12 @@ const TimePickerComponent = ({ }, }} rightSection={ - rightSection ?? - (opened ? ( + opened && ( d - ) : null) + ) } - rightSectionWidth={rightSectionWidth} - rightSectionPointerEvents="auto" value={value} onChange={event => onChange(event.currentTarget.value)} onClick={toggle} From 3b42714b490eb38f672c6b6c06771f03a78577ad Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Wed, 22 Jul 2026 10:57:54 +0100 Subject: [PATCH 13/31] Add metric sources to the search page Allow metric sources in the search page source selector. Since metrics have no raw rows, the view switcher hides List / Event deltas / Event patterns for metric sources and only offers the aggregated chart views (time series, number, summary table, bar, pie, treemap), auto-switching away from List when a metric source is chosen. The aggregation bar swaps the free-text value expression for the metric name/type picker (MetricNameSelect) and drops count-style agg fns. aggViewChartConfig now carries metricTables + metric select fields, and holds off querying until a metric name is picked (showing a prompt instead of the 'no query support for metric type=gauge' error). Co-authored-by: Cursor --- packages/app/src/DBSearchPage.tsx | 130 ++++++++++++++---- .../components/Search/SearchAggControls.tsx | 72 +++++++++- .../app/src/components/Search/searchViews.tsx | 4 + 3 files changed, 176 insertions(+), 30 deletions(-) diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 6d3d58cb82..a1f5ef1adf 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -43,7 +43,9 @@ import { DisplayType, Filter, isTraceSource, + MetricsDataType, SourceKind, + TMetricSource, TSource, } from '@hyperdx/common-utils/dist/types'; import { @@ -144,6 +146,7 @@ import PatternTable from './components/PatternTable'; import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart'; import DirectTraceSidePanel from './components/Search/DirectTraceSidePanel'; import { + aggFnToSelectFields, type AggSortField, SearchAggControls, useSearchAggConfig, @@ -197,7 +200,11 @@ const LIVE_TAIL_REFRESH_FREQUENCY_OPTIONS = [ ]; const DEFAULT_REFRESH_FREQUENCY = 10000; -const ALLOWED_SOURCE_KINDS = [SourceKind.Log, SourceKind.Trace]; +const ALLOWED_SOURCE_KINDS = [ + SourceKind.Log, + SourceKind.Trace, + SourceKind.Metric, +]; const SearchConfigSchema = z.object({ select: z.string(), source: z.string(), @@ -401,22 +408,27 @@ function SearchResultsCountGroup({ onExpandFilters, histogramTimeChartConfig, enableParallelQueries, + hideCount, }: { isFilterSidebarCollapsed: boolean; onExpandFilters: () => void; histogramTimeChartConfig: BuilderChartConfigWithDateRange; enableParallelQueries?: boolean; + /** Metric sources have no row count — skip the total-count query. */ + hideCount?: boolean; }) { return ( {isFilterSidebarCollapsed && ( )} - + {!hideCount && ( + + )} ); } @@ -854,7 +866,7 @@ function useSearchedConfigToChartConfig( ) { const { data: sourceObj, isLoading } = useSource({ id: source, - kinds: [SourceKind.Log, SourceKind.Trace], + kinds: [SourceKind.Log, SourceKind.Trace, SourceKind.Metric], }); const defaultOrderBy = useDefaultOrderBy(source); @@ -1064,12 +1076,19 @@ export function DBSearchPage() { ); const { data: searchedSource } = useSource({ id: searchedConfig.source, - kinds: [SourceKind.Log, SourceKind.Trace], + kinds: [SourceKind.Log, SourceKind.Trace, SourceKind.Metric], }); const directTraceSource = directTraceId != null && searchedSource?.kind === SourceKind.Trace ? searchedSource : undefined; + // Metric sources have no raw rows to list — only aggregated chart views are + // available, and the value expression is chosen via a metric name picker. + const searchedMetricSource = + searchedSource?.kind === SourceKind.Metric + ? (searchedSource as TMetricSource) + : undefined; + const isMetricSource = searchedMetricSource != null; const chartSourceId = directTraceId != null && !directTraceSource ? '' @@ -1107,6 +1126,14 @@ export function DBSearchPage() { } }, [view, setIsLive]); + useEffect(() => { + // Metric sources can't render the raw List / heatmap / patterns views, so + // fall back to the Time series view when one of those is active. + if (isMetricSource && !isAggregatedSearchView(view)) { + setView('timeseries'); + } + }, [isMetricSource, view, setView]); + const [isFilterSidebarCollapsed, setIsFilterSidebarCollapsed] = useLocalStorage('isFilterSidebarCollapsed', false); @@ -1494,7 +1521,9 @@ export function DBSearchPage() { const queryReady = chartConfig?.from?.databaseName && - chartConfig?.from?.tableName && + // Metric sources have an empty `from.tableName`; the real table is resolved + // per metric type from `metricTables` at query time. + (chartConfig?.from?.tableName || isMetricSource) && chartConfig?.timestampValueExpression; const updateSavedSearch = useUpdateSavedSearch(); @@ -1902,6 +1931,11 @@ export function DBSearchPage() { if (chartConfig == null || !isAggregatedSearchView(view)) { return undefined; } + // Metric queries require a chosen metric name — the renderer has no query + // path for an empty metric. Hold off until the user picks one. + if (searchedMetricSource && !aggConfig.metricName) { + return undefined; + } const valueExpression = aggConfig.aggFn === 'count' ? '' : aggConfig.aggExpr.trim(); const groupBy = @@ -1924,16 +1958,33 @@ export function DBSearchPage() { ? `${groupBy} ${dir}` : `"Value" ${dir}`; } - return { - ...chartConfig, - select: [ - { - aggFn: aggConfig.aggFn, + + // Metric sources aggregate the `Value` column of the metric-type table and + // carry `metricTables` + `metricName`/`metricType` so the renderer can pick + // the right table and filter by metric name. + const selectItem = searchedMetricSource + ? { + ...aggFnToSelectFields(aggConfig.aggFn), + aggCondition: '', + valueExpression: 'Value', + metricName: aggConfig.metricName, + metricType: + (aggConfig.metricType as MetricsDataType) || MetricsDataType.Gauge, + ...(isCategoricalLike ? { alias: 'Value' } : {}), + } + : { + ...aggFnToSelectFields(aggConfig.aggFn), aggCondition: '', valueExpression, ...(isCategoricalLike ? { alias: 'Value' } : {}), - }, - ], + }; + + return { + ...chartConfig, + ...(searchedMetricSource + ? { metricTables: searchedMetricSource.metricTables } + : {}), + select: [selectItem], groupBy, orderBy, granularity: view === 'timeseries' ? 'auto' : undefined, @@ -1956,6 +2007,7 @@ export function DBSearchPage() { defaultAggGroupBy, searchedTimeRange, aliasWith, + searchedMetricSource, ]); const onFormSubmit = useCallback>( @@ -2054,7 +2106,9 @@ export function DBSearchPage() { dbSqlRowTableConfig, isChildModalOpen: isDrawerChildModalOpen, setChildModalOpen: setDrawerChildModalOpen, - source: searchedSource, + // The row side panel is only used by the List view (log/trace sources). + source: + searchedSource?.kind === SourceKind.Metric ? undefined : searchedSource, }), [ searchFilters.setFilterValue, @@ -2519,6 +2573,7 @@ export function DBSearchPage() { } histogramTimeChartConfig={histogramTimeChartConfig} enableParallelQueries + hideCount={isMetricSource} /> )} - + {!isMetricSource && ( + + )} @@ -2628,6 +2685,7 @@ export function DBSearchPage() { onChange={setAggConfig} defaultGroupBy={defaultAggGroupBy} onSubmit={onSubmit} + metricSource={searchedMetricSource} /> )} {viewShowsHistogram(view) && !hasQueryError && ( @@ -2820,6 +2878,22 @@ export function DBSearchPage() { ) ) : isAggregatedSearchView(view) ? ( + {isMetricSource && !aggConfig.metricName && ( + + + Select a metric to visualize + + + Choose a metric name from the aggregation bar above. + + + )} {view === 'timeseries' && aggViewChartConfig && ( o.value !== 'count' && o.value !== 'count_distinct', +); + +/** + * Translate a UI agg-fn (which includes percentile shorthands like `p95`) + * into the `select[]` shape understood by the chart config renderer. The + * percentile options map onto `{ aggFn: 'quantile', level }`. + */ +export function aggFnToSelectFields( + aggFn: AggFn, +): { aggFn: string } | { aggFn: 'quantile'; level: number } { + if (['p50', 'p90', 'p95', 'p99'].includes(aggFn)) { + return { + aggFn: 'quantile', + level: Number.parseFloat(aggFn.replace('p', '0.')), + }; + } + return { aggFn }; +} + const DEFAULT_AGG_LIMIT = 20; export type AggSortField = 'value' | 'name'; @@ -44,6 +73,10 @@ export interface SearchAggConfig { sortDir: AggSortDirection; /** Line vs. bar for the Time series view. */ chartType: TimeseriesChartType; + /** Metric name (metric sources only). */ + metricName: string; + /** Metric type: gauge / sum / histogram (metric sources only). */ + metricType: string; } /** URL-backed aggregation config for the search view switcher. */ @@ -59,6 +92,8 @@ export function useSearchAggConfig(): [ sort: parseAsString.withDefault('value'), sortDir: parseAsString.withDefault('desc'), ts: parseAsString.withDefault('bar'), + metric: parseAsString.withDefault(''), + metricType: parseAsString.withDefault(''), }); const config = useMemo( @@ -70,6 +105,8 @@ export function useSearchAggConfig(): [ sort: state.sort as AggSortField, sortDir: state.sortDir as AggSortDirection, chartType: state.ts as TimeseriesChartType, + metricName: state.metric, + metricType: state.metricType, }), [ state.agg, @@ -79,6 +116,8 @@ export function useSearchAggConfig(): [ state.sort, state.sortDir, state.ts, + state.metric, + state.metricType, ], ); @@ -92,6 +131,8 @@ export function useSearchAggConfig(): [ ...(patch.sort != null ? { sort: patch.sort } : {}), ...(patch.sortDir != null ? { sortDir: patch.sortDir } : {}), ...(patch.chartType != null ? { ts: patch.chartType } : {}), + ...(patch.metricName != null ? { metric: patch.metricName } : {}), + ...(patch.metricType != null ? { metricType: patch.metricType } : {}), }); }, [setState], @@ -106,14 +147,21 @@ export function SearchAggControls({ onChange, defaultGroupBy, onSubmit, + metricSource, }: { view: SearchView; config: SearchAggConfig; onChange: (patch: Partial) => void; defaultGroupBy?: string; onSubmit: () => void; + /** When set, the source is a metric source and the value expression input is + * replaced by a metric name/type picker. */ + metricSource?: TMetricSource; }) { - const needsExpr = config.aggFn !== 'count'; + const isMetric = metricSource != null; + // For logs/traces, count needs no value expression; metrics always pick a + // metric name instead of a free-text expression. + const needsExpr = !isMetric && config.aggFn !== 'count'; // Number collapses to a single value: no group-by / limit. const showGroupBy = view !== 'number'; const showLimit = @@ -134,7 +182,7 @@ export function SearchAggControls({ + setRefreshFrequency(value ? parseInt(value, 10) : null) + } + allowDeselect={false} + comboboxProps={{ + withinPortal: true, + zIndex: 1000, + }} + /> + + )} - + )} - {chartConfig && histogramTimeChartConfig && ( - - - - + {analysisMode === 'pattern' && + histogramTimeChartConfig != null && ( + + + setIsFilterSidebarCollapsed(false) } histogramTimeChartConfig={histogramTimeChartConfig} - enableParallelQueries - hideCount={isMetricSource} /> - - - {view === 'list' && ( - ({ - value: column, - label: column, - }))} - activeField={listSort.field} - direction={listSort.direction} - onChange={applyListSort} - onRevert={revertListSort} - canRevert={!!searchedConfig.orderBy} - sqlSlot={ - - } - /> - )} - {(view === 'table' || - view === 'bar' || - view === 'pie' || - view === 'treemap') && ( - { - setAggConfig({ - sort: field as AggSortField, - sortDir: dir, - }); - onSubmit(); - }} - onRevert={() => { - setAggConfig({ sort: 'value', sortDir: 'desc' }); - onSubmit(); - }} - canRevert={ - aggConfig.sort !== 'value' || - aggConfig.sortDir !== 'desc' - } - /> - )} - {view === 'list' && ( - + + {!hasQueryError && ( + + + + )} + + + + + )} + {analysisMode === 'delta' && + searchedSource != null && + isTraceSource(searchedSource) && ( + + )} + {analysisMode === 'results' && ( + + {chartConfig && histogramTimeChartConfig && ( + <> + + + + setIsFilterSidebarCollapsed(false) } + histogramTimeChartConfig={histogramTimeChartConfig} + enableParallelQueries /> - )} - {!isMetricSource && ( - + {shouldShowLiveModeHint && + denoiseResults != true && ( + + )} + + + + + {!hasQueryError && ( + + - )} - - - - {isAggregatedSearchView(view) && ( - - )} - {viewShowsHistogram(view) && !hasQueryError && ( - - - + + )} + )} {hasQueryError && queryError ? ( <> @@ -2835,134 +2619,6 @@ export function DBSearchPage() { )}
- ) : view === 'patterns' ? ( - - - - ) : view === 'heatmap' ? ( - searchedSource != null && isTraceSource(searchedSource) ? ( - - - - ) : ( - - - Event deltas are only available for trace sources. - - - ) - ) : isAggregatedSearchView(view) ? ( - - {isMetricSource && !aggConfig.metricName && ( - - - Select a metric to visualize - - - Choose a metric name from the aggregation bar above. - - - )} - {view === 'timeseries' && aggViewChartConfig && ( - { - setAggConfig({ - chartType: - type === DisplayType.Line ? 'line' : 'bar', - }); - onSubmit(); - }} - showMVOptimizationIndicator={false} - queryKeyPrefix={QUERY_KEY_PREFIX} - /> - )} - {view === 'number' && aggViewChartConfig && ( - - )} - {view === 'table' && aggViewChartConfig && ( - - )} - {view === 'bar' && aggViewChartConfig && ( - - )} - {view === 'pie' && aggViewChartConfig && ( - - )} - {view === 'treemap' && aggViewChartConfig && ( - - )} - ) : ( void; isBeta?: boolean; + badgeLabel?: string; + badgeColor?: string; isActive?: boolean; }) => { const { pathname, isCollapsed } = React.useContext(AppNavContext); @@ -325,14 +329,14 @@ export const AppNavLink = ({ {icon} {!isCollapsed && {label}} - {!isCollapsed && isBeta && ( + {!isCollapsed && (isBeta || badgeLabel) && ( - Beta + {badgeLabel ?? 'Beta'} )} {!isCollapsed && onToggle && ( diff --git a/packages/app/src/components/AppNav/AppNav.tsx b/packages/app/src/components/AppNav/AppNav.tsx index 9a9a877c38..c2dd22e33e 100644 --- a/packages/app/src/components/AppNav/AppNav.tsx +++ b/packages/app/src/components/AppNav/AppNav.tsx @@ -19,6 +19,7 @@ import { IconArrowBarToLeft, IconBell, IconChartDots, + IconCompass, IconDeviceFloppy, IconDeviceLaptop, IconLayoutGrid, @@ -399,6 +400,14 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) { onToggle={() => setIsSavedSearchExpanded(!isSavedSearchExpanded)} /> + {/* Explore (experimental) */} + } + href="/explore" + badgeLabel="Experimental" + /> + {!isCollapsed && (
diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx index 8dd4df5806..4a5abed176 100644 --- a/packages/app/src/components/DBSearchPageFilters.tsx +++ b/packages/app/src/components/DBSearchPageFilters.tsx @@ -25,6 +25,7 @@ import { NumberInput, ScrollArea, Stack, + Tabs, Text, TextInput, Tooltip, @@ -47,6 +48,7 @@ import { IconSitemap, } from '@tabler/icons-react'; +import { IS_CLICKHOUSE_BUILD } from '@/config'; import { useColumns, useGetValuesDistribution, @@ -1061,7 +1063,9 @@ const DBSearchPageFiltersComponent = ({ isLive, chartConfig, analysisMode, + setAnalysisMode, sourceId, + showDelta, denoiseResults, setDenoiseResults, setFilterRange, @@ -1658,6 +1662,33 @@ const DBSearchPageFiltersComponent = ({ )} + {setAnalysisMode && ( + + setAnalysisMode(value as 'results' | 'delta' | 'pattern') + } + orientation="vertical" + w="100%" + placement="right" + > + + + Results Table + + {showDelta && ( + + Event Deltas + + )} + {!IS_CLICKHOUSE_BUILD && ( + + Event Patterns + + )} + + + )} {isSharedFiltersVisible && ( 0} From 8431643634096c2e2b14793aa48941c344ed6923 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Wed, 29 Jul 2026 12:58:18 +0100 Subject: [PATCH 15/31] feat(app): reorganize Explore page into context, query, and results bands Restructure the experimental Explore page toolbar into three bands per the new wireframes: - Context band: source selector, "Saved views" button (opens the saved searches flyout), a save-status pill (Unsaved/Saved/edited), contextual Save / Save as... actions, an Alerts dropdown, and an overflow menu. - Query band: query input with an expand/collapse toggle plus time picker, Live, and Run controls. - Results band: a combined stats line (result count, scanned rows, elapsed) above the view switcher and Sort / Columns / generated-SQL / export row. Extract ExploreContextBand and ExploreResultsToolbar into a dedicated components/Explore folder, and add the SavedSearchesFlyout used by the context band. Co-authored-by: Cursor --- packages/app/src/DBExplorePage.tsx | 604 ++++++++++-------- .../components/Explore/ExploreContextBand.tsx | 243 +++++++ .../Explore/ExploreResultsToolbar.tsx | 48 ++ .../SavedSearchesFlyout.module.scss | 29 + .../SavedSearches/SavedSearchesFlyout.tsx | 186 ++++++ 5 files changed, 834 insertions(+), 276 deletions(-) create mode 100644 packages/app/src/components/Explore/ExploreContextBand.tsx create mode 100644 packages/app/src/components/Explore/ExploreResultsToolbar.tsx create mode 100644 packages/app/src/components/SavedSearches/SavedSearchesFlyout.module.scss create mode 100644 packages/app/src/components/SavedSearches/SavedSearchesFlyout.tsx diff --git a/packages/app/src/DBExplorePage.tsx b/packages/app/src/DBExplorePage.tsx index 9c92a735ee..c15a543f05 100644 --- a/packages/app/src/DBExplorePage.tsx +++ b/packages/app/src/DBExplorePage.tsx @@ -11,7 +11,6 @@ import { } from 'react'; import dynamic from 'next/dynamic'; import Head from 'next/head'; -import Link from 'next/link'; import router from 'next/router'; import { formatDistanceToNow } from 'date-fns'; import { @@ -74,11 +73,14 @@ import { import { notifications } from '@mantine/notifications'; import { IconArrowBarToRight, + IconArrowsDiagonal, + IconArrowsDiagonalMinimize2, IconBolt, IconCheck, IconChevronDown, IconClock, IconCode, + IconDownload, IconPlayerPlay, IconPlus, IconStack2, @@ -90,7 +92,6 @@ import { SortingState } from '@tanstack/react-table'; import CodeMirror from '@uiw/react-codemirror'; import { ActiveFilterPills } from '@/components/ActiveFilterPills'; -import { AlertStatusIcon } from '@/components/AlertStatusIcon'; import { ContactSupportText } from '@/components/ContactSupportText'; import { DBSearchPageFilters } from '@/components/DBSearchPageFilters'; import { cleanClickHouseExpression } from '@/components/DBSearchPageFilters/utils'; @@ -100,10 +101,10 @@ import { ErrorBoundary } from '@/components/Error/ErrorBoundary'; import { FavoriteButton } from '@/components/FavoriteButton'; import { InputControlled } from '@/components/InputControlled'; import OnboardingModal from '@/components/OnboardingModal'; +import { SavedSearchesFlyout } from '@/components/SavedSearches/SavedSearchesFlyout'; import SearchWhereInput, { getStoredLanguage, } from '@/components/SearchInput/SearchWhereInput'; -import SearchPageActionBar from '@/components/SearchPageActionBar'; import SearchTotalCountChart from '@/components/SearchTotalCountChart'; import { TableSourceForm } from '@/components/Sources/SourceForm'; import { SourceSelectControlled } from '@/components/SourceSelect'; @@ -142,6 +143,8 @@ import { DBPieChart } from './components/DBPieChart'; import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import DBTableChart from './components/DBTableChart'; import { DBTreemapChart } from './components/DBTreemapChart'; +import { ExploreContextBand } from './components/Explore/ExploreContextBand'; +import { ExploreResultsToolbar } from './components/Explore/ExploreResultsToolbar'; import PatternTable from './components/PatternTable'; import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart'; import DirectTraceSidePanel from './components/Search/DirectTraceSidePanel'; @@ -409,53 +412,19 @@ function ExpandFiltersButton({ onExpand }: { onExpand: () => void }) { ); } -function SearchResultsCountGroup({ - isFilterSidebarCollapsed, - onExpandFilters, - histogramTimeChartConfig, - enableParallelQueries, - hideCount, -}: { - isFilterSidebarCollapsed: boolean; - onExpandFilters: () => void; - histogramTimeChartConfig: BuilderChartConfigWithDateRange; - enableParallelQueries?: boolean; - /** Metric sources have no row count — skip the total-count query. */ - hideCount?: boolean; -}) { - return ( - - {isFilterSidebarCollapsed && ( - - )} - {!hideCount && ( - - )} - - ); -} - function SearchNumRows({ config, - sqlConfig, enabled, searchElapsedMs, isSearching, isLiveTail = false, }: { config: ChartConfigWithDateRange; - sqlConfig?: ChartConfigWithDateRange; enabled: boolean; searchElapsedMs: number | null; isSearching: boolean; isLiveTail?: boolean; }) { - const [statsOpened, { open: openStats, close: closeStats }] = - useDisclosure(false); const { data, isLoading, error } = useExplainQuery(config, { enabled, // Keep the previous row count on screen while a new EXPLAIN runs so the @@ -477,54 +446,65 @@ function SearchNumRows({ const showElapsedLoading = isSearching && !isLiveTail; const showElapsed = showElapsedLoading || searchElapsedMs != null; + return ( + + + {isLoading + ? 'Scanned Rows ...' + : error || numRows == null + ? '' + : `Scanned Rows: ${Number(numRows).toLocaleString()}`} + + {showElapsed && ( + <> + {(hasData || isLoading) && ( + + | + + )} + + {showElapsedLoading + ? 'Elapsed Time: ...' + : `Elapsed Time: ${formatDurationMs(searchElapsedMs!)}`} + + + )} + + ); +} + +// Opens the generated SQL for the current results/timeline config in a modal. +// Kept separate from the stats text so it can live in the results controls row. +function GeneratedSqlButton({ + config, + sqlConfig, +}: { + config: ChartConfigWithDateRange; + sqlConfig?: ChartConfigWithDateRange; +}) { + const [opened, { open, close }] = useDisclosure(false); return ( <> - - - {isLoading - ? 'Scanned Rows ...' - : error || numRows == null - ? '' - : `Scanned Rows: ${Number(numRows).toLocaleString()}`} - - {showElapsed && ( - <> - {(hasData || isLoading) && ( - - | - - )} - - {showElapsedLoading - ? 'Elapsed Time: ...' - : `Elapsed Time: ${formatDurationMs(searchElapsedMs!)}`} - - - )} - {/* The generated-SQL preview is derived purely from config, not the - explain query, so it renders unconditionally. Gating it on explain - loading/data would make it flicker on every live-tail poll, since - each poll changes the dateRange (and thus the explain queryKey). */} - - - - - - + + + + + ); } @@ -1486,6 +1466,12 @@ function DBExplorePage() { const [saveSearchModalState, setSaveSearchModalState] = useState< 'create' | 'update' | undefined >(undefined); + const [ + savedSearchesFlyoutOpened, + { open: openSavedSearchesFlyout, close: closeSavedSearchesFlyout }, + ] = useDisclosure(false); + const [isQueryExpanded, { toggle: toggleQueryExpanded }] = + useDisclosure(false); const chartSearchConfig = useMemo( () => ({ select: searchedConfig.select ?? '', @@ -2280,7 +2266,14 @@ function DBExplorePage() { - + Saved Searches @@ -2347,19 +2340,6 @@ function DBExplorePage() { {savedSearch.tags?.length || 0} - - { - deleteSavedSearch.mutate(savedSearch?.id ?? '', { - onSuccess: () => { - router.push('/search/list'); - }, - }); - }} - onClickSaveAsNew={() => { - setSaveSearchModalState('create'); - }} - /> @@ -2370,69 +2350,52 @@ function DBExplorePage() { className={searchPageStyles.searchForm} > {/* */} - - setIsSourceSchemaPreviewOpen(true)} - isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled( - inputSourceObj, - )} - allowedSourceKinds={ALLOWED_SOURCE_KINDS} - data-testid="source-selector" - style={{ minWidth: 150 }} - /> - setIsSourceSchemaPreviewOpen(false)} - /> - <> - {!savedSearchId ? ( - - ) : ( - - )} - {!IS_LOCAL_MODE && ( - - )} - - + control={control} + name="source" + onCreate={openNewSourceModal} + onEdit={onEditCurrentSource} + onManageSources={onManageSources} + onSchemaPreview={() => setIsSourceSchemaPreviewOpen(true)} + isSchemaPreviewEnabled={isSourceSchemaPreviewEnabled( + inputSourceObj, + )} + allowedSourceKinds={ALLOWED_SOURCE_KINDS} + data-testid="source-selector" + style={{ minWidth: 150 }} + /> + setIsSourceSchemaPreviewOpen(false)} + /> + + } + savedSearchId={savedSearchId} + savedSearchName={savedSearch?.name} + isDirty={formState.isDirty} + isLocalMode={IS_LOCAL_MODE} + alerts={savedSearch?.alerts} + onOpenSavedViews={openSavedSearchesFlyout} + onSaveView={onSaveSearch} + onUpdate={() => setSaveSearchModalState('update')} + onSaveAsNew={() => setSaveSearchModalState('create')} + onOpenAlert={openAlertModal} + onDelete={() => + deleteSavedSearch.mutate(savedSearch?.id ?? '', { + onSuccess: () => { + router.push('/explore'); + }, + }) + } + /> - - + {/* Band 2: Query editor */} + {(() => { + const queryInput = ( - - - - {view === 'list' && denoiseResults != true && ( - - isLive ? setIsLive(false) : handleResumeLiveTail() + ); + const timeControls = ( + <> + { - setRefreshFrequency(ms); - if (!isLive) { - handleResumeLiveTail(); - } - }} + width="100%" + size="xs" /> - )} - - - + {view === 'list' && denoiseResults != true && ( + + isLive ? setIsLive(false) : handleResumeLiveTail() + } + onSelectCadence={ms => { + setRefreshFrequency(ms); + if (!isLive) { + handleResumeLiveTail(); + } + }} + /> + )} + + + ); + const expandToggle = ( + + + {isQueryExpanded ? ( + + ) : ( + + )} + + + ); + return isQueryExpanded ? ( + + + {timeControls} + {expandToggle} + + {queryInput} + + ) : ( + + + {queryInput} + + {expandToggle} + + {timeControls} + + + ); + })()} )} + - - - - setIsFilterSidebarCollapsed(false) - } - histogramTimeChartConfig={histogramTimeChartConfig} - enableParallelQueries - hideCount={isMetricSource} - /> + + ) + } + stats={ + !isMetricSource && ( + + ) + } + filterExpand={ + isFilterSidebarCollapsed && ( + setIsFilterSidebarCollapsed(false)} + /> + ) + } + viewSwitcher={ - - - {view === 'list' && ( - ({ - value: column, - label: column, - }))} - activeField={listSort.field} - direction={listSort.direction} - onChange={applyListSort} - onRevert={revertListSort} - canRevert={!!searchedConfig.orderBy} - sqlSlot={ - - } - /> - )} - {(view === 'table' || - view === 'bar' || - view === 'pie' || - view === 'treemap') && ( - { - setAggConfig({ - sort: field as AggSortField, - sortDir: dir, - }); - onSubmit(); - }} - onRevert={() => { - setAggConfig({ sort: 'value', sortDir: 'desc' }); - onSubmit(); - }} - canRevert={ - aggConfig.sort !== 'value' || - aggConfig.sortDir !== 'desc' - } - /> - )} - {view === 'list' && ( + } + sortControl={ + <> + {view === 'list' && ( + ({ + value: column, + label: column, + }))} + activeField={listSort.field} + direction={listSort.direction} + onChange={applyListSort} + onRevert={revertListSort} + canRevert={!!searchedConfig.orderBy} + sqlSlot={ + + } + /> + )} + {(view === 'table' || + view === 'bar' || + view === 'pie' || + view === 'treemap') && ( + { + setAggConfig({ + sort: field as AggSortField, + sortDir: dir, + }); + onSubmit(); + }} + onRevert={() => { + setAggConfig({ + sort: 'value', + sortDir: 'desc', + }); + onSubmit(); + }} + canRevert={ + aggConfig.sort !== 'value' || + aggConfig.sortDir !== 'desc' + } + /> + )} + + } + columnsControl={ + view === 'list' && ( } /> - )} - {!isMetricSource && ( - - )} - - + ) + } + exportControl={ + + + + + + } + /> {isAggregatedSearchView(view) && ( a.state === AlertState.ALERT)) { + return 'var(--mantine-color-red-filled)'; + } + if (alerts?.some(a => a.state === AlertState.PENDING)) { + return 'var(--mantine-color-orange-filled)'; + } + return undefined; +} + +function SaveStatusPill({ + savedSearchId, + isDirty, +}: { + savedSearchId?: string | null; + isDirty: boolean; +}) { + if (!savedSearchId) { + return ( + + Unsaved + + ); + } + if (isDirty) { + return ( + + edited + + ); + } + return ( + } + data-testid="save-status-pill" + > + Saved + + ); +} + +function AlertsControl({ + savedSearchId, + alerts, + onOpenAlert, +}: { + savedSearchId?: string | null; + alerts?: AlertLike[]; + onOpenAlert: () => void; +}) { + if (!savedSearchId) { + return ( + + ); + } + + const hasAlerts = alerts != null && alerts.length > 0; + + return ( + + + + + + {hasAlerts && ( + + {alerts!.length} alert{alerts!.length > 1 ? 's' : ''} configured + + )} + } onClick={onOpenAlert}> + Create alert… + + {hasAlerts && ( + } + onClick={onOpenAlert} + > + Manage alerts + + )} + + + ); +} + +/** + * Context band for the Explore page: source selector, saved-view selector, + * save-status pill, contextual save actions, alerts, and an overflow menu. + * Presentational: all data/handlers are passed in from the page. + */ +export function ExploreContextBand({ + sourceSelect, + savedSearchId, + savedSearchName, + isDirty, + isLocalMode, + alerts, + onOpenSavedViews, + onSaveView, + onUpdate, + onSaveAsNew, + onOpenAlert, + onDelete, +}: { + /** Rendered source selector (kept in the page so it stays form-controlled). */ + sourceSelect: React.ReactNode; + savedSearchId?: string | null; + savedSearchName?: string; + isDirty: boolean; + isLocalMode: boolean; + alerts?: AlertLike[]; + onOpenSavedViews: () => void; + onSaveView: () => void; + onUpdate: () => void; + onSaveAsNew: () => void; + onOpenAlert: () => void; + onDelete: () => void; +}) { + return ( + + {sourceSelect} + + + + {!savedSearchId ? ( + + ) : isDirty ? ( + <> + + + + ) : null} + {!isLocalMode && ( + + )} + {savedSearchId && ( + + )} + + + ); +} diff --git a/packages/app/src/components/Explore/ExploreResultsToolbar.tsx b/packages/app/src/components/Explore/ExploreResultsToolbar.tsx new file mode 100644 index 0000000000..f1abd06df9 --- /dev/null +++ b/packages/app/src/components/Explore/ExploreResultsToolbar.tsx @@ -0,0 +1,48 @@ +import { Group, Stack } from '@mantine/core'; + +/** + * Results band for the Explore page: a stats line (result/scanned-row counts + * and elapsed time) above a controls row (view switcher on the left; sort, + * columns, generated SQL and export on the right). Purely presentational - + * every piece is passed in as a slot so the page keeps the view-specific logic. + */ +export function ExploreResultsToolbar({ + resultsCount, + stats, + filterExpand, + viewSwitcher, + sortControl, + columnsControl, + sqlPreview, + exportControl, +}: { + resultsCount?: React.ReactNode; + stats?: React.ReactNode; + filterExpand?: React.ReactNode; + viewSwitcher: React.ReactNode; + sortControl?: React.ReactNode; + columnsControl?: React.ReactNode; + sqlPreview?: React.ReactNode; + exportControl?: React.ReactNode; +}) { + return ( + + + {resultsCount} + {stats} + + + + {filterExpand} + {viewSwitcher} + + + {sortControl} + {columnsControl} + {sqlPreview} + {exportControl} + + + + ); +} diff --git a/packages/app/src/components/SavedSearches/SavedSearchesFlyout.module.scss b/packages/app/src/components/SavedSearches/SavedSearchesFlyout.module.scss new file mode 100644 index 0000000000..b10af11678 --- /dev/null +++ b/packages/app/src/components/SavedSearches/SavedSearchesFlyout.module.scss @@ -0,0 +1,29 @@ +.row { + border-radius: 4px; + + &:hover { + background: var(--color-bg-sidenav-link-active); + } +} + +.rowLink { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + padding: 4px 6px; + border-radius: 4px; + color: var(--color-text); + text-decoration: none; + + &:focus-visible { + outline: none; + background: var(--color-bg-sidenav-link-active); + } + + &[data-active] { + color: var(--color-text-sidenav-link-active); + background: var(--color-bg-sidenav-link-active); + } +} diff --git a/packages/app/src/components/SavedSearches/SavedSearchesFlyout.tsx b/packages/app/src/components/SavedSearches/SavedSearchesFlyout.tsx new file mode 100644 index 0000000000..27c1a44708 --- /dev/null +++ b/packages/app/src/components/SavedSearches/SavedSearchesFlyout.tsx @@ -0,0 +1,186 @@ +import { useMemo, useState } from 'react'; +import Link from 'next/link'; +import { SavedSearchListApiResponse } from '@hyperdx/common-utils/dist/types'; +import { + Drawer, + Group, + ScrollArea, + Stack, + Text, + TextInput, + UnstyledButton, +} from '@mantine/core'; +import { IconSearch, IconTable } from '@tabler/icons-react'; + +import { AlertStatusIcon } from '@/components/AlertStatusIcon'; +import EmptyState from '@/components/EmptyState'; +import { FavoriteButton } from '@/components/FavoriteButton'; +import { useFavorites } from '@/favorites'; +import { useSavedSearches } from '@/savedSearch'; + +import classes from './SavedSearchesFlyout.module.scss'; + +function SavedSearchRow({ + savedSearch, + isActive, + onNavigate, + linkPrefix, +}: { + savedSearch: SavedSearchListApiResponse; + isActive: boolean; + onNavigate: () => void; + linkPrefix: string; +}) { + return ( + + + + + {savedSearch.name} + + + + + ); +} + +export function SavedSearchesFlyout({ + opened, + onClose, + currentSavedSearchId, + linkPrefix = '/search', +}: { + opened: boolean; + onClose: () => void; + currentSavedSearchId?: string | null; + /** Base path for saved-search links (e.g. `/search` or `/explore`). */ + linkPrefix?: string; +}) { + const { data: savedSearches, isLoading, isError } = useSavedSearches(); + const { data: favorites } = useFavorites(); + const [search, setSearch] = useState(''); + + const favoritedIds = useMemo( + () => + new Set( + (favorites ?? []) + .filter(f => f.resourceType === 'savedSearch') + .map(f => f.resourceId), + ), + [favorites], + ); + + const filtered = useMemo(() => { + const all = savedSearches ?? []; + const q = search.trim().toLowerCase(); + const matched = q + ? all.filter( + s => + s.name.toLowerCase().includes(q) || + s.tags.some(t => t.toLowerCase().includes(q)), + ) + : all; + return matched.slice().sort((a, b) => a.name.localeCompare(b.name)); + }, [savedSearches, search]); + + const favorited = useMemo( + () => filtered.filter(s => favoritedIds.has(s.id)), + [filtered, favoritedIds], + ); + const others = useMemo( + () => filtered.filter(s => !favoritedIds.has(s.id)), + [filtered, favoritedIds], + ); + + return ( + + + } + value={search} + onChange={e => setSearch(e.currentTarget.value)} + size="xs" + data-testid="saved-searches-flyout-search" + /> + + {isLoading ? ( + + Loading saved searches... + + ) : isError ? ( + + Failed to load saved searches. + + ) : filtered.length === 0 ? ( + } + title={ + search ? 'No matching saved searches' : 'No saved searches yet' + } + /> + ) : ( + + + {favorited.length > 0 && ( +
+ + Favorites + + + {favorited.map(s => ( + + ))} + +
+ )} + +
+ {favorited.length > 0 && ( + + All Saved Searches + + )} + + {others.map(s => ( + + ))} + +
+
+
+ )} +
+
+ ); +} From e6ef3821ee3d3085b51215321ee4a1dcc63371e9 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Wed, 29 Jul 2026 14:50:32 +0100 Subject: [PATCH 16/31] feat(app): add dedicated Explore query editor card Introduce ExploreQueryEditor, a self-contained query editor card for the experimental Explore page, so the Explore layout can evolve without touching the shared SearchWhereInput used by the Search page. - Bordered card with SQL/Lucene language tabs on the left and the time picker, Live, Run, and expand toggle in the header on the right. - Reuses the existing SQL (CodeMirror) and Lucene (autocomplete) editors for the body. - Default the Explore query language to SQL. - Add an opt-in showLineNumbers prop to SQLInlineEditor (default off) and enable it for the Explore SQL editor. Co-authored-by: Cursor --- packages/app/src/DBExplorePage.tsx | 171 +++++---------- .../Explore/ExploreQueryEditor.module.scss | 43 ++++ .../components/Explore/ExploreQueryEditor.tsx | 207 ++++++++++++++++++ .../components/SQLEditor/SQLInlineEditor.tsx | 9 +- 4 files changed, 318 insertions(+), 112 deletions(-) create mode 100644 packages/app/src/components/Explore/ExploreQueryEditor.module.scss create mode 100644 packages/app/src/components/Explore/ExploreQueryEditor.tsx diff --git a/packages/app/src/DBExplorePage.tsx b/packages/app/src/DBExplorePage.tsx index c15a543f05..3fd828b0e7 100644 --- a/packages/app/src/DBExplorePage.tsx +++ b/packages/app/src/DBExplorePage.tsx @@ -73,8 +73,6 @@ import { import { notifications } from '@mantine/notifications'; import { IconArrowBarToRight, - IconArrowsDiagonal, - IconArrowsDiagonalMinimize2, IconBolt, IconCheck, IconChevronDown, @@ -102,9 +100,7 @@ import { FavoriteButton } from '@/components/FavoriteButton'; import { InputControlled } from '@/components/InputControlled'; import OnboardingModal from '@/components/OnboardingModal'; import { SavedSearchesFlyout } from '@/components/SavedSearches/SavedSearchesFlyout'; -import SearchWhereInput, { - getStoredLanguage, -} from '@/components/SearchInput/SearchWhereInput'; +import { getStoredLanguage } from '@/components/SearchInput/SearchWhereInput'; import SearchTotalCountChart from '@/components/SearchTotalCountChart'; import { TableSourceForm } from '@/components/Sources/SourceForm'; import { SourceSelectControlled } from '@/components/SourceSelect'; @@ -144,6 +140,7 @@ import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; import DBTableChart from './components/DBTableChart'; import { DBTreemapChart } from './components/DBTreemapChart'; import { ExploreContextBand } from './components/Explore/ExploreContextBand'; +import { ExploreQueryEditor } from './components/Explore/ExploreQueryEditor'; import { ExploreResultsToolbar } from './components/Explore/ExploreResultsToolbar'; import PatternTable from './components/PatternTable'; import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart'; @@ -589,7 +586,7 @@ function SaveSearchModalComponent({ select: effectiveSelect, where: searchedConfig.where ?? '', whereLanguage: - searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'lucene', + searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'sql', source: searchedConfig.source ?? '', orderBy: searchedConfig.orderBy ?? '', filters: searchedConfig.filters ?? [], @@ -617,7 +614,7 @@ function SaveSearchModalComponent({ select: effectiveSelect, where: searchedConfig.where ?? '', whereLanguage: - searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'lucene', + searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'sql', source: searchedConfig.source ?? '', orderBy: searchedConfig.orderBy ?? '', filters: searchedConfig.filters ?? [], @@ -1147,7 +1144,7 @@ function DBExplorePage() { select: searchedConfig.select || '', where: searchedConfig.where || '', whereLanguage: - searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'lucene', + searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'sql', source: searchedConfig.source || (savedSearchId || directTraceId ? '' : defaultSourceId), @@ -1184,7 +1181,7 @@ function DBExplorePage() { ? searchedSource.defaultTableSelectExpression : undefined), where: _savedSearch?.where ?? '', - whereLanguage: _savedSearch?.whereLanguage ?? 'lucene', + whereLanguage: _savedSearch?.whereLanguage ?? 'sql', source: _savedSearch?.source, filters: _savedSearch?.filters ?? [], orderBy: _savedSearch?.orderBy || defaultOrderBy, @@ -1217,7 +1214,7 @@ function DBExplorePage() { select: searchedConfig?.select ?? '', where: searchedConfig?.where ?? '', whereLanguage: - searchedConfig?.whereLanguage ?? getStoredLanguage() ?? 'lucene', + searchedConfig?.whereLanguage ?? getStoredLanguage() ?? 'sql', source: searchedConfig?.source ?? undefined, filters: searchedConfig?.filters ?? [], orderBy: searchedConfig?.orderBy ?? '', @@ -1259,7 +1256,7 @@ function DBExplorePage() { source: defaultSourceId, where: '', select: '', - whereLanguage: getStoredLanguage() ?? 'lucene', + whereLanguage: getStoredLanguage() ?? 'sql', filters: [], orderBy: '', }); @@ -1478,7 +1475,7 @@ function DBExplorePage() { source: chartSourceId, where: searchedConfig.where ?? '', whereLanguage: - searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'lucene', + searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'sql', filters: searchedConfig.filters ?? [], orderBy: searchedConfig.orderBy ?? '', }), @@ -1551,7 +1548,7 @@ function DBExplorePage() { select: searchedConfig.select ?? '', where: searchedConfig.where ?? '', whereLanguage: - searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'lucene', + searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'sql', source: searchedConfig.source ?? '', orderBy: searchedConfig.orderBy ?? '', filters: searchedConfig.filters ?? [], @@ -2154,7 +2151,7 @@ function DBExplorePage() { setSearchedConfig({ source: null, where: '', - whereLanguage: getStoredLanguage() ?? 'lucene', + whereLanguage: getStoredLanguage() ?? 'sql', filters: [], }); return; @@ -2407,106 +2404,58 @@ function DBExplorePage() { onCreate={onNewSourceCreate} /> {/* Band 2: Query editor */} - {(() => { - const queryInput = ( - - ); - const timeControls = ( - <> - - {view === 'list' && denoiseResults != true && ( - - isLive ? setIsLive(false) : handleResumeLiveTail() + + + { - setRefreshFrequency(ms); - if (!isLive) { - handleResumeLiveTail(); - } - }} + width="100%" + size="xs" /> - )} - - - ); - const expandToggle = ( - - - {isQueryExpanded ? ( - - ) : ( - + {view === 'list' && denoiseResults != true && ( + + isLive ? setIsLive(false) : handleResumeLiveTail() + } + onSelectCadence={ms => { + setRefreshFrequency(ms); + if (!isLive) { + handleResumeLiveTail(); + } + }} + /> )} - - - ); - return isQueryExpanded ? ( - - - {timeControls} - {expandToggle} - - {queryInput} - - ) : ( - - - {queryInput} - - {expandToggle} - - {timeControls} - - - ); - })()} + + + } + /> + void; + enableHotkey?: boolean; + size?: 'xs' | 'sm'; + dateRange?: [Date, Date]; + sourceId?: string; + sqlQueryHistoryType?: string; + luceneQueryHistoryType?: string; + sqlPlaceholder?: string; + lucenePlaceholder?: string; + additionalSuggestions?: string[]; + /** Form field name for the language value; defaults to `${name}Language`. */ + languageName?: string; + /** Whether the editor body is expanded (multiline). */ + isExpanded: boolean; + onToggleExpand: () => void; + /** Right-aligned header controls (time picker, Live, Run, ...). */ + controls?: React.ReactNode; + 'data-testid'?: string; +} & TableConnectionChoice & + UseControllerProps; + +/** + * Explore-only query editor card. Owns the visual chrome (bordered card with a + * header holding the Lucene/SQL language tabs on the left and caller-provided + * controls plus an expand toggle on the right) while reusing the existing + * SQL (CodeMirror) and Lucene (autocomplete) editors for the body. Kept + * separate from the shared SearchWhereInput so the Explore layout can evolve + * without affecting the Search page. + */ +export function ExploreQueryEditor({ + tableConnection, + tableConnections, + control, + name, + onSubmit, + enableHotkey, + size = 'xs', + dateRange, + sourceId, + sqlQueryHistoryType, + luceneQueryHistoryType, + sqlPlaceholder = "SQL WHERE clause (ex. column = 'foo')", + lucenePlaceholder = 'Search your events w/ Lucene ex. column:foo', + additionalSuggestions, + languageName = `${name}Language`, + isExpanded, + onToggleExpand, + controls, + 'data-testid': dataTestId, +}: ExploreQueryEditorProps) { + const [syntaxRefOpened, { open: openSyntaxRef, close: closeSyntaxRef }] = + useDisclosure(false); + + const { field: languageField } = useController({ + control, + name: languageName as FieldPath, + }); + + const language: 'sql' | 'lucene' = + languageField.value ?? getStoredLanguage() ?? 'sql'; + const isSql = language === 'sql'; + + const handleLanguageChange = (lang: 'sql' | 'lucene') => { + setStoredLanguage(lang); + languageField.onChange(lang); + }; + + const tc = tableConnection ? { tableConnection } : { tableConnections }; + + return ( + <> + + + + handleLanguageChange(value as 'sql' | 'lucene')} + data={[ + { label: 'SQL', value: 'sql' }, + { label: 'Lucene', value: 'lucene' }, + ]} + aria-label="Query language" + /> + + + + + + + {controls} + + + {isExpanded ? ( + + ) : ( + + )} + + + + + + {isSql ? ( + + ) : ( + + )} + + + + ); +} diff --git a/packages/app/src/components/SQLEditor/SQLInlineEditor.tsx b/packages/app/src/components/SQLEditor/SQLInlineEditor.tsx index c27163078a..f7c9f9904e 100644 --- a/packages/app/src/components/SQLEditor/SQLInlineEditor.tsx +++ b/packages/app/src/components/SQLEditor/SQLInlineEditor.tsx @@ -69,6 +69,9 @@ type SQLInlineEditorProps = { // (intersection) rather than the union — for an expression that must be valid // against every connection, e.g. a chart-level Group By over multiple series. intersectFields?: boolean; + // Show a line-number gutter. Off by default so existing inline usages stay + // compact; opt-in for editor-style surfaces (e.g. the Explore query editor). + showLineNumbers?: boolean; }; const MAX_EDITOR_HEIGHT = '150px'; @@ -96,6 +99,7 @@ export default function SQLInlineEditor({ dateRange, sourceId, intersectFields, + showLineNumbers = false, }: SQLInlineEditorProps & TableConnectionChoice) { const { colorScheme } = useMantineColorScheme(); const _tableConnections = tableConnection @@ -372,7 +376,10 @@ export default function SQLInlineEditor({ }, [setIsFocused])} extensions={cmExtensions} onCreateEditor={updateAutocompleteColumns} - basicSetup={DEFAULT_CODE_MIRROR_BASIC_SETUP} + basicSetup={{ + ...DEFAULT_CODE_MIRROR_BASIC_SETUP, + lineNumbers: showLineNumbers, + }} placeholder={placeholder} onClick={onClickCodeMirror} /> From 0aa83cfb762ebc424fe4757c7b3be54649cadebc Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Wed, 29 Jul 2026 15:25:53 +0100 Subject: [PATCH 17/31] feat(app): render Explore query editor in CodeMirror for both languages Replace the reuse-based Explore query editor with a CodeMirror-backed QueryEditor so both SQL and Lucene get line numbers and syntax highlighting in a single unified editor. - Add a presentational QueryEditor (SQL via the app's ClickHouse dialect, Lucene via a small custom StreamLanguage highlighter), theme-aware, with Enter-to-submit and Tab-to-complete. - ExploreQueryEditor now wraps QueryEditor with react-hook-form wiring, the ClickHouse field list for autocomplete, and the syntax-reference modal. - Add @codemirror/language dependency. Co-authored-by: Cursor --- packages/app/package.json | 1 + packages/app/src/DBExplorePage.tsx | 10 +- .../Explore/ExploreQueryEditor.module.scss | 43 --- .../components/Explore/ExploreQueryEditor.tsx | 175 ++++------- .../Explore/QueryEditor.module.scss | 31 ++ .../src/components/Explore/QueryEditor.tsx | 297 ++++++++++++++++++ yarn.lock | 22 ++ 7 files changed, 410 insertions(+), 169 deletions(-) delete mode 100644 packages/app/src/components/Explore/ExploreQueryEditor.module.scss create mode 100644 packages/app/src/components/Explore/QueryEditor.module.scss create mode 100644 packages/app/src/components/Explore/QueryEditor.tsx diff --git a/packages/app/package.json b/packages/app/package.json index 0e7a3805e4..03088d53e4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -29,6 +29,7 @@ "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-sql": "^6.7.0", + "@codemirror/language": "^6.12.4", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@dagrejs/dagre": "^1.1.5", diff --git a/packages/app/src/DBExplorePage.tsx b/packages/app/src/DBExplorePage.tsx index 3fd828b0e7..5bc307a6c5 100644 --- a/packages/app/src/DBExplorePage.tsx +++ b/packages/app/src/DBExplorePage.tsx @@ -125,12 +125,7 @@ import { parseTimeQuery, useNewTimeQuery, } from '@/timeQuery'; -import { - formatDurationMs, - QUERY_LOCAL_STORAGE, - useLocalStorage, - usePrevious, -} from '@/utils'; +import { formatDurationMs, useLocalStorage, usePrevious } from '@/utils'; import ChartSQLPreview, { SQLPreview } from './components/ChartSQLPreview'; import { DBBarChart } from './components/DBBarChart'; @@ -2410,13 +2405,10 @@ function DBExplorePage() { control={control} name="where" onSubmit={onSubmit} - sqlQueryHistoryType={QUERY_LOCAL_STORAGE.SEARCH_SQL} - luceneQueryHistoryType={QUERY_LOCAL_STORAGE.SEARCH_LUCENE} enableHotkey data-testid="search-input" dateRange={searchedTimeRange} sourceId={inputSource} - size="xs" isExpanded={isQueryExpanded} onToggleExpand={toggleQueryExpanded} controls={ diff --git a/packages/app/src/components/Explore/ExploreQueryEditor.module.scss b/packages/app/src/components/Explore/ExploreQueryEditor.module.scss deleted file mode 100644 index f76492a124..0000000000 --- a/packages/app/src/components/Explore/ExploreQueryEditor.module.scss +++ /dev/null @@ -1,43 +0,0 @@ -.card { - border: 1px solid var(--color-border); - border-radius: var(--mantine-radius-default); - background-color: var(--color-bg-body); - overflow: hidden; -} - -.header { - padding: 4px 6px 4px 4px; - border-bottom: 1px solid var(--color-border); - min-height: 38px; -} - -.controls { - min-width: 0; -} - -.body { - padding: 4px 8px; -} - -/* The inner editors (SQLInlineEditor / SearchInputV2) bring their own - Paper/border chrome; neutralize it so they blend into this card. */ -.body :global(.mantine-Paper-root) { - border: none !important; - background: transparent !important; - box-shadow: none !important; -} - -.body :global(.cm-editor) { - background: transparent !important; -} - -/* Blend the line-number gutter into the card. */ -.body :global(.cm-gutters) { - background: transparent !important; - border: none !important; -} - -/* Give the multiline body some room when expanded. */ -.card[data-expanded='true'] .body { - min-height: 120px; -} diff --git a/packages/app/src/components/Explore/ExploreQueryEditor.tsx b/packages/app/src/components/Explore/ExploreQueryEditor.tsx index 7d4b7cc335..46970d11d9 100644 --- a/packages/app/src/components/Explore/ExploreQueryEditor.tsx +++ b/packages/app/src/components/Explore/ExploreQueryEditor.tsx @@ -1,29 +1,20 @@ +import { useMemo } from 'react'; import { FieldPath, useController, UseControllerProps } from 'react-hook-form'; import { TableConnectionChoice } from '@hyperdx/common-utils/dist/core/metadata'; -import { - ActionIcon, - Box, - Flex, - SegmentedControl, - Tooltip, -} from '@mantine/core'; +import { ActionIcon, Tooltip } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; -import { - IconArrowsDiagonal, - IconArrowsDiagonalMinimize2, - IconHelp, -} from '@tabler/icons-react'; +import { IconHelp } from '@tabler/icons-react'; -import SearchInputV2 from '@/components/SearchInput/SearchInputV2'; import { getStoredLanguage } from '@/components/SearchInput/SearchWhereInput'; import SyntaxReferenceModal from '@/components/SearchInput/SyntaxReferenceModal'; -import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; +import { useMultipleAllFields } from '@/hooks/useMetadata'; +import { useSource } from '@/source'; -import styles from './ExploreQueryEditor.module.scss'; +import { QueryEditor, QueryLanguage } from './QueryEditor'; const STORAGE_KEY = 'hdx-search-where-language'; -function setStoredLanguage(lang: 'sql' | 'lucene'): void { +function setStoredLanguage(lang: QueryLanguage): void { try { if (typeof window !== 'undefined') { window.localStorage.setItem(STORAGE_KEY, lang); @@ -36,13 +27,8 @@ function setStoredLanguage(lang: 'sql' | 'lucene'): void { export type ExploreQueryEditorProps = { onSubmit?: () => void; enableHotkey?: boolean; - size?: 'xs' | 'sm'; dateRange?: [Date, Date]; sourceId?: string; - sqlQueryHistoryType?: string; - luceneQueryHistoryType?: string; - sqlPlaceholder?: string; - lucenePlaceholder?: string; additionalSuggestions?: string[]; /** Form field name for the language value; defaults to `${name}Language`. */ languageName?: string; @@ -56,12 +42,10 @@ export type ExploreQueryEditorProps = { UseControllerProps; /** - * Explore-only query editor card. Owns the visual chrome (bordered card with a - * header holding the Lucene/SQL language tabs on the left and caller-provided - * controls plus an expand toggle on the right) while reusing the existing - * SQL (CodeMirror) and Lucene (autocomplete) editors for the body. Kept - * separate from the shared SearchWhereInput so the Explore layout can evolve - * without affecting the Search page. + * Explore-only query editor. Wraps the presentational QueryEditor with + * react-hook-form wiring, the ClickHouse field list for autocomplete, and the + * shared syntax-reference modal. Kept separate from SearchWhereInput so the + * Explore layout can evolve without affecting the Search page. */ export function ExploreQueryEditor({ tableConnection, @@ -70,13 +54,8 @@ export function ExploreQueryEditor({ name, onSubmit, enableHotkey, - size = 'xs', dateRange, sourceId, - sqlQueryHistoryType, - luceneQueryHistoryType, - sqlPlaceholder = "SQL WHERE clause (ex. column = 'foo')", - lucenePlaceholder = 'Search your events w/ Lucene ex. column:foo', additionalSuggestions, languageName = `${name}Language`, isExpanded, @@ -87,21 +66,43 @@ export function ExploreQueryEditor({ const [syntaxRefOpened, { open: openSyntaxRef, close: closeSyntaxRef }] = useDisclosure(false); + const { field: valueField } = useController({ + control, + name: name as FieldPath, + }); const { field: languageField } = useController({ control, name: languageName as FieldPath, }); - const language: 'sql' | 'lucene' = + const language: QueryLanguage = languageField.value ?? getStoredLanguage() ?? 'sql'; - const isSql = language === 'sql'; - const handleLanguageChange = (lang: 'sql' | 'lucene') => { + const handleLanguageChange = (lang: QueryLanguage) => { setStoredLanguage(lang); languageField.onChange(lang); }; - const tc = tableConnection ? { tableConnection } : { tableConnections }; + const _tableConnections = tableConnection + ? [tableConnection] + : tableConnections; + const { data: source } = useSource({ id: sourceId }); + const { data: fields } = useMultipleAllFields(_tableConnections ?? [], { + dateRange, + timestampValueExpression: source?.timestampValueExpression, + }); + + const identifiers = useMemo(() => { + return [ + ...(fields?.map(c => + c.path.length > 1 ? `${c.path[0]}['${c.path[1]}']` : c.path[0], + ) ?? []), + ...(additionalSuggestions ?? []), + ]; + }, [fields, additionalSuggestions]); + + const stringValue = + typeof valueField.value === 'string' ? valueField.value : ''; return ( <> @@ -110,22 +111,16 @@ export function ExploreQueryEditor({ onClose={closeSyntaxRef} language={language} /> - - - handleLanguageChange(value as 'sql' | 'lucene')} - data={[ - { label: 'SQL', value: 'sql' }, - { label: 'Lucene', value: 'lucene' }, - ]} - aria-label="Query language" - /> + - - {controls} - - - {isExpanded ? ( - - ) : ( - - )} - - - - - - {isSql ? ( - - ) : ( - - )} - - + } + fields={identifiers} + placeholder={ + language === 'sql' + ? "SQL WHERE clause (ex. column = 'foo')" + : 'Search your events w/ Lucene ex. column:foo' + } + onSubmit={onSubmit} + enableHotkey={enableHotkey} + data-testid={dataTestId} + /> ); } diff --git a/packages/app/src/components/Explore/QueryEditor.module.scss b/packages/app/src/components/Explore/QueryEditor.module.scss new file mode 100644 index 0000000000..adc12640b2 --- /dev/null +++ b/packages/app/src/components/Explore/QueryEditor.module.scss @@ -0,0 +1,31 @@ +.card { + border: 1px solid var(--color-border); + border-radius: var(--mantine-radius-default); + background-color: var(--color-bg-field); + overflow: hidden; +} + +.header { + padding: 4px 6px 4px 6px; + border-bottom: 1px solid var(--color-border); + min-height: 38px; + background-color: var(--color-bg-surface); +} + +.controls { + min-width: 0; +} + +.body { + padding: 2px 6px; +} + +/* Blend the line-number gutter into the card. */ +.body :global(.cm-gutters) { + background: transparent !important; + border: none !important; +} + +.body :global(.cm-editor) { + background: transparent !important; +} diff --git a/packages/app/src/components/Explore/QueryEditor.tsx b/packages/app/src/components/Explore/QueryEditor.tsx new file mode 100644 index 0000000000..814cc5ba98 --- /dev/null +++ b/packages/app/src/components/Explore/QueryEditor.tsx @@ -0,0 +1,297 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { + acceptCompletion, + autocompletion, + Completion, + CompletionSource, +} from '@codemirror/autocomplete'; +import { StreamLanguage, type StreamParser } from '@codemirror/language'; +import { EditorState, type Extension } from '@codemirror/state'; +import { + ActionIcon, + Box, + Flex, + SegmentedControl, + Tooltip, + useMantineColorScheme, +} from '@mantine/core'; +import { + IconArrowsDiagonal, + IconArrowsDiagonalMinimize2, +} from '@tabler/icons-react'; +import CodeMirror, { + EditorView, + keymap, + Prec, + ReactCodeMirrorRef, +} from '@uiw/react-codemirror'; + +import { KEYWORDS_FOR_WHERE_OR_ORDER_BY } from '@/components/SQLEditor/constants'; +import { + createCodeMirrorSqlDialect, + createCodeMirrorStyleTheme, +} from '@/components/SQLEditor/utils'; + +import styles from './QueryEditor.module.scss'; + +export type QueryLanguage = 'sql' | 'lucene'; + +const DEFAULT_LANGUAGES: QueryLanguage[] = ['sql', 'lucene']; +const EMPTY_FIELDS: string[] = []; + +export interface QueryEditorProps { + /** Current query text (controlled). */ + value: string; + onChange: (value: string) => void; + /** Language (controlled). */ + language: QueryLanguage; + onLanguageChange: (language: QueryLanguage) => void; + /** Which languages appear in the toggle (also controls order). */ + languages?: QueryLanguage[]; + /** Expanded (controlled) — multiline vs single line. */ + expanded: boolean; + onToggleExpanded: () => void; + /** Right-aligned header controls (date picker, Live, Run, ...). */ + rightSection?: React.ReactNode; + /** Extra node next to the language tabs (e.g. a syntax-help button). */ + leftSection?: React.ReactNode; + /** Field names offered by autocomplete (both languages). */ + fields?: string[]; + placeholder?: string; + /** Fired on Enter (Shift+Enter inserts a newline when expanded). */ + onSubmit?: () => void; + /** Focus the editor on "/" or "s" when true. */ + enableHotkey?: boolean; + maxExpandedHeight?: number; + 'data-testid'?: string; +} + +const baseTheme = EditorView.theme({ + '&': { backgroundColor: 'transparent', fontSize: '13px' }, + '&.cm-focused': { outline: 'none' }, + '.cm-content': { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + }, + '.cm-activeLine, .cm-activeLineGutter': { backgroundColor: 'transparent' }, + '.cm-lineNumbers .cm-gutterElement': { padding: '0 8px' }, +}); + +/* Block newline insertion while collapsed so a single line stays single. */ +const singleLine = EditorState.transactionFilter.of(tr => { + if (!tr.docChanged) return tr; + let insertsNewline = false; + tr.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => { + if (inserted.lines > 1) insertsNewline = true; + }); + return insertsNewline ? [] : tr; +}); + +const clipScroller = EditorView.theme({ + '.cm-scroller': { overflow: 'hidden' }, +}); + +/** + * Minimal Lucene highlighter. `@codemirror/legacy-modes` has no Lucene mode, so + * we tokenize the essentials here — quoted strings, `field:` names, boolean + * keywords, numbers, and operators — using standard token names that the + * default highlight style already colors. + */ +const luceneStreamParser: StreamParser = { + token(stream) { + if (stream.eatSpace()) return null; + if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string'; + if (stream.match(/^[-+]?[\w.$*?]+(?=\s*:)/)) return 'propertyName'; + if (stream.match(/^(?:AND|OR|NOT|TO)\b/)) return 'keyword'; + if (stream.match(/^\d+(?:\.\d+)?\b/)) return 'number'; + if (stream.match(/^[:+\-!^~*?(){}[\]]/)) return 'operator'; + if (stream.match(/^[^\s:()]+/)) return null; + stream.next(); + return null; + }, +}; + +function luceneCompletions(fields: string[]): CompletionSource { + const fieldOpts: Completion[] = fields.map(label => ({ + label, + type: 'variable', + apply: `${label}:`, + })); + const keywordOpts: Completion[] = ['AND', 'OR', 'NOT', 'TO'].map(label => ({ + label, + type: 'keyword', + })); + const all = [...fieldOpts, ...keywordOpts]; + + return context => { + const word = context.matchBefore(/[\w.$-]*/); + if (!word) return null; + if (word.from === word.to && !context.explicit) return null; + return { from: word.from, options: all, validFor: /^[\w.$-]*$/ }; + }; +} + +function languageExtensions( + language: QueryLanguage, + fields: string[], +): Extension[] { + if (language === 'sql') { + // Reuse the app's ClickHouse dialect + identifier/keyword/function + // completion for consistency with the rest of the product. + return createCodeMirrorSqlDialect({ + identifiers: fields, + keywords: KEYWORDS_FOR_WHERE_OR_ORDER_BY, + includeRegularFunctions: true, + }); + } + return [ + StreamLanguage.define(luceneStreamParser), + autocompletion({ override: [luceneCompletions(fields)] }), + ]; +} + +/** + * Presentational query editor: a bordered card with language tabs and header + * controls on top of a CodeMirror body that renders SQL or Lucene with line + * numbers and syntax highlighting. Fully controlled — value, language, and + * expanded state are owned by the caller. + */ +export function QueryEditor({ + value, + onChange, + language, + onLanguageChange, + languages = DEFAULT_LANGUAGES, + expanded, + onToggleExpanded, + rightSection, + leftSection, + fields = EMPTY_FIELDS, + placeholder = 'Search your events…', + onSubmit, + enableHotkey, + maxExpandedHeight = 320, + 'data-testid': dataTestId, +}: QueryEditorProps) { + const { colorScheme } = useMantineColorScheme(); + const ref = useRef(null); + + const onSubmitRef = useRef(onSubmit); + useEffect(() => { + onSubmitRef.current = onSubmit; + }, [onSubmit]); + + useHotkeys( + ['/', 's'], + () => { + if (enableHotkey) ref.current?.view?.focus(); + }, + { + preventDefault: true, + enableOnFormTags: false, + enableOnContentEditable: false, + }, + [enableHotkey], + ); + + const extensions = useMemo(() => { + const submitKeymap = Prec.highest( + keymap.of([ + { + key: 'Enter', + run: () => { + if (!onSubmitRef.current) return false; + onSubmitRef.current(); + return true; + }, + }, + ...(expanded ? [{ key: 'Shift-Enter', run: () => false }] : []), + ]), + ); + return [ + baseTheme, + createCodeMirrorStyleTheme(), + submitKeymap, + keymap.of([{ key: 'Tab', run: acceptCompletion }]), + ...languageExtensions(language, fields), + ...(expanded ? [EditorView.lineWrapping] : [singleLine, clipScroller]), + ]; + }, [language, expanded, fields]); + + const showToggle = languages.length > 1; + const collapsedHeight = '30px'; + + return ( + + + + {showToggle && ( + onLanguageChange(v as QueryLanguage)} + data={languages.map(l => ({ + value: l, + label: l === 'sql' ? 'SQL' : 'Lucene', + }))} + aria-label="Query language" + /> + )} + {leftSection} + + + {rightSection} + + + {expanded ? ( + + ) : ( + + )} + + + + + + + + + ); +} diff --git a/yarn.lock b/yarn.lock index fc64fde792..ac47f8a5a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3181,6 +3181,20 @@ __metadata: languageName: node linkType: hard +"@codemirror/language@npm:^6.12.4": + version: 6.12.4 + resolution: "@codemirror/language@npm:6.12.4" + dependencies: + "@codemirror/state": "npm:^6.0.0" + "@codemirror/view": "npm:^6.23.0" + "@lezer/common": "npm:^1.5.0" + "@lezer/highlight": "npm:^1.0.0" + "@lezer/lr": "npm:^1.0.0" + style-mod: "npm:^4.0.0" + checksum: 10c0/1b704a66f618d96eddf5937a41996e1ab42a70b5333a90b768e5539c25b7ab822e91a93e66b14d47a886cd73b3e3e22ddf77b8f11fb2f496ebd7ba010f3c4deb + languageName: node + linkType: hard + "@codemirror/lint@npm:^6.0.0": version: 6.5.0 resolution: "@codemirror/lint@npm:6.5.0" @@ -4500,6 +4514,7 @@ __metadata: "@codemirror/autocomplete": "npm:^6.0.0" "@codemirror/lang-json": "npm:^6.0.1" "@codemirror/lang-sql": "npm:^6.7.0" + "@codemirror/language": "npm:^6.12.4" "@codemirror/lint": "npm:^6.0.0" "@codemirror/state": "npm:^6.0.0" "@dagrejs/dagre": "npm:^1.1.5" @@ -5841,6 +5856,13 @@ __metadata: languageName: node linkType: hard +"@lezer/common@npm:^1.5.0": + version: 1.5.2 + resolution: "@lezer/common@npm:1.5.2" + checksum: 10c0/e39b46d74899409eab549df7942f00cd8c7f46c81ef0e2f079654ca96d262fca009927328bcd500d69270f5f09986e74768bed19c0acaadbd22f1a6c7dd9bd85 + languageName: node + linkType: hard + "@lezer/highlight@npm:^1.0.0": version: 1.2.0 resolution: "@lezer/highlight@npm:1.2.0" From b61e5de9082225fedf13c8d4f0224a594604090b Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Wed, 29 Jul 2026 15:37:21 +0100 Subject: [PATCH 18/31] refactor(app): move Explore saved-view header into the context band Remove the top header block (breadcrumbs, editable title, created/updated byline) from the Explore page and surface the Favorite toggle and Tags control inside the context band instead, so all saved-view controls live in the new toolbar structure. Co-authored-by: Cursor --- packages/app/src/DBExplorePage.tsx | 114 +++++------------- .../components/Explore/ExploreContextBand.tsx | 12 ++ 2 files changed, 39 insertions(+), 87 deletions(-) diff --git a/packages/app/src/DBExplorePage.tsx b/packages/app/src/DBExplorePage.tsx index 5bc307a6c5..8a940485b4 100644 --- a/packages/app/src/DBExplorePage.tsx +++ b/packages/app/src/DBExplorePage.tsx @@ -12,7 +12,6 @@ import { import dynamic from 'next/dynamic'; import Head from 'next/head'; import router from 'next/router'; -import { formatDistanceToNow } from 'date-fns'; import { parseAsBoolean, parseAsInteger, @@ -49,9 +48,7 @@ import { } from '@hyperdx/common-utils/dist/types'; import { ActionIcon, - Anchor, Box, - Breadcrumbs, Button, Card, Code, @@ -180,9 +177,7 @@ import { } from './utils/queryParsers'; import { LOCAL_STORE_CONNECTIONS_KEY } from './connection'; import { DBSearchPageAlertModal } from './DBSearchPageAlertModal'; -import { EditablePageName } from './EditablePageName'; import { SearchConfig } from './types'; -import { FormatTime } from './useFormatTime'; import searchPageStyles from '@styles/SearchPage.module.scss'; @@ -2254,88 +2249,6 @@ function DBExplorePage() { /> )} - {savedSearch && ( - - - - - Saved Searches - - - {savedSearch.name} - - - - {savedSearch.createdBy && ( - - Created by{' '} - {savedSearch.createdBy.name || savedSearch.createdBy.email}.{' '} - - )} - {savedSearch.updatedAt && ( - - - {savedSearch.updatedBy - ? ` by ${savedSearch.updatedBy.name || savedSearch.updatedBy.email}` - : ''} - - } - > - {`Updated ${formatDistanceToNow(new Date(savedSearch.updatedAt), { addSuffix: true })}.`} - - )} - - - -
- { - updateSavedSearch.mutate({ - id: savedSearch.id, - name: editedName, - }); - }} - /> -
- - - - - - - -
-
- )}
+ ) + } + tagsControl={ + savedSearch && ( + + + + ) + } onOpenSavedViews={openSavedSearchesFlyout} onSaveView={onSaveSearch} onUpdate={() => setSaveSearchModalState('update')} diff --git a/packages/app/src/components/Explore/ExploreContextBand.tsx b/packages/app/src/components/Explore/ExploreContextBand.tsx index ee5af455b2..4f101ab832 100644 --- a/packages/app/src/components/Explore/ExploreContextBand.tsx +++ b/packages/app/src/components/Explore/ExploreContextBand.tsx @@ -146,6 +146,8 @@ export function ExploreContextBand({ isDirty, isLocalMode, alerts, + favoriteButton, + tagsControl, onOpenSavedViews, onSaveView, onUpdate, @@ -160,6 +162,10 @@ export function ExploreContextBand({ isDirty: boolean; isLocalMode: boolean; alerts?: AlertLike[]; + /** Favorite toggle for the current saved view (rendered by the page). */ + favoriteButton?: React.ReactNode; + /** Tags control for the current saved view (rendered by the page). */ + tagsControl?: React.ReactNode; onOpenSavedViews: () => void; onSaveView: () => void; onUpdate: () => void; @@ -191,6 +197,12 @@ export function ExploreContextBand({ + {savedSearchId && (favoriteButton || tagsControl) ? ( + + {favoriteButton} + {tagsControl} + + ) : null} {!savedSearchId ? ( + + + {showGeneratedSql && ( + } + onClick={open} + data-testid="generated-sql-button" + > + Show generated SQL + + )} + } + disabled + data-testid="export-button" + > + Export (coming soon) + + + + + ); +} + +// "Add to dashboard" action for chart-tile views. Opens the shared +// SaveToDashboardModal with the current aggregation rendered as a tile config. +function AddToDashboardButton({ config }: { config: SavedChartConfig }) { + const [opened, { open, close }] = useDisclosure(false); + return ( + <> + + ); } @@ -1989,6 +2039,30 @@ function DBExplorePage() { searchedMetricSource, ]); + // Dashboard-tile config for the "Add to dashboard" action: reuses the + // aggregated chart config but references the source by id (as tiles do) and + // drops the runtime-only date range so the tile follows the dashboard's own + // time range. + const addToDashboardConfig = useMemo(() => { + if (!aggViewChartConfig || !searchedConfig.source) { + return undefined; + } + return { + name: savedSearch?.name || 'Explore chart', + source: searchedConfig.source, + displayType: aggViewChartConfig.displayType, + select: aggViewChartConfig.select, + where: searchedConfig.where ?? '', + whereLanguage: searchedConfig.whereLanguage ?? 'sql', + filters: searchedConfig.filters ?? [], + groupBy: aggViewChartConfig.groupBy, + orderBy: aggViewChartConfig.orderBy, + granularity: aggViewChartConfig.granularity, + seriesLimit: aggViewChartConfig.seriesLimit, + with: aggViewChartConfig.with, + } as SavedChartConfig; + }, [aggViewChartConfig, savedSearch?.name, searchedConfig]); + const onFormSubmit = useCallback>( e => { e.preventDefault(); @@ -2503,6 +2577,12 @@ function DBExplorePage() { sourceKind={searchedSource?.kind} /> } + addToDashboard={ + isAggregatedSearchView(view) && + addToDashboardConfig && ( + + ) + } sortControl={ <> {view === 'list' && ( @@ -2592,30 +2672,15 @@ function DBExplorePage() { /> ) } - sqlPreview={ - !isMetricSource && ( - - ) - } - exportControl={ - - - - - + overflowMenu={ + } /> diff --git a/packages/app/src/components/Explore/ExploreResultsToolbar.tsx b/packages/app/src/components/Explore/ExploreResultsToolbar.tsx index f1abd06df9..63d2ed1d7f 100644 --- a/packages/app/src/components/Explore/ExploreResultsToolbar.tsx +++ b/packages/app/src/components/Explore/ExploreResultsToolbar.tsx @@ -11,19 +11,21 @@ export function ExploreResultsToolbar({ stats, filterExpand, viewSwitcher, + addToDashboard, sortControl, columnsControl, - sqlPreview, - exportControl, + overflowMenu, }: { resultsCount?: React.ReactNode; stats?: React.ReactNode; filterExpand?: React.ReactNode; viewSwitcher: React.ReactNode; + /** "Add to dashboard" action, shown only for chart-tile views. */ + addToDashboard?: React.ReactNode; sortControl?: React.ReactNode; columnsControl?: React.ReactNode; - sqlPreview?: React.ReactNode; - exportControl?: React.ReactNode; + /** Overflow (3-dots) menu holding secondary actions (SQL, export). */ + overflowMenu?: React.ReactNode; }) { return ( @@ -37,10 +39,10 @@ export function ExploreResultsToolbar({ {viewSwitcher} + {addToDashboard} {sortControl} {columnsControl} - {sqlPreview} - {exportControl} + {overflowMenu} diff --git a/packages/app/src/components/Search/SearchColumnPicker.tsx b/packages/app/src/components/Search/SearchColumnPicker.tsx index d3fb0db2b0..cf1b3ddb94 100644 --- a/packages/app/src/components/Search/SearchColumnPicker.tsx +++ b/packages/app/src/components/Search/SearchColumnPicker.tsx @@ -78,8 +78,7 @@ export function SearchColumnPicker({ > diff --git a/packages/app/src/components/Explore/SeveritySummary.tsx b/packages/app/src/components/Explore/SeveritySummary.tsx new file mode 100644 index 0000000000..5ac17247bd --- /dev/null +++ b/packages/app/src/components/Explore/SeveritySummary.tsx @@ -0,0 +1,191 @@ +import { useMemo } from 'react'; +import { + filterColumnMetaByType, + JSDataType, + ResponseJSON, +} from '@hyperdx/common-utils/dist/clickhouse'; +import { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types'; +import { Box, Group, Tooltip, UnstyledButton } from '@mantine/core'; +import { keepPreviousData } from '@tanstack/react-query'; + +import { useQueriedChartConfig } from '@/hooks/useChartConfig'; +import { getLogLevelClass } from '@/utils'; + +type SeverityBucket = 'error' | 'warn'; + +// Semantic status colors shared with the rest of the app (log level text, +// charts, service map). Keeps the pills consistent with how severity is +// colored everywhere else. +const BUCKET_COLOR: Record = { + error: 'var(--color-chart-error)', + warn: 'var(--color-chart-warning)', +}; + +// Reuse the canonical log-level classifier so bucketing matches the log level +// coloring used elsewhere (ERROR/FATAL/CRIT/... → error, WARN → warn). +function classifySeverity(value: string): SeverityBucket | null { + const cls = getLogLevelClass(value); + return cls === 'error' ? 'error' : cls === 'warn' ? 'warn' : null; +} + +const numberFormatter = new Intl.NumberFormat('en-US'); + +function inferColumns(meta: ResponseJSON['meta'] | undefined): { + countColumn: string; + groupColumn: string; +} | null { + if (!meta || meta.length === 0) return null; + const countColumn = + (meta.find(col => col.name === 'count()') ? 'count()' : undefined) ?? + filterColumnMetaByType(meta, [JSDataType.Number])?.[0]?.name; + if (!countColumn) return null; + const groupColumn = meta.find(col => col.name !== countColumn)?.name; + if (!groupColumn) return null; + return { countColumn, groupColumn }; +} + +type BucketResult = { count: number; values: string[] }; + +/** + * Compact "N errors / N warnings" pills for the results toolbar. Runs a single + * grouped count query (severity value → count) in parallel with the main + * results and buckets the rows client-side. Clicking a pill scopes the search + * to (or clears) the matching severity values via the structured filter. + */ +export function SeveritySummary({ + config, + enabled = true, + queryKeyPrefix, + activeValues, + onToggle, +}: { + config: BuilderChartConfigWithDateRange; + enabled?: boolean; + queryKeyPrefix?: string; + /** Severity values currently included by the structured filter. */ + activeValues?: string[]; + /** Toggle the filter for a bucket. `isActive` reflects the current state. */ + onToggle?: (values: string[], isActive: boolean) => void; +}) { + const { data } = useQueriedChartConfig(config, { + queryKey: [queryKeyPrefix, 'severity-summary', config], + enabled, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false, + placeholderData: keepPreviousData, + }); + + const { error, warn } = useMemo(() => { + const empty: Record = { + error: { count: 0, values: [] }, + warn: { count: 0, values: [] }, + }; + const cols = inferColumns(data?.meta); + if (!cols || !data?.data) return empty; + for (const row of data.data) { + const raw = row[cols.groupColumn]; + const value = raw == null ? '' : String(raw); + const bucket = classifySeverity(value); + if (!bucket) continue; + empty[bucket].count += Number(row[cols.countColumn] ?? 0); + if (value && !empty[bucket].values.includes(value)) { + empty[bucket].values.push(value); + } + } + return empty; + }, [data]); + + const activeSet = new Set(activeValues ?? []); + const isBucketActive = (bucket: BucketResult) => + bucket.values.length > 0 && + bucket.values.every(v => activeSet.has(v)) && + activeSet.size === bucket.values.length; + + const pills: { + bucket: SeverityBucket; + result: BucketResult; + label: string; + }[] = [ + { bucket: 'error', result: error, label: 'error' }, + { bucket: 'warn', result: warn, label: 'warning' }, + ]; + + const visiblePills = pills.filter( + p => p.result.count > 0 || isBucketActive(p.result), + ); + if (visiblePills.length === 0) return null; + + return ( + + {visiblePills.map(({ bucket, result, label }) => { + const active = isBucketActive(result); + const clickable = onToggle != null && result.values.length > 0; + const color = BUCKET_COLOR[bucket]; + const text = `${numberFormatter.format(result.count)} ${ + result.count === 1 ? label : `${label}s` + }`; + const pillStyle: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 6, + height: 22, + padding: '0 8px', + borderRadius: 'var(--mantine-radius-sm)', + fontSize: 'var(--mantine-font-size-xs)', + fontWeight: 500, + lineHeight: 1, + whiteSpace: 'nowrap', + color: active ? '#fff' : color, + backgroundColor: active + ? color + : `color-mix(in srgb, ${color} 14%, transparent)`, + border: `1px solid color-mix(in srgb, ${color} ${ + active ? '0%' : '35%' + }, transparent)`, + cursor: clickable ? 'pointer' : 'default', + }; + const dot = ( + + ); + const pill = clickable ? ( + onToggle?.(result.values, active)} + data-testid={`severity-summary-${bucket}`} + > + {dot} + {text} + + ) : ( + + {dot} + {text} + + ); + return ( + + {pill} + + ); + })} + + ); +} diff --git a/packages/app/src/components/Search/searchViews.tsx b/packages/app/src/components/Search/searchViews.tsx index db13870aa5..e4085ab16f 100644 --- a/packages/app/src/components/Search/searchViews.tsx +++ b/packages/app/src/components/Search/searchViews.tsx @@ -62,7 +62,7 @@ const SEARCH_VIEWS: SearchViewMeta[] = [ }, { value: 'table', - label: 'Summary table', + label: 'Grouped table', icon: , aggregated: true, }, diff --git a/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx b/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx index 8829c9ec1f..45c2939ebd 100644 --- a/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx +++ b/packages/app/src/components/__tests__/ActiveFilterPills.test.tsx @@ -51,6 +51,7 @@ function makeSearchFilters( setFilters: jest.fn(), setFilterValue: jest.fn(), setOnlyFilters: jest.fn(), + setIncludedValues: jest.fn(), replaceFilterValue: jest.fn(), setFilterRange: jest.fn(), clearFilter: jest.fn(), diff --git a/packages/app/src/searchFilters.tsx b/packages/app/src/searchFilters.tsx index ac193b7f26..6685615e4c 100644 --- a/packages/app/src/searchFilters.tsx +++ b/packages/app/src/searchFilters.tsx @@ -212,6 +212,31 @@ export const useSearchPageFilterState = ({ [updateFilterQuery], ); + // Set a property's included values to exactly `values` (an OR-set) in one + // update. Unlike setFilterValue(..., 'only') which takes a single value, this + // scopes a column to multiple values at once (e.g. a "severity: error" pill + // that maps to both ERROR and FATAL). Passing an empty array clears the + // filter entirely. + const setIncludedValues = useCallback( + (property: string, values: (string | boolean)[]) => { + setFilters(prevFilters => { + const newFilters = produce(prevFilters, draft => { + if (values.length === 0) { + delete draft[property]; + } else { + draft[property] = { + included: new Set(values), + excluded: new Set(), + }; + } + }); + updateFilterQuery(newFilters); + return newFilters; + }); + }, + [updateFilterQuery], + ); + const setFilterRange = useCallback( (property: string, range: { min: number; max: number }) => { setFilters(prevFilters => { @@ -308,6 +333,7 @@ export const useSearchPageFilterState = ({ setFilters, setFilterValue, setOnlyFilters, + setIncludedValues, replaceFilterValue, setFilterRange, clearFilter, From c8d1bead7022b5241096c8e248075fde72ff7294 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Wed, 29 Jul 2026 19:14:35 +0100 Subject: [PATCH 23/31] feat(app): add SQL-only mode to the Explore page Add a chart-explorer-style Builder | SQL toggle to the Explore query bar. In SQL mode the query bar becomes a full raw-SQL editor (sqlTemplate + macros) and results render as a user-selectable chart display type, reusing the existing raw-SQL config/execution pipeline. SQL mode is URL-only for now and is not persisted to saved searches (Save is disabled with a tooltip). Co-authored-by: Cursor --- packages/app/src/DBExplorePage.tsx | 434 ++++++++++++++---- .../components/Explore/ExploreContextBand.tsx | 11 + .../components/Explore/ExploreQueryEditor.tsx | 39 +- .../Explore/ExploreRawSqlEditor.tsx | 113 +++++ .../Explore/QueryEditor.module.scss | 17 + .../src/components/Explore/QueryEditor.tsx | 132 ++++-- .../app/src/components/Search/searchViews.tsx | 12 +- 7 files changed, 628 insertions(+), 130 deletions(-) create mode 100644 packages/app/src/components/Explore/ExploreRawSqlEditor.tsx diff --git a/packages/app/src/DBExplorePage.tsx b/packages/app/src/DBExplorePage.tsx index 4cf0f913c7..39104e2384 100644 --- a/packages/app/src/DBExplorePage.tsx +++ b/packages/app/src/DBExplorePage.tsx @@ -28,6 +28,7 @@ import { ClickHouseQueryError, ColumnMeta, } from '@hyperdx/common-utils/dist/clickhouse'; +import { renderBuilderConfigAsSqlTemplate } from '@hyperdx/common-utils/dist/core/builderToRawSql'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { buildSearchChartConfig } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { @@ -40,8 +41,12 @@ import { ChartConfigWithDateRange, DisplayType, Filter, + isLogSource, + isMetricSource as isMetricSourceGuard, isTraceSource, MetricsDataType, + RawSqlChartConfig, + RawSqlSavedChartConfig, SavedChartConfig, SourceKind, TMetricSource, @@ -138,6 +143,7 @@ import { DBTreemapChart } from './components/DBTreemapChart'; import { ExploreContextBand } from './components/Explore/ExploreContextBand'; import { ExploreQueryEditor } from './components/Explore/ExploreQueryEditor'; import { ExploreResultsToolbar } from './components/Explore/ExploreResultsToolbar'; +import { type QueryConfigMode } from './components/Explore/QueryEditor'; import { SeveritySummary } from './components/Explore/SeveritySummary'; import PatternTable from './components/PatternTable'; import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart'; @@ -166,6 +172,7 @@ import { } from './components/TimePicker/utils'; import { useColumns, + useMetadataWithSettings, useResolvedDateTimeColumns, useTableMetadata, } from './hooks/useMetadata'; @@ -205,6 +212,10 @@ const SearchConfigSchema = z.object({ source: z.string(), where: z.string(), whereLanguage: z.enum(['sql', 'lucene']), + // Query mode: 'builder' edits only the WHERE predicate (SQL/Lucene) and lets + // the page assemble the rest; 'sql' is a full raw-SQL statement (sqlTemplate). + configType: z.enum(['builder', 'sql']), + sqlTemplate: z.string(), orderBy: z.string(), filters: z.array( z.union([ @@ -1041,6 +1052,8 @@ const queryStateMap = { where: parseAsStringEncoded, select: parseAsStringEncoded, whereLanguage: parseAsStringEnum<'sql' | 'lucene'>(['sql', 'lucene']), + configType: parseAsStringEnum<'builder' | 'sql'>(['builder', 'sql']), + sqlTemplate: parseAsStringEncoded, filters: parseAsJsonEncoded(), orderBy: parseAsStringEncoded, }; @@ -1166,6 +1179,12 @@ function DBExplorePage() { const [view, setView] = useSearchView(); const [aggConfig, setAggConfig] = useSearchAggConfig(); + // Submitted query mode (drives results rendering + control gating). The live + // form value (`inputConfigType`) drives the query-editor toggle instead. + const searchedConfigType: QueryConfigMode = + searchedConfig.configType ?? 'builder'; + const isSqlMode = searchedConfigType === 'sql'; + // Legacy 3-mode value still consumed by the filters sidebar (denoise gating) // and a few source-capability checks below. New view types collapse onto // 'results' for those purposes. @@ -1203,6 +1222,15 @@ function DBExplorePage() { } }, [isMetricSource, view, setView]); + useEffect(() => { + // SQL mode renders a single raw-SQL statement as a chart display type, so + // the raw List / heatmap / patterns views don't apply — default to the + // Grouped table view when one of those is active. + if (isSqlMode && !isAggregatedSearchView(view)) { + setView('table'); + } + }, [isSqlMode, view, setView]); + const [isFilterSidebarCollapsed, setIsFilterSidebarCollapsed] = useLocalStorage('isFilterSidebarCollapsed', false); @@ -1224,13 +1252,15 @@ function DBExplorePage() { [sources, lastSelectedSourceId], ); - const { control, setValue, reset, handleSubmit, formState } = + const { control, setValue, getValues, reset, handleSubmit, formState } = useForm({ values: { select: searchedConfig.select || '', where: searchedConfig.where || '', whereLanguage: searchedConfig.whereLanguage ?? getStoredLanguage() ?? 'sql', + configType: searchedConfig.configType ?? 'builder', + sqlTemplate: searchedConfig.sqlTemplate ?? '', source: searchedConfig.source || (savedSearchId || directTraceId ? '' : defaultSourceId), @@ -1301,6 +1331,8 @@ function DBExplorePage() { where: searchedConfig?.where ?? '', whereLanguage: searchedConfig?.whereLanguage ?? getStoredLanguage() ?? 'sql', + configType: searchedConfig?.configType ?? 'builder', + sqlTemplate: searchedConfig?.sqlTemplate ?? '', source: searchedConfig?.source ?? undefined, filters: searchedConfig?.filters ?? [], orderBy: searchedConfig?.orderBy ?? '', @@ -1379,11 +1411,22 @@ function DBExplorePage() { const onSubmit = useCallback(() => { onSearch(displayedTimeInputValue); handleSubmit( - ({ select, where, whereLanguage, source, filters, orderBy }) => { + ({ + select, + where, + whereLanguage, + configType, + sqlTemplate, + source, + filters, + orderBy, + }) => { setSearchedConfig({ select, where, whereLanguage, + configType, + sqlTemplate, source, filters, orderBy, @@ -1588,6 +1631,8 @@ function DBExplorePage() { }, [_queryErrors]); const inputWhere = useWatch({ name: 'where', control }); const inputWhereLanguage = useWatch({ name: 'whereLanguage', control }); + const inputConfigType: QueryConfigMode = + useWatch({ name: 'configType', control }) ?? 'builder'; // query suggestion for 'where' if error const whereSuggestions = useSqlSuggestions({ input: inputWhere, @@ -2109,6 +2154,132 @@ function DBExplorePage() { } as SavedChartConfig; }, [aggViewChartConfig, savedSearch?.name, searchedConfig]); + // Raw-SQL config for SQL mode. Bypasses buildSearchChartConfig entirely: the + // user-authored sqlTemplate owns the whole statement, and the source metadata + // is carried over so macros ($__sourceTable, $__filters) resolve. The display + // type is picked from the current chart view (aggregated views map 1:1 to raw + // SQL display types). + const rawSqlChartConfig = useMemo< + (RawSqlChartConfig & { dateRange: [Date, Date] }) | undefined + >(() => { + if (!isSqlMode || !searchedSource || !searchedConfig.source) { + return undefined; + } + const displayType = isAggregatedSearchView(view) + ? searchViewToDisplayType(view) + : DisplayType.Table; + return { + configType: 'sql', + sqlTemplate: searchedConfig.sqlTemplate ?? '', + connection: searchedSource.connection, + source: searchedConfig.source, + from: searchedSource.from, + displayType, + granularity: view === 'timeseries' ? 'auto' : undefined, + dateRange: searchedTimeRange, + filters: searchedConfig.filters ?? [], + implicitColumnExpression: + isLogSource(searchedSource) || isTraceSource(searchedSource) + ? searchedSource.implicitColumnExpression + : undefined, + bodyExpression: isLogSource(searchedSource) + ? searchedSource.bodyExpression + : undefined, + useTextIndexForImplicitColumn: + isLogSource(searchedSource) || isTraceSource(searchedSource) + ? searchedSource.useTextIndexForImplicitColumn + : undefined, + metricTables: isMetricSourceGuard(searchedSource) + ? searchedSource.metricTables + : undefined, + }; + }, [ + isSqlMode, + searchedSource, + searchedConfig.source, + searchedConfig.sqlTemplate, + searchedConfig.filters, + view, + searchedTimeRange, + ]); + + // Dashboard-tile config for the "Add to dashboard" action in SQL mode: a raw + // SQL SavedChartConfig (dashboards already support configType 'sql'). + const rawSqlAddToDashboardConfig = useMemo< + RawSqlSavedChartConfig | undefined + >(() => { + if (!rawSqlChartConfig) return undefined; + return { + name: savedSearch?.name || 'Explore SQL chart', + configType: 'sql', + sqlTemplate: rawSqlChartConfig.sqlTemplate, + connection: rawSqlChartConfig.connection, + source: searchedConfig.source ?? undefined, + displayType: rawSqlChartConfig.displayType ?? DisplayType.Table, + granularity: rawSqlChartConfig.granularity, + }; + }, [rawSqlChartConfig, savedSearch?.name, searchedConfig.source]); + + const metadata = useMetadataWithSettings(); + + // Builder -> SQL prefill: on first switch to SQL mode, seed the empty SQL + // editor with a macro-based template generated from the current builder + // config so the user starts from a working statement. + const handleQueryModeChange = useCallback( + (mode: QueryConfigMode) => { + setValue('configType', mode, { shouldDirty: true }); + if (mode !== 'sql') return; + const current = getValues('sqlTemplate'); + if (current && current.trim()) return; + + // aggViewChartConfig already has a raw-SQL-compatible display type and an + // array select; for non-aggregated views synthesize a simple count(). + const base = + aggViewChartConfig ?? + (chartConfig + ? { + ...chartConfig, + displayType: DisplayType.Table, + select: [ + { aggFn: 'count', aggCondition: '', valueExpression: '' }, + ], + groupBy: undefined, + orderBy: undefined, + granularity: undefined, + dateRange: searchedTimeRange, + } + : undefined); + if (!base) return; + + renderBuilderConfigAsSqlTemplate( + base as BuilderChartConfigWithDateRange, + metadata, + ) + .then(result => { + if (result.isError) return; + // Don't clobber a hand-edit made while generation was in flight, and + // only write while still in SQL mode. + if ( + getValues('configType') === 'sql' && + !getValues('sqlTemplate')?.trim() + ) { + setValue('sqlTemplate', result.sql, { shouldDirty: true }); + } + }) + .catch(() => { + // Leave the editor empty (with its placeholder) if conversion fails. + }); + }, + [ + setValue, + getValues, + aggViewChartConfig, + chartConfig, + searchedTimeRange, + metadata, + ], + ); + const onFormSubmit = useCallback>( e => { e.preventDefault(); @@ -2230,6 +2401,8 @@ function DBExplorePage() { } satisfies BuilderChartConfigWithDateRange; }, [chartConfig, severityExpression, searchedTimeRange, aliasWith]); + // Severity pills reflect the structured filter for the severity column, so + // they render as filter chips in the query bar alongside sidebar filters. const activeSeverityValues = useMemo(() => { if (!severityProperty) return []; const included = searchFilters.filters[severityProperty]?.included; @@ -2239,7 +2412,18 @@ function DBExplorePage() { const handleSeverityToggle = useCallback( (values: string[], isActive: boolean) => { if (!severityProperty) return; - searchFilters.setIncludedValues(severityProperty, isActive ? [] : values); + // Merge with any severity values already selected so error + warning can + // be active at once (adding one bucket doesn't drop the other). + const next = new Set( + Array.from(searchFilters.filters[severityProperty]?.included ?? []).map( + String, + ), + ); + for (const v of values) { + if (isActive) next.delete(v); + else next.add(v); + } + searchFilters.setIncludedValues(severityProperty, Array.from(next)); }, [searchFilters, severityProperty], ); @@ -2493,6 +2677,8 @@ function DBExplorePage() { onSaveView={onSaveSearch} onUpdate={() => setSaveSearchModalState('update')} onSaveAsNew={() => setSaveSearchModalState('create')} + saveDisabled={inputConfigType === 'sql'} + saveDisabledTooltip="SQL searches aren't savable yet" onOpenAlert={openAlertModal} onDelete={() => deleteSavedSearch.mutate(savedSearch?.id ?? '', { @@ -2525,6 +2711,21 @@ function DBExplorePage() { sourceId={inputSource} isExpanded={isQueryExpanded} onToggleExpand={toggleQueryExpanded} + queryMode={inputConfigType} + onQueryModeChange={handleQueryModeChange} + sqlTemplateName="sqlTemplate" + rawSqlDisplayType={ + isAggregatedSearchView(view) + ? searchViewToDisplayType(view) + : DisplayType.Table + } + filtersSlot={ + + } controls={ <> - {searchedConfig != null && searchedSource != null && ( } addToDashboard={ - isAggregatedSearchView(view) && - addToDashboardConfig && ( - - ) + isSqlMode + ? rawSqlAddToDashboardConfig && ( + + ) + : isAggregatedSearchView(view) && + addToDashboardConfig && ( + + ) } sortControl={ - <> - {view === 'list' && ( - ({ - value: column, - label: column, - }))} - activeField={listSort.field} - direction={listSort.direction} - onChange={applyListSort} - onRevert={revertListSort} - canRevert={!!searchedConfig.orderBy} - sqlSlot={ - - } - /> - )} - {(view === 'table' || - view === 'bar' || - view === 'pie' || - view === 'treemap') && ( - { - setAggConfig({ - sort: field as AggSortField, - sortDir: dir, - }); - onSubmit(); - }} - onRevert={() => { - setAggConfig({ - sort: 'value', - sortDir: 'desc', - }); - onSubmit(); - }} - canRevert={ - aggConfig.sort !== 'value' || - aggConfig.sortDir !== 'desc' - } - /> - )} - + !isSqlMode && ( + <> + {view === 'list' && ( + ({ + value: column, + label: column, + }))} + activeField={listSort.field} + direction={listSort.direction} + onChange={applyListSort} + onRevert={revertListSort} + canRevert={!!searchedConfig.orderBy} + sqlSlot={ + + } + /> + )} + {(view === 'table' || + view === 'bar' || + view === 'pie' || + view === 'treemap') && ( + { + setAggConfig({ + sort: field as AggSortField, + sortDir: dir, + }); + onSubmit(); + }} + onRevert={() => { + setAggConfig({ + sort: 'value', + sortDir: 'desc', + }); + onSubmit(); + }} + canRevert={ + aggConfig.sort !== 'value' || + aggConfig.sortDir !== 'desc' + } + /> + )} + + ) } columnsControl={ - view === 'list' && ( + view === 'list' && + !isSqlMode && ( } /> - {isAggregatedSearchView(view) && ( + {isAggregatedSearchView(view) && !isSqlMode && ( )} - {viewShowsHistogram(view) && !hasQueryError && ( + {viewShowsHistogram(view) && !hasQueryError && !isSqlMode && ( + ) : isSqlMode ? ( + + {rawSqlChartConfig && + (view === 'timeseries' ? ( + + ) : view === 'number' ? ( + + ) : view === 'bar' ? ( + + ) : view === 'pie' ? ( + + ) : view === 'treemap' ? ( + + ) : ( + + ))} + ) : view === 'patterns' ? ( void; onOpenAlert: () => void; onDelete: () => void; + /** Disable saving (e.g. SQL mode isn't persistable to saved searches yet). */ + saveDisabled?: boolean; + saveDisabledTooltip?: string; }) { return ( } > @@ -223,6 +230,8 @@ export function ExploreContextBand({ variant="primary" size="xs" onClick={onUpdate} + disabled={saveDisabled} + title={saveDisabled ? saveDisabledTooltip : undefined} style={{ flexShrink: 0 }} > Save @@ -232,6 +241,8 @@ export function ExploreContextBand({ variant="secondary" size="xs" onClick={onSaveAsNew} + disabled={saveDisabled} + title={saveDisabled ? saveDisabledTooltip : undefined} style={{ flexShrink: 0 }} > Save as… diff --git a/packages/app/src/components/Explore/ExploreQueryEditor.tsx b/packages/app/src/components/Explore/ExploreQueryEditor.tsx index 46970d11d9..1356d7871a 100644 --- a/packages/app/src/components/Explore/ExploreQueryEditor.tsx +++ b/packages/app/src/components/Explore/ExploreQueryEditor.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { FieldPath, useController, UseControllerProps } from 'react-hook-form'; import { TableConnectionChoice } from '@hyperdx/common-utils/dist/core/metadata'; +import { DisplayType } from '@hyperdx/common-utils/dist/types'; import { ActionIcon, Tooltip } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { IconHelp } from '@tabler/icons-react'; @@ -10,7 +11,8 @@ import SyntaxReferenceModal from '@/components/SearchInput/SyntaxReferenceModal' import { useMultipleAllFields } from '@/hooks/useMetadata'; import { useSource } from '@/source'; -import { QueryEditor, QueryLanguage } from './QueryEditor'; +import { ExploreRawSqlEditor } from './ExploreRawSqlEditor'; +import { QueryConfigMode, QueryEditor, QueryLanguage } from './QueryEditor'; const STORAGE_KEY = 'hdx-search-where-language'; @@ -37,6 +39,19 @@ export type ExploreQueryEditorProps = { onToggleExpand: () => void; /** Right-aligned header controls (time picker, Live, Run, ...). */ controls?: React.ReactNode; + /** Active filter chips rendered inside the card, below the input. */ + filtersSlot?: React.ReactNode; + /** + * Query authoring mode. When provided, a `Builder | SQL` toggle is shown; in + * `'sql'` mode the WHERE editor is swapped for a raw-SQL editor bound to + * `sqlTemplateName`. + */ + queryMode?: QueryConfigMode; + onQueryModeChange?: (mode: QueryConfigMode) => void; + /** Form field name for the raw-SQL template (SQL mode). */ + sqlTemplateName?: string; + /** Display type the raw-SQL query targets (drives macros/placeholder/help). */ + rawSqlDisplayType?: DisplayType; 'data-testid'?: string; } & TableConnectionChoice & UseControllerProps; @@ -61,6 +76,11 @@ export function ExploreQueryEditor({ isExpanded, onToggleExpand, controls, + filtersSlot, + queryMode, + onQueryModeChange, + sqlTemplateName = 'sqlTemplate', + rawSqlDisplayType = DisplayType.Table, 'data-testid': dataTestId, }: ExploreQueryEditorProps) { const [syntaxRefOpened, { open: openSyntaxRef, close: closeSyntaxRef }] = @@ -117,9 +137,12 @@ export function ExploreQueryEditor({ language={language} onLanguageChange={handleLanguageChange} languages={['sql', 'lucene']} + queryMode={queryMode} + onQueryModeChange={onQueryModeChange} expanded={isExpanded} onToggleExpanded={onToggleExpand} rightSection={controls} + filtersSlot={filtersSlot} leftSection={ + > + {queryMode === 'sql' ? ( + } + tableConnections={_tableConnections ?? []} + displayType={rawSqlDisplayType} + dateRange={dateRange} + timestampValueExpression={source?.timestampValueExpression} + onSubmit={onSubmit} + /> + ) : null} + ); } diff --git a/packages/app/src/components/Explore/ExploreRawSqlEditor.tsx b/packages/app/src/components/Explore/ExploreRawSqlEditor.tsx new file mode 100644 index 0000000000..63e4390ec2 --- /dev/null +++ b/packages/app/src/components/Explore/ExploreRawSqlEditor.tsx @@ -0,0 +1,113 @@ +import { useMemo } from 'react'; +import { UseControllerProps } from 'react-hook-form'; +import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; +import { MACRO_SUGGESTIONS } from '@hyperdx/common-utils/dist/macros'; +import { QUERY_PARAMS_BY_DISPLAY_TYPE } from '@hyperdx/common-utils/dist/rawSqlParams'; +import { DisplayType } from '@hyperdx/common-utils/dist/types'; +import { ActionIcon, Box, Group, Text, Tooltip } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { IconHelp } from '@tabler/icons-react'; + +import { SQL_PLACEHOLDERS } from '@/components/ChartEditor/constants'; +import { RawSqlChartInstructions } from '@/components/ChartEditor/RawSqlChartInstructions'; +import { SQLEditorControlled } from '@/components/SQLEditor/SQLEditor'; +import { type SQLCompletion } from '@/components/SQLEditor/utils'; + +import styles from './QueryEditor.module.scss'; + +type ExploreRawSqlEditorProps = { + /** Table connections offered for column/table autocomplete. */ + tableConnections: TableConnection[]; + /** Display type the query targets — drives placeholder, params, and help. */ + displayType?: DisplayType; + dateRange?: [Date, Date]; + timestampValueExpression?: string; + onSubmit?: () => void; +} & UseControllerProps; + +/** + * Raw-SQL editor for the Explore page's SQL mode. Reuses the shared + * `SQLEditorControlled` (ClickHouse dialect + column autocomplete) and layers + * on macro/param completions (`$__sourceTable`, `$__filters`, + * `{startDateMilliseconds:Int64}`, ...) plus a collapsible column-mapping + * reference, mirroring the chart editor's raw-SQL experience. + */ +export function ExploreRawSqlEditor({ + tableConnections, + displayType = DisplayType.Table, + dateRange, + timestampValueExpression, + onSubmit, + ...controllerProps +}: ExploreRawSqlEditorProps) { + const [instructionsOpen, { toggle: toggleInstructions }] = + useDisclosure(false); + + const additionalCompletions = useMemo(() => { + const params = QUERY_PARAMS_BY_DISPLAY_TYPE[displayType] ?? []; + + const paramCompletions: SQLCompletion[] = params.map(({ name, type }) => ({ + label: `{${name}:${type}}`, + // Omit the closing } because the editor auto-inserts it on { + apply: `{${name}:${type}`, + detail: 'param', + type: 'variable', + })); + + const macroCompletions: SQLCompletion[] = MACRO_SUGGESTIONS.map( + ({ name, minArgs }) => ({ + label: `$__${name}`, + apply: minArgs > 0 ? `$__${name}(` : `$__${name}`, + detail: 'macro', + type: 'function', + }), + ); + + return [...paramCompletions, ...macroCompletions]; + }, [displayType]); + + return ( + + + + + + + + + Write a full query with macros like{' '} + + $__sourceTable + {' '} + and{' '} + + $__filters + + . + + + {instructionsOpen ? ( + + + + ) : null} + + + ); +} diff --git a/packages/app/src/components/Explore/QueryEditor.module.scss b/packages/app/src/components/Explore/QueryEditor.module.scss index adc12640b2..9adaae3480 100644 --- a/packages/app/src/components/Explore/QueryEditor.module.scss +++ b/packages/app/src/components/Explore/QueryEditor.module.scss @@ -20,6 +20,11 @@ padding: 2px 6px; } +/* Raw-SQL body: full statement editor with a help/instructions row above it. */ +.sqlBody { + padding: 6px; +} + /* Blend the line-number gutter into the card. */ .body :global(.cm-gutters) { background: transparent !important; @@ -29,3 +34,15 @@ .body :global(.cm-editor) { background: transparent !important; } + +/* Active filter chips live inside the card, below the input. Collapse the + strip entirely when there are no chips (ActiveFilterPills renders null). */ +.filters { + padding: 6px 0; + border-top: 1px solid var(--color-border); + background-color: var(--color-bg-surface); + + &:empty { + display: none; + } +} diff --git a/packages/app/src/components/Explore/QueryEditor.tsx b/packages/app/src/components/Explore/QueryEditor.tsx index 7a5e64dbdf..cd0685392f 100644 --- a/packages/app/src/components/Explore/QueryEditor.tsx +++ b/packages/app/src/components/Explore/QueryEditor.tsx @@ -44,6 +44,9 @@ import styles from './QueryEditor.module.scss'; export type QueryLanguage = 'sql' | 'lucene'; +/** Query authoring mode: builder edits WHERE only, sql is a full statement. */ +export type QueryConfigMode = 'builder' | 'sql'; + const DEFAULT_LANGUAGES: QueryLanguage[] = ['sql', 'lucene']; const EMPTY_FIELDS: string[] = []; @@ -56,6 +59,16 @@ export interface QueryEditorProps { onLanguageChange: (language: QueryLanguage) => void; /** Which languages appear in the toggle (also controls order). */ languages?: QueryLanguage[]; + /** + * Query authoring mode. When provided, a `Builder | SQL` toggle is shown at + * the far left of the header. In `'sql'` mode the CodeMirror WHERE editor is + * replaced by `children` (a raw-SQL editor) and the language toggle, expand + * toggle, and `leftSection` are hidden. + */ + queryMode?: QueryConfigMode; + onQueryModeChange?: (mode: QueryConfigMode) => void; + /** Body override rendered instead of the WHERE editor when in SQL mode. */ + children?: React.ReactNode; /** Expanded (controlled) — multiline vs single line. */ expanded: boolean; onToggleExpanded: () => void; @@ -63,6 +76,8 @@ export interface QueryEditorProps { rightSection?: React.ReactNode; /** Extra node next to the language tabs (e.g. a syntax-help button). */ leftSection?: React.ReactNode; + /** Active filter chips rendered inside the card, below the input. */ + filtersSlot?: React.ReactNode; /** Field names offered by autocomplete (both languages). */ fields?: string[]; placeholder?: string; @@ -195,10 +210,14 @@ export function QueryEditor({ language, onLanguageChange, languages = DEFAULT_LANGUAGES, + queryMode, + onQueryModeChange, + children, expanded, onToggleExpanded, rightSection, leftSection, + filtersSlot, fields = EMPTY_FIELDS, placeholder = 'Search your events…', onSubmit, @@ -262,13 +281,27 @@ export function QueryEditor({ if (view) startCompletion(view); }, []); - const showToggle = languages.length > 1; + const isSqlMode = queryMode === 'sql'; + const showToggle = languages.length > 1 && !isSqlMode; const collapsedHeight = '30px'; return ( + {onQueryModeChange != null && ( + onQueryModeChange(v as QueryConfigMode)} + data={[ + { value: 'builder', label: 'Builder' }, + { value: 'sql', label: 'SQL' }, + ]} + aria-label="Query mode" + data-testid="query-mode-toggle" + /> + )} {showToggle && ( )} - {leftSection} + {!isSqlMode && leftSection} {rightSection} - - - {expanded ? ( - - ) : ( - - )} - - + + {expanded ? ( + + ) : ( + + )} + + + )} - - - + {isSqlMode ? ( + {children} + ) : ( + + + + )} + {filtersSlot != null && ( + {filtersSlot} + )} ); } diff --git a/packages/app/src/components/Search/searchViews.tsx b/packages/app/src/components/Search/searchViews.tsx index e4085ab16f..e08eb42d2f 100644 --- a/packages/app/src/components/Search/searchViews.tsx +++ b/packages/app/src/components/Search/searchViews.tsx @@ -163,10 +163,17 @@ export function SearchViewSwitcher({ value, onChange, sourceKind, + chartTypesOnly = false, }: { value: SearchView; onChange: (view: SearchView) => void; sourceKind?: SourceKind; + /** + * When true, only the aggregated chart views are shown. Used by SQL mode, + * where the switcher picks a raw-SQL display type (the aggregated views map + * 1:1 to the raw-SQL display types) rather than a builder view. + */ + chartTypesOnly?: boolean; }) { const options = useMemo( () => @@ -181,9 +188,12 @@ export function SearchViewSwitcher({ // (time series / number / table / bar / pie / treemap) make sense — // the List, Event deltas, and Event patterns views are hidden. if (sourceKind === SourceKind.Metric && !v.aggregated) return false; + // SQL mode renders a single raw-SQL statement as a chart display type, + // so only the aggregated (chart) views apply. + if (chartTypesOnly && !v.aggregated) return false; return true; }), - [sourceKind], + [sourceKind, chartTypesOnly], ); return ( From d970591f958ebf4af975f763aee5f3f4a3e7dd71 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Wed, 29 Jul 2026 19:14:54 +0100 Subject: [PATCH 24/31] feat(app): restyle Explore severity pills as toolbar buttons Render the error/warning severity summary as secondary toolbar buttons with status icons and a count chip (matching the Columns/Sort controls), tinting with the semantic status color only while active. Treat a bucket as active via a subset test so both severities can be filtered at once. Co-authored-by: Cursor --- .../components/Explore/SeveritySummary.tsx | 137 +++++++++--------- 1 file changed, 72 insertions(+), 65 deletions(-) diff --git a/packages/app/src/components/Explore/SeveritySummary.tsx b/packages/app/src/components/Explore/SeveritySummary.tsx index 5ac17247bd..01c42b4f40 100644 --- a/packages/app/src/components/Explore/SeveritySummary.tsx +++ b/packages/app/src/components/Explore/SeveritySummary.tsx @@ -5,7 +5,8 @@ import { ResponseJSON, } from '@hyperdx/common-utils/dist/clickhouse'; import { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types'; -import { Box, Group, Tooltip, UnstyledButton } from '@mantine/core'; +import { Box, Button, Group, Tooltip } from '@mantine/core'; +import { IconAlertCircle, IconAlertTriangle } from '@tabler/icons-react'; import { keepPreviousData } from '@tanstack/react-query'; import { useQueriedChartConfig } from '@/hooks/useChartConfig'; @@ -21,6 +22,19 @@ const BUCKET_COLOR: Record = { warn: 'var(--color-chart-warning)', }; +const BUCKET_ICON: Record< + SeverityBucket, + React.ComponentType<{ size?: number; color?: string }> +> = { + error: IconAlertCircle, + warn: IconAlertTriangle, +}; + +const BUCKET_TITLE: Record = { + error: 'Error', + warn: 'Warning', +}; + // Reuse the canonical log-level classifier so bucketing matches the log level // coloring used elsewhere (ERROR/FATAL/CRIT/... → error, WARN → warn). function classifySeverity(value: string): SeverityBucket | null { @@ -96,93 +110,86 @@ export function SeveritySummary({ }, [data]); const activeSet = new Set(activeValues ?? []); + // Active when every value in this bucket is present in the query. The query + // may hold both buckets' values at once, so this is a subset test rather than + // an exact match. const isBucketActive = (bucket: BucketResult) => - bucket.values.length > 0 && - bucket.values.every(v => activeSet.has(v)) && - activeSet.size === bucket.values.length; + bucket.values.length > 0 && bucket.values.every(v => activeSet.has(v)); const pills: { bucket: SeverityBucket; result: BucketResult; - label: string; }[] = [ - { bucket: 'error', result: error, label: 'error' }, - { bucket: 'warn', result: warn, label: 'warning' }, + { bucket: 'error', result: error }, + { bucket: 'warn', result: warn }, ]; - const visiblePills = pills.filter( - p => p.result.count > 0 || isBucketActive(p.result), - ); - if (visiblePills.length === 0) return null; - return ( - - {visiblePills.map(({ bucket, result, label }) => { + + {pills.map(({ bucket, result }) => { const active = isBucketActive(result); - const clickable = onToggle != null && result.values.length > 0; + const clickable = + onToggle != null && (result.values.length > 0 || active); const color = BUCKET_COLOR[bucket]; - const text = `${numberFormatter.format(result.count)} ${ - result.count === 1 ? label : `${label}s` - }`; - const pillStyle: React.CSSProperties = { + const Icon = BUCKET_ICON[bucket]; + const title = BUCKET_TITLE[bucket]; + + // Neutral secondary button (matching the Columns/Sort controls); tinted + // with the semantic status color only while its filter is active. + const rootStyle: React.CSSProperties = active + ? { + borderColor: `color-mix(in srgb, ${color} 55%, transparent)`, + backgroundColor: `color-mix(in srgb, ${color} 12%, transparent)`, + color, + } + : {}; + + const countChipStyle: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', - gap: 6, - height: 22, - padding: '0 8px', - borderRadius: 'var(--mantine-radius-sm)', - fontSize: 'var(--mantine-font-size-xs)', - fontWeight: 500, + justifyContent: 'center', + minWidth: 18, + height: 16, + padding: '0 5px', + borderRadius: 999, + fontSize: 11, + fontWeight: 600, lineHeight: 1, - whiteSpace: 'nowrap', - color: active ? '#fff' : color, backgroundColor: active - ? color - : `color-mix(in srgb, ${color} 14%, transparent)`, - border: `1px solid color-mix(in srgb, ${color} ${ - active ? '0%' : '35%' - }, transparent)`, - cursor: clickable ? 'pointer' : 'default', + ? `color-mix(in srgb, ${color} 22%, transparent)` + : 'var(--mantine-color-default-hover)', + color: active ? color : 'var(--mantine-color-dimmed)', }; - const dot = ( - - ); - const pill = clickable ? ( - onToggle?.(result.values, active)} - data-testid={`severity-summary-${bucket}`} - > - {dot} - {text} - - ) : ( - - {dot} - {text} - - ); + return ( - {pill} + ); })} From 912564f22fe6a45629289d8dcfacfb065833394a Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Wed, 29 Jul 2026 20:28:20 +0100 Subject: [PATCH 25/31] feat(app): refine Explore query editor and SQL-mode UX - Auto-expand the builder query editor (remove the manual expand toggle and line numbers; grow with content up to a max height, then scroll) - Drive SQL-mode chrome off the live editor toggle so builder-only controls (histogram, severity, sort/columns, agg controls) hide the instant you switch to SQL, while query execution still waits for Run - Unify the Builder/SQL switch and SQL/Lucene toggle into a single three-way control (SQL | Lucene | Advanced) Co-authored-by: Cursor --- packages/app/src/DBExplorePage.tsx | 95 +++++++----- .../components/Explore/ExploreQueryEditor.tsx | 7 - .../src/components/Explore/QueryEditor.tsx | 138 ++++++++---------- 3 files changed, 113 insertions(+), 127 deletions(-) diff --git a/packages/app/src/DBExplorePage.tsx b/packages/app/src/DBExplorePage.tsx index 39104e2384..e142e51ab5 100644 --- a/packages/app/src/DBExplorePage.tsx +++ b/packages/app/src/DBExplorePage.tsx @@ -1179,8 +1179,9 @@ function DBExplorePage() { const [view, setView] = useSearchView(); const [aggConfig, setAggConfig] = useSearchAggConfig(); - // Submitted query mode (drives results rendering + control gating). The live - // form value (`inputConfigType`) drives the query-editor toggle instead. + // Submitted query mode: drives query *execution* (the raw-SQL chart config) + // so results only change on Run. The live form value (`isSqlUiMode`, defined + // once the form exists) gates the SQL-mode UI/chrome instead. const searchedConfigType: QueryConfigMode = searchedConfig.configType ?? 'builder'; const isSqlMode = searchedConfigType === 'sql'; @@ -1596,8 +1597,6 @@ function DBExplorePage() { savedSearchesFlyoutOpened, { open: openSavedSearchesFlyout, close: closeSavedSearchesFlyout }, ] = useDisclosure(false); - const [isQueryExpanded, { toggle: toggleQueryExpanded }] = - useDisclosure(false); const chartSearchConfig = useMemo( () => ({ select: searchedConfig.select ?? '', @@ -1633,6 +1632,11 @@ function DBExplorePage() { const inputWhereLanguage = useWatch({ name: 'whereLanguage', control }); const inputConfigType: QueryConfigMode = useWatch({ name: 'configType', control }) ?? 'builder'; + // Live SQL-mode flag: driven by the editor toggle (not the last-run config) + // so builder-only chrome (histogram, severity, sort/columns, agg controls, + // view switcher) hides the instant you switch to SQL. Query *execution* still + // keys off `isSqlMode` (submitted) so nothing re-runs until Run. + const isSqlUiMode = inputConfigType === 'sql'; // query suggestion for 'where' if error const whereSuggestions = useSqlSuggestions({ input: inputWhere, @@ -2709,8 +2713,6 @@ function DBExplorePage() { data-testid="search-input" dateRange={searchedTimeRange} sourceId={inputSource} - isExpanded={isQueryExpanded} - onToggleExpand={toggleQueryExpanded} queryMode={inputConfigType} onQueryModeChange={handleQueryModeChange} sqlTemplateName="sqlTemplate" @@ -2837,7 +2839,7 @@ function DBExplorePage() { } addToDashboard={ - isSqlMode + isSqlUiMode ? rawSqlAddToDashboardConfig && ( {view === 'list' && ( - {isAggregatedSearchView(view) && !isSqlMode && ( + {isAggregatedSearchView(view) && !isSqlUiMode && ( )} - {viewShowsHistogram(view) && !hasQueryError && !isSqlMode && ( - - - - )} + {viewShowsHistogram(view) && + !hasQueryError && + !isSqlUiMode && ( + + + + )} {hasQueryError && queryError ? ( <>
@@ -3156,10 +3160,10 @@ function DBExplorePage() { )}
- ) : isSqlMode ? ( + ) : isSqlUiMode ? ( - {rawSqlChartConfig && - (view === 'timeseries' ? ( + {rawSqlChartConfig ? ( + view === 'timeseries' ? ( - ))} + ) + ) : ( + + + Press Run to execute your SQL query. + + + )} ) : view === 'patterns' ? ( diff --git a/packages/app/src/components/Explore/ExploreQueryEditor.tsx b/packages/app/src/components/Explore/ExploreQueryEditor.tsx index 1356d7871a..d083bf9a62 100644 --- a/packages/app/src/components/Explore/ExploreQueryEditor.tsx +++ b/packages/app/src/components/Explore/ExploreQueryEditor.tsx @@ -34,9 +34,6 @@ export type ExploreQueryEditorProps = { additionalSuggestions?: string[]; /** Form field name for the language value; defaults to `${name}Language`. */ languageName?: string; - /** Whether the editor body is expanded (multiline). */ - isExpanded: boolean; - onToggleExpand: () => void; /** Right-aligned header controls (time picker, Live, Run, ...). */ controls?: React.ReactNode; /** Active filter chips rendered inside the card, below the input. */ @@ -73,8 +70,6 @@ export function ExploreQueryEditor({ sourceId, additionalSuggestions, languageName = `${name}Language`, - isExpanded, - onToggleExpand, controls, filtersSlot, queryMode, @@ -139,8 +134,6 @@ export function ExploreQueryEditor({ languages={['sql', 'lucene']} queryMode={queryMode} onQueryModeChange={onQueryModeChange} - expanded={isExpanded} - onToggleExpanded={onToggleExpand} rightSection={controls} filtersSlot={filtersSlot} leftSection={ diff --git a/packages/app/src/components/Explore/QueryEditor.tsx b/packages/app/src/components/Explore/QueryEditor.tsx index cd0685392f..6e940a1eb2 100644 --- a/packages/app/src/components/Explore/QueryEditor.tsx +++ b/packages/app/src/components/Explore/QueryEditor.tsx @@ -13,20 +13,14 @@ import { type StreamParser, syntaxHighlighting, } from '@codemirror/language'; -import { EditorState, type Extension } from '@codemirror/state'; +import { type Extension } from '@codemirror/state'; import { tags as t } from '@lezer/highlight'; import { - ActionIcon, Box, Flex, SegmentedControl, - Tooltip, useMantineColorScheme, } from '@mantine/core'; -import { - IconArrowsDiagonal, - IconArrowsDiagonalMinimize2, -} from '@tabler/icons-react'; import CodeMirror, { EditorView, keymap, @@ -62,16 +56,13 @@ export interface QueryEditorProps { /** * Query authoring mode. When provided, a `Builder | SQL` toggle is shown at * the far left of the header. In `'sql'` mode the CodeMirror WHERE editor is - * replaced by `children` (a raw-SQL editor) and the language toggle, expand - * toggle, and `leftSection` are hidden. + * replaced by `children` (a raw-SQL editor) and the language toggle and + * `leftSection` are hidden. */ queryMode?: QueryConfigMode; onQueryModeChange?: (mode: QueryConfigMode) => void; /** Body override rendered instead of the WHERE editor when in SQL mode. */ children?: React.ReactNode; - /** Expanded (controlled) — multiline vs single line. */ - expanded: boolean; - onToggleExpanded: () => void; /** Right-aligned header controls (date picker, Live, Run, ...). */ rightSection?: React.ReactNode; /** Extra node next to the language tabs (e.g. a syntax-help button). */ @@ -81,11 +72,12 @@ export interface QueryEditorProps { /** Field names offered by autocomplete (both languages). */ fields?: string[]; placeholder?: string; - /** Fired on Enter (Shift+Enter inserts a newline when expanded). */ + /** Fired on Enter (Shift+Enter inserts a newline). */ onSubmit?: () => void; /** Focus the editor on "/" or "s" when true. */ enableHotkey?: boolean; - maxExpandedHeight?: number; + /** Max body height (px) before the editor scrolls. Defaults to 200. */ + maxHeight?: number; 'data-testid'?: string; } @@ -125,20 +117,6 @@ const queryHighlightStyle = HighlightStyle.define([ }, ]); -/* Block newline insertion while collapsed so a single line stays single. */ -const singleLine = EditorState.transactionFilter.of(tr => { - if (!tr.docChanged) return tr; - let insertsNewline = false; - tr.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => { - if (inserted.lines > 1) insertsNewline = true; - }); - return insertsNewline ? [] : tr; -}); - -const clipScroller = EditorView.theme({ - '.cm-scroller': { overflow: 'hidden' }, -}); - /** * Minimal Lucene highlighter. `@codemirror/legacy-modes` has no Lucene mode, so * we tokenize the essentials here — quoted strings, `field:` names, boolean @@ -200,9 +178,10 @@ function languageExtensions( /** * Presentational query editor: a bordered card with language tabs and header - * controls on top of a CodeMirror body that renders SQL or Lucene with line - * numbers and syntax highlighting. Fully controlled — value, language, and - * expanded state are owned by the caller. + * controls on top of a CodeMirror body that renders SQL or Lucene with syntax + * highlighting. The body auto-grows with its content (wrapping long lines) up + * to `maxHeight` before scrolling. Fully controlled — value and language are + * owned by the caller. */ export function QueryEditor({ value, @@ -213,8 +192,6 @@ export function QueryEditor({ queryMode, onQueryModeChange, children, - expanded, - onToggleExpanded, rightSection, leftSection, filtersSlot, @@ -222,7 +199,7 @@ export function QueryEditor({ placeholder = 'Search your events…', onSubmit, enableHotkey, - maxExpandedHeight = 320, + maxHeight = 200, 'data-testid': dataTestId, }: QueryEditorProps) { const { colorScheme } = useMantineColorScheme(); @@ -248,6 +225,10 @@ export function QueryEditor({ const extensions = useMemo(() => { const submitKeymap = Prec.highest( + // The Enter handler reads `onSubmitRef.current`, but only when the key is + // pressed (an event), never during render — the compiler can't tell the + // CodeMirror `run` callback isn't a render-time ref read, so disable here. + // eslint-disable-next-line react-hooks/refs keymap.of([ { key: 'Enter', @@ -260,7 +241,8 @@ export function QueryEditor({ return true; }, }, - ...(expanded ? [{ key: 'Shift-Enter', run: () => false }] : []), + // Shift+Enter inserts a newline (the editor auto-grows to fit). + { key: 'Shift-Enter', run: () => false }, ]), ); return [ @@ -270,9 +252,9 @@ export function QueryEditor({ submitKeymap, keymap.of([{ key: 'Tab', run: acceptCompletion }]), ...languageExtensions(language, fields), - ...(expanded ? [EditorView.lineWrapping] : [singleLine, clipScroller]), + EditorView.lineWrapping, ]; - }, [language, expanded, fields]); + }, [language, fields]); // Surface field/variable suggestions as soon as the editor is focused, so // people can discover available fields without knowing exact names. @@ -283,36 +265,52 @@ export function QueryEditor({ const isSqlMode = queryMode === 'sql'; const showToggle = languages.length > 1 && !isSqlMode; - const collapsedHeight = '30px'; + + // Combined authoring control: builder WHERE languages plus an "Advanced" + // option that maps to raw-SQL mode, so the value spans both `queryMode` and + // `language` and "SQL" isn't repeated across two adjacent controls. + const modeValue = isSqlMode ? 'advanced' : language; + const handleModeChange = (v: string) => { + if (v === 'advanced') { + onQueryModeChange?.('sql'); + return; + } + if (isSqlMode) onQueryModeChange?.('builder'); + onLanguageChange(v as QueryLanguage); + }; return ( - {onQueryModeChange != null && ( + {onQueryModeChange != null ? ( onQueryModeChange(v as QueryConfigMode)} + value={modeValue} + onChange={handleModeChange} data={[ - { value: 'builder', label: 'Builder' }, - { value: 'sql', label: 'SQL' }, + ...languages.map(l => ({ + value: l, + label: l === 'sql' ? 'SQL' : 'Lucene', + })), + { value: 'advanced', label: 'Advanced' }, ]} aria-label="Query mode" data-testid="query-mode-toggle" /> - )} - {showToggle && ( - onLanguageChange(v as QueryLanguage)} - data={languages.map(l => ({ - value: l, - label: l === 'sql' ? 'SQL' : 'Lucene', - }))} - aria-label="Query language" - /> + ) : ( + showToggle && ( + onLanguageChange(v as QueryLanguage)} + data={languages.map(l => ({ + value: l, + label: l === 'sql' ? 'SQL' : 'Lucene', + }))} + aria-label="Query language" + /> + ) )} {!isSqlMode && leftSection} @@ -324,28 +322,6 @@ export function QueryEditor({ className={styles.controls} > {rightSection} - {!isSqlMode && ( - - - {expanded ? ( - - ) : ( - - )} - - - )} {isSqlMode ? ( @@ -360,11 +336,11 @@ export function QueryEditor({ placeholder={placeholder} theme={colorScheme === 'dark' ? 'dark' : 'light'} extensions={extensions} - height={expanded ? 'auto' : collapsedHeight} - minHeight={expanded ? '96px' : collapsedHeight} - maxHeight={expanded ? `${maxExpandedHeight}px` : collapsedHeight} + height="auto" + minHeight="24px" + maxHeight={`${maxHeight}px`} basicSetup={{ - lineNumbers: true, + lineNumbers: false, foldGutter: false, highlightActiveLine: false, highlightActiveLineGutter: false, From 404b34e2efe0b164ba40f98fe414fbf06373e605 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Thu, 30 Jul 2026 11:28:13 +0100 Subject: [PATCH 26/31] feat(app): consolidate Explore results toolbar into a single dense row Merge the read-only stats line and the view/actions line into one row: the view switcher, severity pills, and result/scanned-row/elapsed stats sit on the left, while add-to-dashboard and the overflow menu stay pinned right. A shape-the-view row now appears only when the active view exposes controls (aggregation on the left; columns / sort on the right). - Partition the view switcher into raw vs chart views separated by a divider so the icon strip reads as two related clusters. - Let the toolbar's left cluster wrap gracefully so the severity pills and stats are never pushed under the actions on narrow layouts. - Flatten the query editor so the mode toggle, editor, and filters no longer sit inside a card. Co-authored-by: Cursor --- packages/app/src/DBExplorePage.tsx | 196 +++++++++--------- .../Explore/ExploreResultsToolbar.tsx | 56 +++-- .../Explore/QueryEditor.module.scss | 33 ++- .../app/src/components/Search/searchViews.tsx | 41 ++-- 4 files changed, 174 insertions(+), 152 deletions(-) diff --git a/packages/app/src/DBExplorePage.tsx b/packages/app/src/DBExplorePage.tsx index e142e51ab5..62936712bc 100644 --- a/packages/app/src/DBExplorePage.tsx +++ b/packages/app/src/DBExplorePage.tsx @@ -2896,98 +2896,6 @@ function DBExplorePage() { /> ) } - sortControl={ - !isSqlUiMode && ( - <> - {view === 'list' && ( - ({ - value: column, - label: column, - }))} - activeField={listSort.field} - direction={listSort.direction} - onChange={applyListSort} - onRevert={revertListSort} - canRevert={!!searchedConfig.orderBy} - sqlSlot={ - - } - /> - )} - {(view === 'table' || - view === 'bar' || - view === 'pie' || - view === 'treemap') && ( - { - setAggConfig({ - sort: field as AggSortField, - sortDir: dir, - }); - onSubmit(); - }} - onRevert={() => { - setAggConfig({ - sort: 'value', - sortDir: 'desc', - }); - onSubmit(); - }} - canRevert={ - aggConfig.sort !== 'value' || - aggConfig.sortDir !== 'desc' - } - /> - )} - - ) - } - columnsControl={ - view === 'list' && - !isSqlUiMode && ( - - } - /> - ) - } overflowMenu={ } + shapeControls={ + !isSqlUiMode && isAggregatedSearchView(view) ? ( + + ) : undefined + } + shapeActions={ + isSqlUiMode ? undefined : view === 'list' ? ( + <> + + } + /> + ({ + value: column, + label: column, + }))} + activeField={listSort.field} + direction={listSort.direction} + onChange={applyListSort} + onRevert={revertListSort} + canRevert={!!searchedConfig.orderBy} + sqlSlot={ + + } + /> + + ) : view === 'table' || + view === 'bar' || + view === 'pie' || + view === 'treemap' ? ( + { + setAggConfig({ + sort: field as AggSortField, + sortDir: dir, + }); + onSubmit(); + }} + onRevert={() => { + setAggConfig({ sort: 'value', sortDir: 'desc' }); + onSubmit(); + }} + canRevert={ + aggConfig.sort !== 'value' || + aggConfig.sortDir !== 'desc' + } + /> + ) : undefined + } /> - {isAggregatedSearchView(view) && !isSqlUiMode && ( - - )} {viewShowsHistogram(view) && !hasQueryError && !isSqlUiMode && ( diff --git a/packages/app/src/components/Explore/ExploreResultsToolbar.tsx b/packages/app/src/components/Explore/ExploreResultsToolbar.tsx index 63d2ed1d7f..47c16e72c2 100644 --- a/packages/app/src/components/Explore/ExploreResultsToolbar.tsx +++ b/packages/app/src/components/Explore/ExploreResultsToolbar.tsx @@ -1,10 +1,13 @@ -import { Group, Stack } from '@mantine/core'; +import { Divider, Group, Stack } from '@mantine/core'; /** - * Results band for the Explore page: a stats line (result/scanned-row counts - * and elapsed time) above a controls row (view switcher on the left; sort, - * columns, generated SQL and export on the right). Purely presentational - - * every piece is passed in as a slot so the page keeps the view-specific logic. + * Results band for the Explore page. The primary row packs everything a reader + * scans first into a single dense line: the view switcher, severity summary, + * and result/scanned-row/elapsed stats on the left, with add-to-dashboard and + * the overflow menu pinned to the right. A second "shape-the-view" row appears + * only when the current view exposes controls (aggregation on the left; columns + * / sort on the right). Purely presentational - every piece is passed in as a + * slot so the page keeps the view-specific logic. */ export function ExploreResultsToolbar({ resultsCount, @@ -12,9 +15,9 @@ export function ExploreResultsToolbar({ filterExpand, viewSwitcher, addToDashboard, - sortControl, - columnsControl, overflowMenu, + shapeControls, + shapeActions, }: { resultsCount?: React.ReactNode; stats?: React.ReactNode; @@ -22,29 +25,46 @@ export function ExploreResultsToolbar({ viewSwitcher: React.ReactNode; /** "Add to dashboard" action, shown only for chart-tile views. */ addToDashboard?: React.ReactNode; - sortControl?: React.ReactNode; - columnsControl?: React.ReactNode; /** Overflow (3-dots) menu holding secondary actions (SQL, export). */ overflowMenu?: React.ReactNode; + /** Left side of the shape-the-view row: the aggregation sentence. */ + shapeControls?: React.ReactNode; + /** Right side of the shape-the-view row: sort / columns adjustments. */ + shapeActions?: React.ReactNode; }) { + const hasStats = Boolean(resultsCount) || Boolean(stats); return ( - - {resultsCount} - {stats} - - - + + {/* Left cluster wraps to a second line when the row is too narrow so + the severity pills / stats are never pushed under the actions. */} + {filterExpand} {viewSwitcher} + {hasStats && } + {stats} + {resultsCount} - + {addToDashboard} - {sortControl} - {columnsControl} {overflowMenu} + {(shapeControls != null || shapeActions != null) && ( + + + {shapeControls} + + + {shapeActions} + + + )} ); } diff --git a/packages/app/src/components/Explore/QueryEditor.module.scss b/packages/app/src/components/Explore/QueryEditor.module.scss index 9adaae3480..6efbdcf255 100644 --- a/packages/app/src/components/Explore/QueryEditor.module.scss +++ b/packages/app/src/components/Explore/QueryEditor.module.scss @@ -1,31 +1,34 @@ +/* Flat container: the mode control, editor, and filters sit directly on the + page rather than inside a bordered card. */ .card { - border: 1px solid var(--color-border); - border-radius: var(--mantine-radius-default); - background-color: var(--color-bg-field); - overflow: hidden; + display: flex; + flex-direction: column; + gap: 6px; } .header { - padding: 4px 6px 4px 6px; - border-bottom: 1px solid var(--color-border); - min-height: 38px; - background-color: var(--color-bg-surface); + min-height: 34px; } .controls { min-width: 0; } +/* The builder editor keeps an input-style border so it still reads as an + editable field once the surrounding card is gone. */ .body { - padding: 2px 6px; + padding: 3px 8px; + border: 1px solid var(--color-border); + border-radius: var(--mantine-radius-default); + background-color: var(--color-bg-field); } /* Raw-SQL body: full statement editor with a help/instructions row above it. */ .sqlBody { - padding: 6px; + display: flex; + flex-direction: column; } -/* Blend the line-number gutter into the card. */ .body :global(.cm-gutters) { background: transparent !important; border: none !important; @@ -35,13 +38,9 @@ background: transparent !important; } -/* Active filter chips live inside the card, below the input. Collapse the - strip entirely when there are no chips (ActiveFilterPills renders null). */ +/* Active filter chips sit below the input. Collapse the strip entirely when + there are no chips (ActiveFilterPills renders null). */ .filters { - padding: 6px 0; - border-top: 1px solid var(--color-border); - background-color: var(--color-bg-surface); - &:empty { display: none; } diff --git a/packages/app/src/components/Search/searchViews.tsx b/packages/app/src/components/Search/searchViews.tsx index e08eb42d2f..3c89ac0244 100644 --- a/packages/app/src/components/Search/searchViews.tsx +++ b/packages/app/src/components/Search/searchViews.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo } from 'react'; import { parseAsString, useQueryState } from 'nuqs'; import { DisplayType, SourceKind } from '@hyperdx/common-utils/dist/types'; -import { ActionIcon, Group, Tooltip } from '@mantine/core'; +import { ActionIcon, Divider, Group, Tooltip } from '@mantine/core'; import { IconBracketsContain, IconChartBar, @@ -196,6 +196,26 @@ export function SearchViewSwitcher({ [sourceKind, chartTypesOnly], ); + // Group raw (non-aggregated) views apart from the chart types so the strip of + // icons reads as two related clusters instead of one undifferentiated row. + const rawViews = options.filter(o => !o.aggregated); + const chartViews = options.filter(o => o.aggregated); + + const renderOption = (option: SearchViewMeta) => ( + + onChange(option.value)} + aria-label={option.label} + data-active={value === option.value || undefined} + > + {option.icon} + + + ); + return ( - {options.map(option => ( - - onChange(option.value)} - aria-label={option.label} - data-active={value === option.value || undefined} - > - {option.icon} - - - ))} + {rawViews.map(renderOption)} + {rawViews.length > 0 && chartViews.length > 0 && ( + + )} + {chartViews.map(renderOption)} ); } From 67c123724837ebd390040319e656d7e0fa1f0686 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 21 Aug 2026 22:20:14 +0100 Subject: [PATCH 27/31] fix(app): tidy Explore filters header, badge casing, and query-editor focus Keep the sidebar as a single Filters heading, leave Experimental in sentence case, and defer CodeMirror autocomplete so focus no longer updates the editor mid-cycle. Co-authored-by: Cursor --- .../components/AppNav/AppNav.components.tsx | 1 + .../src/components/DBSearchPageFilters.tsx | 276 ++++++++---------- .../src/components/Explore/QueryEditor.tsx | 10 +- 3 files changed, 129 insertions(+), 158 deletions(-) diff --git a/packages/app/src/components/AppNav/AppNav.components.tsx b/packages/app/src/components/AppNav/AppNav.components.tsx index 5bfd69269d..0052a22dcc 100644 --- a/packages/app/src/components/AppNav/AppNav.components.tsx +++ b/packages/app/src/components/AppNav/AppNav.components.tsx @@ -334,6 +334,7 @@ export const AppNavLink = ({ size="xs" color={badgeLabel ? (badgeColor ?? 'orange') : 'blue'} variant="light" + tt="none" className={styles.navItemBadge} > {badgeLabel ?? 'Beta'} diff --git a/packages/app/src/components/DBSearchPageFilters.tsx b/packages/app/src/components/DBSearchPageFilters.tsx index 4a5abed176..848c3f2501 100644 --- a/packages/app/src/components/DBSearchPageFilters.tsx +++ b/packages/app/src/components/DBSearchPageFilters.tsx @@ -16,7 +16,6 @@ import { Button, Center, Checkbox, - Collapse, Divider, Flex, Group, @@ -1121,10 +1120,6 @@ const DBSearchPageFiltersComponent = ({ 'hdx-show-filter-counts', true, ); - const [isFiltersExpanded, setFiltersExpanded] = useLocalStorage( - 'hdx-filters-expanded', - true, - ); const [isSharedFiltersExpanded, setSharedFiltersExpanded] = useLocalStorage( 'hdx-shared-filters-expanded', true, @@ -1622,8 +1617,13 @@ const DBSearchPageFiltersComponent = ({ > - - Analysis Mode + + Filters {isFacetsFetching && '···'} {showRefreshButton && ( @@ -1636,6 +1636,25 @@ const DBSearchPageFiltersComponent = ({ } /> )} + {showFiltersClearButton && ( + + + + + + )} )} - {/* Collapsible "Filters" section */} - - - setFiltersExpanded(!isFiltersExpanded)} - style={{ flex: 1 }} - > - - Filters {isFacetsFetching && '···'} - - - - {showFiltersClearButton && ( + + {analysisMode === 'results' && ( + - - - + + + + Denoise Results + + - )} - setFiltersExpanded(!isFiltersExpanded)} - > - - - - - - - {analysisMode === 'results' && ( - - - - - Denoise Results - - - - } - onChange={() => setDenoiseResults(!denoiseResults)} - /> - )} + } + onChange={() => setDenoiseResults(!denoiseResults)} + /> + )} - {source?.kind === SourceKind.Trace && - source.parentSpanIdExpression && ( - - - - - Root Spans Only - - - - } - onChange={event => setRootSpansOnly(event.target.checked)} - /> - )} + {source?.kind === SourceKind.Trace && + source.parentSpanIdExpression && ( + + + + + Root Spans Only + + + + } + onChange={event => setRootSpansOnly(event.target.checked)} + /> + )} - {isFacetsLoading ? ( - - - + {isFacetsLoading ? ( + + + + ) : ( + shownFacets.length === 0 && ( + No filters available + ) + )} + {/* Show facets even when loading to ensure pinned filters are visible while loading */} + {renderFacetList(shownFacets)} + + + + ) + } + onClick={() => setShowMoreFields(!showMoreFields)} + > + {showMoreFields ? 'Less filters' : 'More filters'} + - {showMoreFields && ( -
- - Not seeing a filter? - - - {`Try searching instead (e.g. column:foo)`} - -
- )} -
-
+ {showMoreFields && ( +
+ + Not seeing a filter? + + + {`Try searching instead (e.g. column:foo)`} + +
+ )}
diff --git a/packages/app/src/components/Explore/QueryEditor.tsx b/packages/app/src/components/Explore/QueryEditor.tsx index 6e940a1eb2..796ce7cb94 100644 --- a/packages/app/src/components/Explore/QueryEditor.tsx +++ b/packages/app/src/components/Explore/QueryEditor.tsx @@ -258,9 +258,15 @@ export function QueryEditor({ // Surface field/variable suggestions as soon as the editor is focused, so // people can discover available fields without knowing exact names. + // react-codemirror fires onFocus from an updateListener, so dispatching + // startCompletion synchronously throws "Calls to EditorView.update are not + // allowed while an update is in progress" (also hit when Enter re-focuses + // the editor). Wait until this update finishes. const handleFocus = useCallback(() => { - const view = ref.current?.view; - if (view) startCompletion(view); + queueMicrotask(() => { + const view = ref.current?.view; + if (view) startCompletion(view); + }); }, []); const isSqlMode = queryMode === 'sql'; From 798e8c3aa42fdbd5b0fc88a3a3bd04996e5d6542 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Fri, 21 Aug 2026 22:20:34 +0100 Subject: [PATCH 28/31] feat(app): add multi-series cards to Explore and restyle the tile editor Replace the one-line aggregation bar with series cards so Explore chart views can author multiple series, per-series Where, and aliases. Share those cards with the tile editor and move display settings into a persistent Tile settings rail. Co-authored-by: Cursor --- .changeset/explore-series-cards.md | 5 + packages/app/src/DBExplorePage.tsx | 108 ++-- packages/app/src/components/AggFnSelect.tsx | 6 +- .../components/ChartDisplaySettingsDrawer.tsx | 373 ++++++++------ .../ChartEditor/PromqlChartEditor.tsx | 18 +- .../ChartEditor/RawSqlChartEditor.tsx | 18 +- .../src/components/ChartSeries/SeriesCard.tsx | 89 ++++ .../DBEditTimeChartForm/ChartActionBar.tsx | 50 +- .../ChartEditorControls.tsx | 117 +++-- .../DBEditTimeChartForm/ChartPreviewPanel.tsx | 2 +- .../DBEditTimeChartForm/ChartSeriesEditor.tsx | 487 +++++++++--------- .../DBEditTimeChartForm/EditTimeChartForm.tsx | 451 ++++++++-------- .../__tests__/DBEditTimeChartForm.test.tsx | 37 +- .../Explore/ExploreResultsToolbar.tsx | 18 +- .../components/Explore/ExploreSeriesList.tsx | 194 +++++++ .../components/Search/SearchAggControls.tsx | 420 +++++++-------- .../Search/__tests__/searchAggConfig.test.ts | 83 +++ .../e2e/components/ChartEditorComponent.ts | 51 +- .../app/tests/e2e/features/dashboard.spec.ts | 8 +- 19 files changed, 1499 insertions(+), 1036 deletions(-) create mode 100644 .changeset/explore-series-cards.md create mode 100644 packages/app/src/components/ChartSeries/SeriesCard.tsx create mode 100644 packages/app/src/components/Explore/ExploreSeriesList.tsx create mode 100644 packages/app/src/components/Search/__tests__/searchAggConfig.test.ts diff --git a/.changeset/explore-series-cards.md b/.changeset/explore-series-cards.md new file mode 100644 index 0000000000..6dfb4f8e4a --- /dev/null +++ b/.changeset/explore-series-cards.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': minor +--- + +Explore aggregated views now use multi-series cards (alias, per-series where, add/remove) instead of a single aggregation bar. The tile editor shares those cards and shows tile settings in a persistent right rail. diff --git a/packages/app/src/DBExplorePage.tsx b/packages/app/src/DBExplorePage.tsx index 62936712bc..7a6e1c7bcb 100644 --- a/packages/app/src/DBExplorePage.tsx +++ b/packages/app/src/DBExplorePage.tsx @@ -44,7 +44,6 @@ import { isLogSource, isMetricSource as isMetricSourceGuard, isTraceSource, - MetricsDataType, RawSqlChartConfig, RawSqlSavedChartConfig, SavedChartConfig, @@ -143,15 +142,15 @@ import { DBTreemapChart } from './components/DBTreemapChart'; import { ExploreContextBand } from './components/Explore/ExploreContextBand'; import { ExploreQueryEditor } from './components/Explore/ExploreQueryEditor'; import { ExploreResultsToolbar } from './components/Explore/ExploreResultsToolbar'; +import { ExploreSeriesList } from './components/Explore/ExploreSeriesList'; import { type QueryConfigMode } from './components/Explore/QueryEditor'; import { SeveritySummary } from './components/Explore/SeveritySummary'; import PatternTable from './components/PatternTable'; import { DBSearchHeatmapChart } from './components/Search/DBSearchHeatmapChart'; import DirectTraceSidePanel from './components/Search/DirectTraceSidePanel'; import { - aggFnToSelectFields, type AggSortField, - SearchAggControls, + exploreSeriesHaveMetricNames, useSearchAggConfig, } from './components/Search/SearchAggControls'; import { SearchColumnPicker } from './components/Search/SearchColumnPicker'; @@ -2056,59 +2055,62 @@ function DBExplorePage() { return undefined; } // Metric queries require a chosen metric name — the renderer has no query - // path for an empty metric. Hold off until the user picks one. - if (searchedMetricSource && !aggConfig.metricName) { + // path for an empty metric. Hold off until every series has one. + if ( + searchedMetricSource && + !exploreSeriesHaveMetricNames(aggConfig.series) + ) { return undefined; } - const valueExpression = - aggConfig.aggFn === 'count' ? '' : aggConfig.aggExpr.trim(); const groupBy = view === 'number' ? undefined : aggConfig.groupBy.trim() || defaultAggGroupBy || undefined; // Categorical + summary-table views support the structured Sort menu - // (Value = the metric, Name = the group key). Alias the aggregate as + // (Value = the metric, Name = the group key). Alias a single aggregate as // "Value" so ordering by it is stable regardless of the expression. const isCategoricalLike = view === 'table' || view === 'bar' || view === 'pie' || view === 'treemap'; + + const select = aggConfig.series.map(series => { + const isCount = series.aggFn === 'count'; + const alias = + series.alias || + (isCategoricalLike && aggConfig.series.length === 1 + ? 'Value' + : undefined); + return { + ...series, + aggCondition: series.aggCondition ?? '', + aggConditionLanguage: series.aggConditionLanguage ?? 'lucene', + valueExpression: searchedMetricSource + ? 'Value' + : isCount + ? '' + : (series.valueExpression ?? ''), + ...(alias != null ? { alias } : {}), + }; + }); + let orderBy: string | undefined; if (isCategoricalLike) { const dir = aggConfig.sortDir.toUpperCase(); - orderBy = - aggConfig.sort === 'name' && groupBy - ? `${groupBy} ${dir}` - : `"Value" ${dir}`; + if (aggConfig.sort === 'name' && groupBy) { + orderBy = `${groupBy} ${dir}`; + } else if (select[0]?.alias) { + orderBy = `"${select[0].alias}" ${dir}`; + } } - // Metric sources aggregate the `Value` column of the metric-type table and - // carry `metricTables` + `metricName`/`metricType` so the renderer can pick - // the right table and filter by metric name. - const selectItem = searchedMetricSource - ? { - ...aggFnToSelectFields(aggConfig.aggFn), - aggCondition: '', - valueExpression: 'Value', - metricName: aggConfig.metricName, - metricType: - (aggConfig.metricType as MetricsDataType) || MetricsDataType.Gauge, - ...(isCategoricalLike ? { alias: 'Value' } : {}), - } - : { - ...aggFnToSelectFields(aggConfig.aggFn), - aggCondition: '', - valueExpression, - ...(isCategoricalLike ? { alias: 'Value' } : {}), - }; - return { ...chartConfig, ...(searchedMetricSource ? { metricTables: searchedMetricSource.metricTables } : {}), - select: [selectItem], + select, groupBy, orderBy, granularity: view === 'timeseries' ? 'auto' : undefined, @@ -2916,13 +2918,14 @@ function DBExplorePage() { } shapeControls={ !isSqlUiMode && isAggregatedSearchView(view) ? ( - ) : undefined } @@ -3268,22 +3271,23 @@ function DBExplorePage() { ) ) : isAggregatedSearchView(view) ? ( - {isMetricSource && !aggConfig.metricName && ( - - - Select a metric to visualize - - - Choose a metric name from the aggregation bar above. - - - )} + {isMetricSource && + !exploreSeriesHaveMetricNames(aggConfig.series) && ( + + + Select a metric to visualize + + + Choose a metric name from the series cards above. + + + )} {view === 'timeseries' && aggViewChartConfig && ( DBExplorePage, { ssr: false, }); -// @ts-ignore +// @ts-expect-error next/dynamic component type does not include the getLayout static DBExplorePageDynamic.getLayout = withAppNav; export default DBExplorePageDynamic; diff --git a/packages/app/src/components/AggFnSelect.tsx b/packages/app/src/components/AggFnSelect.tsx index a15cf13b6b..3d006cb5ef 100644 --- a/packages/app/src/components/AggFnSelect.tsx +++ b/packages/app/src/components/AggFnSelect.tsx @@ -89,6 +89,7 @@ export function AggFnSelectControlled({ defaultValue, hideCustom, metricType, + onValueChange, ...props }: { defaultValue: string; @@ -96,6 +97,8 @@ export function AggFnSelectControlled({ quantileLevelName: string; hideCustom?: boolean; metricType?: MetricsDataType; + /** Fires after the form fields are updated (Explore commits URL state). */ + onValueChange?: () => void; } & Omit, 'name'>) { const { field: { onChange: onAggFnChange, value: aggFnValue }, @@ -119,8 +122,9 @@ export function AggFnSelectControlled({ } else { onAggFnChange(value.aggFn); } + onValueChange?.(); }, - [onAggFnChange, onQuantileLevelChange], + [onAggFnChange, onQuantileLevelChange, onValueChange], ); const value = useMemo(() => { diff --git a/packages/app/src/components/ChartDisplaySettingsDrawer.tsx b/packages/app/src/components/ChartDisplaySettingsDrawer.tsx index ee8e0eca33..f610f11de0 100644 --- a/packages/app/src/components/ChartDisplaySettingsDrawer.tsx +++ b/packages/app/src/components/ChartDisplaySettingsDrawer.tsx @@ -76,6 +76,8 @@ interface ChartDisplaySettingsDrawerProps { onChange: (settings: ChartConfigDisplaySettings, isDirty: boolean) => void; onClose: () => void; isPerSeriesNumberFormatAllowed?: boolean; + /** Persistent tile-editor rail vs the original slide-over drawer. */ + variant?: 'drawer' | 'panel'; } function applyDefaultSettings( @@ -115,6 +117,7 @@ export default function ChartDisplaySettingsDrawer({ onClose, previousDateRange, isPerSeriesNumberFormatAllowed = false, + variant = 'drawer', }: ChartDisplaySettingsDrawerProps) { const appliedDefaults = useMemo( () => applyDefaultSettings(settings, defaultNumberFormat), @@ -166,8 +169,17 @@ export default function ChartDisplaySettingsDrawer({ hasDirtyFields, ); })(); - onClose(); - }, [onChange, handleSubmit, onClose, settings.numberFormat, dirtyFields]); + if (variant === 'drawer') { + onClose(); + } + }, [ + onChange, + handleSubmit, + onClose, + settings.numberFormat, + dirtyFields, + variant, + ]); const resetToDefaults = useCallback(() => { reset( @@ -216,87 +228,50 @@ export default function ChartDisplaySettingsDrawer({ const showBackgroundChart = displayType === DisplayType.Number; const isBackgroundChartDisabled = configType === 'sql'; - return ( - - - {isTimeChart && ( - <> - + {isTimeChart && ( + <> + + + { + setValue('fillNulls', e.currentTarget.checked ? 0 : false); + }} /> - - { - setValue('fillNulls', e.currentTarget.checked ? 0 : false); - }} - /> - - - ( - - {' - '} - ) - - ) - } - /> - - {showSeriesLimit && ( - - ( - - onChange(v === '' || v == null ? null : Number(v)) - } - /> - )} - /> - - )} - - - )} - - {showCategoricalLimit && ( - <> + + + ( + + {' - '} + ) + + ) + } + /> + + {showSeriesLimit && ( @@ -317,106 +296,162 @@ export default function ChartDisplaySettingsDrawer({ )} /> - - - )} + )} + + + )} - {showTableOptions && ( - <> - {showGroupByColumnsOnLeft && ( - - )} + {showCategoricalLimit && ( + <> + + ( + + onChange(v === '' || v == null ? null : Number(v)) + } + /> + )} + /> + + + + )} + + {showTableOptions && ( + <> + {showGroupByColumnsOnLeft && ( - - - )} + )} + + + + )} - {showTileColor && ( - <> - - - Color - - ( - - )} - /> - - - ( - - )} - /> - - - - )} - - {showBackgroundChart && ( - <> + {showTileColor && ( + <> + + + Color + ( - )} /> - - - )} + + + ( + + )} + /> + + + + )} + + {showBackgroundChart && ( + <> + ( + + )} + /> + + + )} + + + + Format may be overridden on individual series. + + + ) : undefined + } + /> + + + + + + + ); - - - Format may be overridden on individual series. - - - ) : undefined - } - /> - - - - - - + if (variant === 'panel') { + return ( + + + Tile settings + + {body} + + ); + } + + return ( + + {body} ); } diff --git a/packages/app/src/components/ChartEditor/PromqlChartEditor.tsx b/packages/app/src/components/ChartEditor/PromqlChartEditor.tsx index 1b6a7b346d..72cb6c233e 100644 --- a/packages/app/src/components/ChartEditor/PromqlChartEditor.tsx +++ b/packages/app/src/components/ChartEditor/PromqlChartEditor.tsx @@ -13,10 +13,12 @@ export default function PromqlChartEditor({ control, onSubmit, onOpenDisplaySettings, + hideDisplaySettings = false, }: { control: Control; onSubmit: (suppressErrorNotification?: boolean) => void; onOpenDisplaySettings: () => void; + hideDisplaySettings?: boolean; }) { const { field: expressionField } = useController({ control, @@ -58,13 +60,15 @@ export default function PromqlChartEditor({ /> - + {!hideDisplaySettings && ( + + )} ); diff --git a/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx b/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx index 903cef223b..c1ce946360 100644 --- a/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx +++ b/packages/app/src/components/ChartEditor/RawSqlChartEditor.tsx @@ -121,6 +121,7 @@ export default function RawSqlChartEditor({ additionalWarnings, dashboardId, variables, + hideDisplaySettings = false, }: { control: Control; setValue: UseFormSetValue; @@ -131,6 +132,7 @@ export default function RawSqlChartEditor({ additionalWarnings?: string[]; dashboardId?: string; variables?: ChartVariable[]; + hideDisplaySettings?: boolean; }) { const { size, startResize } = useResizable(20, 'bottom'); @@ -313,13 +315,15 @@ export default function RawSqlChartEditor({ onSubmit={onSubmit} /> )} - + {!hideDisplaySettings && ( + + )}
diff --git a/packages/app/src/components/ChartSeries/SeriesCard.tsx b/packages/app/src/components/ChartSeries/SeriesCard.tsx new file mode 100644 index 0000000000..1fd0305e54 --- /dev/null +++ b/packages/app/src/components/ChartSeries/SeriesCard.tsx @@ -0,0 +1,89 @@ +import { ReactNode } from 'react'; +import { ActionIcon, Box, ColorSwatch, Group, Menu, Text } from '@mantine/core'; +import { IconDotsVertical } from '@tabler/icons-react'; + +/** + * Presentational chrome for one chart series: color, name, alias, overflow + * menu, and a fields slot. Shared by Explore and the tile editor. + */ +export function SeriesCard({ + index, + color, + onColorClick, + titleExtra, + aliasSlot, + menu, + children, +}: { + index: number; + color: string; + onColorClick?: () => void; + titleExtra?: ReactNode; + aliasSlot: ReactNode; + menu: ReactNode; + children: ReactNode; +}) { + return ( + + + + + + Series {index + 1} + + {titleExtra} + + + {aliasSlot} + {menu} + + + {children} + + ); +} + +export function SeriesCardMenu({ children }: { children: ReactNode }) { + return ( + + + + + + + {children} + + ); +} + +export function SeriesAliasField({ children }: { children: ReactNode }) { + return ( + + + Alias + + {children} + + ); +} diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartActionBar.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartActionBar.tsx index 21ca394281..1ba43c6979 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartActionBar.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartActionBar.tsx @@ -32,6 +32,7 @@ type ChartActionBarProps = { setDisplayedTimeInputValue?: (value: string) => void; onTimeRangeSearch?: (value: string) => void; setSaveToDashboardModalOpen: (open: boolean) => void; + hidePrimaryActions?: boolean; }; export function ChartActionBar({ @@ -52,31 +53,34 @@ export function ChartActionBar({ setDisplayedTimeInputValue, onTimeRangeSearch, setSaveToDashboardModalOpen, + hidePrimaryActions = false, }: ChartActionBarProps) { return ( - - - {onSave != null && ( - - )} - {onClose != null && ( - - )} - + + {!hidePrimaryActions && ( + + {onSave != null && ( + + )} + {onClose != null && ( + + )} + + )} {(activeTab === 'table' || activeTab === 'pie' || diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx index 4f26493cf0..5d8a51c8e0 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx @@ -19,7 +19,16 @@ import { SourceKind, TSource, } from '@hyperdx/common-utils/dist/types'; -import { Box, Button, Divider, Flex, Group, Switch, Text } from '@mantine/core'; +import { + Box, + Button, + Divider, + Flex, + Group, + Stack, + Switch, + Text, +} from '@mantine/core'; import { IconBell, IconCirclePlus, @@ -81,6 +90,7 @@ type ChartEditorControlsProps = { onSubmit: (suppressErrorNotification?: boolean) => void; openDisplaySettings: () => void; openHeatmapSettings: () => void; + hideDisplaySettingsButton?: boolean; }; export function ChartEditorControls({ @@ -112,6 +122,7 @@ export function ChartEditorControls({ onSubmit, openDisplaySettings, openHeatmapSettings, + hideDisplaySettingsButton = false, }: ChartEditorControlsProps) { // Formulas (HDX-5080): derived series computed from the chart's series via // letter-ref arithmetic expressions. Metric and event (log/trace) sources, @@ -268,52 +279,54 @@ export function ChartEditorControls({ ) : displayType !== DisplayType.Search && Array.isArray(select) ? ( <> - {fields.map((field, index) => ( - - ))} - {sourceSupportsFormulas && - formulaFields.map((field, index) => ( - + {fields.map((field, index) => ( + ))} + {sourceSupportsFormulas && + formulaFields.map((field, index) => ( + + ))} + {fields.length > 1 && displayType !== DisplayType.Number && ( <> @@ -332,7 +345,7 @@ export function ChartEditorControls({ whiteSpace: 'nowrap', }} > - Group By + Group by
@@ -392,7 +405,7 @@ export function ChartEditorControls({ }} > - Add Series + Add series )} {canAddFormula && ( @@ -505,14 +518,16 @@ export function ChartEditorControls({ onSubmit={onSubmit} /> )} - + {!hideDisplaySettingsButton && ( + + )} diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx index 5a31e51091..e769f10561 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartPreviewPanel.tsx @@ -395,7 +395,7 @@ export function ChartPreviewPanel({ }> - Sample Matched Events + Sample matched events diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartSeriesEditor.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartSeriesEditor.tsx index 137806906b..ec558cc756 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartSeriesEditor.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartSeriesEditor.tsx @@ -14,16 +14,7 @@ import { SourceKind, TSource, } from '@hyperdx/common-utils/dist/types'; -import { - ActionIcon, - Badge, - Button, - Divider, - Flex, - Group, - Text, - Tooltip, -} from '@mantine/core'; +import { Badge, Box, Flex, Group, Menu, Text, Tooltip } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { IconArrowDown, @@ -43,6 +34,11 @@ import { SavedChartConfigWithSelectArray, } from '@/components/ChartEditor/types'; import { isFormulaSourceKind } from '@/components/ChartEditor/utils'; +import { + SeriesAliasField, + SeriesCard, + SeriesCardMenu, +} from '@/components/ChartSeries/SeriesCard'; import { CheckBoxControlled, TextInputControlled, @@ -59,7 +55,7 @@ import { parseAttributeKeysFromSuggestions, useFetchMetricResourceAttrs, } from '@/hooks/useFetchMetricResourceAttrs'; -import { getColorFromCSSToken, getMetricTableName } from '@/utils'; +import { COLORS, getColorFromCSSToken, getMetricTableName } from '@/utils'; type SeriesItem = NonNullable< SavedChartConfigWithSelectArray['select'] @@ -87,6 +83,9 @@ type ChartSeriesEditorProps = { tableSource?: TSource; errors?: FieldErrors; clearErrors: UseFormClearErrors; + /** Commit URL/query immediately on agg and metric picks (Explore). */ + eagerSubmit?: boolean; + groupByPlaceholder?: string; }; export function ChartSeriesEditor({ @@ -111,6 +110,8 @@ export function ChartSeriesEditor({ tableSource, errors, clearErrors, + eagerSubmit = false, + groupByPlaceholder = 'SQL columns', }: ChartSeriesEditorProps) { const aggFn = useWatch({ control, name: `${namePrefix}aggFn` }); const aggConditionLanguage = useWatch({ @@ -252,280 +253,254 @@ export function ChartSeriesEditor({ { open: openSeriesColor, close: closeSeriesColor }, ] = useDisclosure(false); + const swatchColor = + seriesColor && isChartPaletteToken(seriesColor) + ? getColorFromCSSToken(seriesColor) + : COLORS[index % COLORS.length]; + const canRemove = (index ?? -1) > 0 || length > 1; + return ( <> - - {/* Formula series reference (HDX-5080): formulas address series - positionally by letter (`A` = series 1, ...), so surface the - letter on each row of formula-capable sources (metric and - log/trace events). */} - {isFormulaSourceKind(tableSource?.kind) && ( - - - {indexToSeriesRef(index) ?? index + 1} - - - )} - Alias - -
+ + + {indexToSeriesRef(index) ?? index + 1} + + + ) : undefined + } + aliasSlot={ + +
onSubmit()} + placeholder="Alias" + onBlur={() => onSubmit()} size="xs" data-testid="series-alias-input" />
+
+ } + menu={ + + {showDuplicate && ( + } + onClick={() => onDuplicateSeries(index)} + data-testid="series-duplicate-button" + > + Duplicate + + )} {(index ?? -1) > 0 && ( - + Move up + )} {(index ?? -1) < length - 1 && ( - + Move down + )} - {showDuplicate && ( - + Color + )} - {((index ?? -1) > 0 || length > 1) && ( - - )} - - - {FORMAT_ICONS[seriesNumberFormat?.output ?? 'number']} - - - {showColor && ( - - - - - + Remove series + )} - + } - labelPosition="right" - mb={8} - mt="sm" - /> - -
- -
- {tableSource?.kind === SourceKind.Metric && metricType && ( -
- { - setValue(`${namePrefix}metricName`, value); - setValue(`${namePrefix}valueExpression`, 'Value'); - }} - setMetricType={value => - setValue(`${namePrefix}metricType`, value) + > + + + clearErrors(`${namePrefix}metricName`)} + onValueChange={eagerSubmit ? onSubmit : undefined} /> - {metricType === 'gauge' && ( - - + {tableSource?.kind === SourceKind.Metric && metricType && ( + + { + setValue(`${namePrefix}metricName`, value); + setValue(`${namePrefix}valueExpression`, 'Value'); + if (eagerSubmit) onSubmit(); + }} + setMetricType={value => { + setValue(`${namePrefix}metricType`, value); + if (eagerSubmit) onSubmit(); + }} + metricSource={tableSource} + data-testid="metric-name-selector" + error={errors?.metricName?.message} + onFocus={() => clearErrors(`${namePrefix}metricName`)} + /> + {metricType === 'gauge' && ( + + + + )} + + )} + {tableSource?.kind !== SourceKind.Metric && aggFn !== 'count' && ( + + + + )} + {showWhere && ( + + + Where + + + - - )} -
- )} - {tableSource?.kind !== SourceKind.Metric && aggFn !== 'count' && ( -
- + + )} + {showGroupBy && ( + + + Group by + + + + + + )} + {showHaving && ( + + + Having + + + + + + )} + + {tableSource?.kind === SourceKind.Metric && + metricName && + metricType && ( + -
- )} - {(showWhere || showGroupBy || showHaving) && ( -
- {showWhere && ( - <> - Where -
- -
- - )} - {showGroupBy && ( - <> - - Group By - -
- -
- {showHaving && ( - <> - - Having - -
- -
- - )} - - )} -
- )} -
- {tableSource?.kind === SourceKind.Metric && metricName && metricType && ( - - )} + )} +
- ( - - - } - > - Time Series - - } - > - Table - - } - > - Number - - } - > - Bar - - } - > - Pie - - } - > - Treemap - - } - > - Search - - } - > - Heatmap - - } - > - Patterns - - } - > - Markdown - - - - )} - /> - - Chart Name - - {isRawSqlDisplayType(displayType) && ( + {onSave != null && ( + + )} + {onClose != null && ( + + )} + + + ( - + radius={'xs'} + mb="md" + data-testid="chart-type-input" + > + + } + > + Time series + + } + > + Table + + } + > + Number + + } + > + Bar + + } + > + Pie + + } + > + Treemap + + } + > + Search + + } + > + Heatmap + + } + > + Patterns + + } + > + Markdown + + + )} /> - )} - - - {activeTab === 'markdown' ? ( -
-