diff --git a/.changeset/chart-legend-filtering.md b/.changeset/chart-legend-filtering.md new file mode 100644 index 0000000000..0dae24ba69 --- /dev/null +++ b/.changeset/chart-legend-filtering.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/app': minor +--- + +Add chart legend series filtering with click and shift-click selection + diff --git a/.changeset/dashboard-tile-tags.md b/.changeset/dashboard-tile-tags.md new file mode 100644 index 0000000000..90403bf774 --- /dev/null +++ b/.changeset/dashboard-tile-tags.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/app': minor +'@hyperdx/common-utils': minor +--- + +Add dashboard tile tags for organization and filtering + diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 90f1c0818e..c3d48fe9ff 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -43,6 +43,7 @@ import { Text, Title, Tooltip, + Badge, } from '@mantine/core'; import { useHover } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; @@ -58,6 +59,7 @@ import { IconTags, IconTrash, IconUpload, + IconX, } from '@tabler/icons-react'; import { ContactSupportText } from '@/components/ContactSupportText'; @@ -75,12 +77,19 @@ import { } from '@/dashboard'; import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; +import MVOptimizationIndicator from './components/MaterializedViews/MVOptimizationIndicator'; import OnboardingModal from './components/OnboardingModal'; import { Tags } from './components/Tags'; import useDashboardFilters from './hooks/useDashboardFilters'; import { useDashboardRefresh } from './hooks/useDashboardRefresh'; import { parseAsStringWithNewLines } from './utils/queryParsers'; -import { buildTableRowSearchUrl, DEFAULT_CHART_CONFIG } from './ChartUtils'; +import { + buildTableRowSearchUrl, + convertToNumberChartConfig, + convertToTableChartConfig, + convertToTimeChartConfig, + DEFAULT_CHART_CONFIG, +} from './ChartUtils'; import { IS_LOCAL_MODE } from './config'; import { useDashboard } from './dashboard'; import DashboardFilters from './DashboardFilters'; @@ -121,6 +130,7 @@ const Tile = forwardRef( granularity, onTimeRangeSelect, filters, + onTagClick, // Properties forwarded by grid layout className, @@ -130,6 +140,7 @@ const Tile = forwardRef( onTouchEnd, children, isHighlighted, + isHidden, }: { chart: Tile; dateRange: [Date, Date]; @@ -142,6 +153,7 @@ const Tile = forwardRef( granularity: SQLInterval | undefined; onTimeRangeSelect: (start: Date, end: Date) => void; filters?: Filter[]; + onTagClick?: (tag: string) => void; // Properties forwarded by grid layout className?: string; @@ -151,6 +163,7 @@ const Tile = forwardRef( onTouchEnd?: (e: React.TouchEvent) => void; children?: React.ReactNode; // Resizer tooltip isHighlighted?: boolean; + isHidden?: boolean; }, ref: ForwardedRef, ) => { @@ -166,6 +179,25 @@ const Tile = forwardRef( ChartConfigWithDateRange | undefined >(undefined); + // Transform the queried config to match what will be queried by the + // child components, so that the MV Optimization indicator is accurate. + const configForMVOptimizationIndicator = useMemo(() => { + if (!queriedConfig) return undefined; + + if ( + queriedConfig.displayType === DisplayType.Line || + queriedConfig.displayType === DisplayType.StackedBar + ) { + return convertToTimeChartConfig(queriedConfig); + } else if (queriedConfig.displayType === DisplayType.Number) { + return convertToNumberChartConfig(queriedConfig); + } else if (queriedConfig.displayType === DisplayType.Table) { + return convertToTableChartConfig(queriedConfig); + } + + return queriedConfig; + }, [queriedConfig]); + const { data: source } = useSource({ id: chart.config.source, }); @@ -224,96 +256,10 @@ const Tile = forwardRef( return tooltip; }, [alert]); - const hoverToolbar = useMemo(() => { - return hovered ? ( - e.stopPropagation()} - key="hover-toolbar" - > - {(chart.config.displayType === DisplayType.Line || - chart.config.displayType === DisplayType.StackedBar) && ( - +} - mr={4} - > - - - - - )} - - - - - - ) : ( - - ); - }, [ - alert, - alertIndicatorColor, - alertTooltip, - chart.config.displayType, - chart.id, - hovered, - onDeleteClick, - onDuplicateClick, - onEditClick, - ]); - - const title = useMemo( - () => ( - - {chart.config.name} - - ), - [chart.config.name], - ); - return (
- - - +
+ + + {chart.config.name} + + {chart.tags && chart.tags.length > 0 && ( + e.stopPropagation()}> + {chart.tags.map(tag => ( + { + e.stopPropagation(); + onTagClick?.(tag); + }} + > + {tag} + + ))} + + )} + + + {hovered ? ( + e.stopPropagation()}> + {(chart.config.displayType === DisplayType.Line || + chart.config.displayType === DisplayType.StackedBar) && ( + +} + mr={4} + > + + + + + )} + + + + + + ) : ( + + )} + {source?.materializedViews?.length && queriedConfig && ( + e.stopPropagation()}> + + + )} + +
e.stopPropagation()} > buildTableRowSearchUrl({ @@ -381,18 +417,10 @@ const Tile = forwardRef( )} {queriedConfig?.displayType === DisplayType.Number && ( - + )} {queriedConfig?.displayType === DisplayType.Markdown && ( - + )} {queriedConfig?.displayType === DisplayType.Search && ( { const contextZIndex = useZIndex(); const modalZIndex = contextZIndex + 10; + const [tileTags, setTileTags] = useState(chart?.tags || []); + + // Update tileTags when chart changes + useEffect(() => { + if (chart) { + setTileTags(chart.tags || []); + } + }, [chart]); + return ( {chart != null && ( + + Tile Tags + + + + { if (!dashboard) return []; const tc: TableConnection[] = []; @@ -734,7 +789,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const tiles = useMemo( () => - (dashboard?.tiles ?? []).map(chart => { + (dashboard?.tiles ?? []) + .map(chart => { + const isHidden = selectedTag && !chart.tags?.includes(selectedTag); + console.log('Tile:', chart.config.name, 'Tags:', chart.tags, 'SelectedTag:', selectedTag, 'isHidden:', isHidden); return ( { + setSelectedTag(tag === selectedTag ? null : tag); + }} + isHidden={isHidden} + style={{ display: isHidden ? 'none' : undefined }} onUpdateChart={newChart => { if (!dashboard) { return; @@ -822,6 +885,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { whereLanguage, onTimeRangeSelect, filterQueries, + selectedTag, + setSelectedTag, ], ); @@ -938,18 +1003,39 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { )} - { - if (dashboard != null) { - setDashboard({ - ...dashboard, - name: editedName, - }); - } - }} - /> + + { + if (dashboard != null) { + setDashboard({ + ...dashboard, + name: editedName, + }); + } + }} + /> + {selectedTag && ( + { + e.stopPropagation(); + setSelectedTag(null); + }} + /> + } + > + Filtered by: {selectedTag} + + )} + {!isLocalDashboard && dashboard?.id && ( void; +}) { const [_expanded, setExpanded] = useState(false); const isExpanded = _expanded || expanded; return ( setExpanded(v => !v)} - title="Click to expand" + onClick={(e) => { + if (onToggle) { + onToggle(e.shiftKey); + } else { + setExpanded(v => !v); + } + }} + title={ + isSelected + ? "Click to show all (Shift+click to deselect)" + : "Click to show only this (Shift+click for multi-select)" + } >
@@ -241,8 +268,9 @@ function ExpandableLegendItem({ entry, expanded }: any) { x2="12" y2="2" stroke={entry.color} - opacity={entry.opacity} + opacity={isDisabled ? 0.3 : 1} strokeDasharray={entry.payload?.strokeDasharray} + strokeWidth={isSelected ? 2.5 : 1.5} />
@@ -258,13 +286,31 @@ export const LegendRenderer = memo<{ color: string; }[]; lineDataMap: { [key: string]: LineData }; + allLineData?: LineData[]; + selectedSeries?: Set; + onToggleSeries?: (seriesName: string, isShiftKey?: boolean) => void; }>(props => { - const { payload = [], lineDataMap } = props; + const { payload = [], lineDataMap, allLineData = [], selectedSeries = new Set(), onToggleSeries } = props; + + const hasSelection = selectedSeries.size > 0; + + // Use allLineData to ensure all series are always shown in legend + const allSeriesPayload = useMemo(() => { + if (allLineData.length > 0) { + return allLineData.map(ld => ({ + dataKey: ld.dataKey, + value: ld.displayName || ld.dataKey, + color: ld.color, + payload: { strokeDasharray: ld.isDashed ? '4 3' : '0' } + })); + } + return payload; + }, [allLineData, payload]); const sortedLegendItems = useMemo(() => { // Order items such that current and previous period lines are consecutive const currentPeriodKeyIndex = new Map(); - payload.forEach((line, index) => { + allSeriesPayload.forEach((line, index) => { const currentPeriodKey = lineDataMap[line.dataKey]?.currentPeriodKey || ''; if (!currentPeriodKeyIndex.has(currentPeriodKey)) { @@ -272,7 +318,7 @@ export const LegendRenderer = memo<{ } }); - return payload.sort((a, b) => { + return allSeriesPayload.sort((a, b) => { const keyA = lineDataMap[a.dataKey]?.currentPeriodKey ?? ''; const keyB = lineDataMap[b.dataKey]?.currentPeriodKey ?? ''; @@ -288,13 +334,20 @@ export const LegendRenderer = memo<{ return (
- {shownItems.map((entry, index) => ( - - ))} + {shownItems.map((entry, index) => { + const isSelected = selectedSeries.has(entry.value); + const isDisabled = hasSelection && !isSelected; + return ( + onToggleSeries?.(entry.value, isShiftKey)} + /> + ); + })} {restItems.length ? ( @@ -304,13 +357,20 @@ export const LegendRenderer = memo<{
- {restItems.map((entry, index) => ( - - ))} + {restItems.map((entry, index) => { + const isSelected = selectedSeries.has(entry.value); + const isDisabled = hasSelection && !isSelected; + return ( + onToggleSeries?.(entry.value, isShiftKey)} + /> + ); + })}
@@ -336,6 +396,8 @@ export const MemoChart = memo(function MemoChart({ onTimeRangeSelect, showLegend = true, previousPeriodOffsetSeconds, + selectedSeriesNames, + onToggleSeries, }: { graphResults: any[]; setIsClickActive: (v: any) => void; @@ -351,6 +413,8 @@ export const MemoChart = memo(function MemoChart({ onTimeRangeSelect?: (start: Date, end: Date) => void; showLegend?: boolean; previousPeriodOffsetSeconds?: number; + selectedSeriesNames?: Set; + onToggleSeries?: (seriesName: string, isShiftKey?: boolean) => void; }) { const _id = useId(); const id = _id.replace(/:/g, ''); @@ -361,13 +425,23 @@ export const MemoChart = memo(function MemoChart({ displayType === DisplayType.StackedBar ? BarChart : AreaChart; // LineChart; const lines = useMemo(() => { + const hasSelection = selectedSeriesNames && selectedSeriesNames.size > 0; + const limitedGroupKeys = lineData .map(ld => ld.dataKey) - .slice(0, HARD_LINES_LIMIT); + .slice(0, HARD_LINES_LIMIT) + .filter((key, i) => { + const seriesName = lineData[i]?.displayName ?? key; + // If there's a selection, only show selected series + // If no selection, show all series + return !hasSelection || selectedSeriesNames.has(seriesName); + }); return limitedGroupKeys.map((key, i) => { - const color = lineData[i]?.color; - const strokeDasharray = lineData[i]?.isDashed ? '4 3' : '0'; + const lineDataIndex = lineData.findIndex(ld => ld.dataKey === key); + const color = lineData[lineDataIndex]?.color; + const strokeDasharray = lineData[lineDataIndex]?.isDashed ? '4 3' : '0'; + const seriesName = lineData[lineDataIndex]?.displayName ?? key; const StackedBarWithOverlap = (props: BarProps) => { const { x, y, width, height, fill } = props; @@ -388,7 +462,7 @@ export const MemoChart = memo(function MemoChart({ key={key} type="monotone" dataKey={key} - name={lineData[i]?.displayName ?? key} + name={seriesName} fill={color} opacity={1} stackId="1" @@ -408,12 +482,50 @@ export const MemoChart = memo(function MemoChart({ fill: `url(#time-chart-lin-grad-${id}-${color.replace('#', '').toLowerCase()})`, strokeDasharray, })} - name={lineData[i]?.displayName ?? key} + name={seriesName} isAnimationActive={false} /> ); }); - }, [lineData, displayType, id, isHovered]); + }, [lineData, displayType, id, isHovered, selectedSeriesNames]); + + const yAxisDomain = useMemo(() => { + const hasSelection = selectedSeriesNames && selectedSeriesNames.size > 0; + + if (!hasSelection) { + // No selection, let Recharts auto-calculate based on all data + return ['auto', 'auto']; + } + + // When series are selected, calculate domain based only on visible series + let minValue = Infinity; + let maxValue = -Infinity; + + graphResults.forEach(dataPoint => { + lineData.forEach(ld => { + const seriesName = ld.displayName || ld.dataKey; + // Only consider selected series + if (selectedSeriesNames.has(seriesName)) { + const value = dataPoint[ld.dataKey]; + if (typeof value === 'number' && !isNaN(value)) { + minValue = Math.min(minValue, value); + maxValue = Math.max(maxValue, value); + } + } + }); + }); + + // If we found valid values, return them with some padding + if (minValue !== Infinity && maxValue !== -Infinity) { + const padding = (maxValue - minValue) * 0.1; // 10% padding + return [ + Math.max(0, minValue - padding), // Don't go below 0 + maxValue + padding + ]; + } + + return ['auto', 'auto']; + }, [graphResults, lineData, selectedSeriesNames]); const sizeRef = useRef<[number, number]>([0, 0]); @@ -606,6 +718,7 @@ export const MemoChart = memo(function MemoChart({ minTickGap={25} tickFormatter={tickFormatter} tick={{ fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }} + domain={yAxisDomain} /> {lines} {isClickActive == null && ( @@ -635,7 +748,14 @@ export const MemoChart = memo(function MemoChart({ } + content={ + + } offset={-100} /> )} diff --git a/packages/app/src/components/DBTimeChart.tsx b/packages/app/src/components/DBTimeChart.tsx index 2a884fe071..20360c75a2 100644 --- a/packages/app/src/components/DBTimeChart.tsx +++ b/packages/app/src/components/DBTimeChart.tsx @@ -243,6 +243,34 @@ function DBTimeChartComponent({ showMVOptimizationIndicator = true, }: DBTimeChartComponentProps) { const [isErrorExpanded, errorExpansion] = useDisclosure(false); + const [selectedSeriesSet, setSelectedSeriesSet] = useState>(new Set()); + + const handleToggleSeries = useCallback((seriesName: string, isShiftKey?: boolean) => { + setSelectedSeriesSet(prev => { + const newSet = new Set(prev); + + if (isShiftKey) { + // Shift-click: add to selection + if (newSet.has(seriesName)) { + newSet.delete(seriesName); + } else { + newSet.add(seriesName); + } + } else { + // Regular click: toggle selection + if (newSet.has(seriesName) && newSet.size === 1) { + // If this is the only selected item, clear selection (show all) + newSet.clear(); + } else { + // Otherwise, select only this one + newSet.clear(); + newSet.add(seriesName); + } + } + + return newSet; + }); + }, []); const originalDateRange = config.dateRange; const { @@ -683,6 +711,8 @@ function DBTimeChartComponent({ showLegend={showLegend} timestampKey={timestampColumn?.name} previousPeriodOffsetSeconds={previousPeriodOffsetSeconds} + selectedSeriesNames={selectedSeriesSet} + onToggleSeries={handleToggleSeries} /> )} diff --git a/packages/app/src/dashboard.ts b/packages/app/src/dashboard.ts index 33070157dd..d35c09a638 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -20,6 +20,7 @@ export type Tile = { w: number; h: number; config: SavedChartConfig; + tags?: string[]; }; export type Dashboard = { diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 5e429b3f4b..37f4568d2d 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -520,6 +520,7 @@ export const TileSchema = z.object({ w: z.number(), h: z.number(), config: SavedChartConfigSchema, + tags: z.array(z.string()).optional(), }); export const TileTemplateSchema = TileSchema.extend({ config: TileSchema.shape.config.omit({ alert: true }),