diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index c82fffa8ea5..6f908a114a4 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1079,4 +1079,8 @@ export interface FeatureToggles { * @default false */ alertmanagerRemoteSecondaryWithRemoteState?: boolean; + /** + * Enable adhoc filter buttons in visualization tooltips + */ + adhocFiltersInTooltips?: boolean; } diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx index db5d1e88c37..b4eccaee66a 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx @@ -11,10 +11,16 @@ import { DataLinkButton } from '../DataLinks/DataLinkButton'; import { Icon } from '../Icon/Icon'; import { Stack } from '../Layout/Stack/Stack'; import { ResponsiveProp } from '../Layout/utils/responsiveness'; +import { AdHocFilterItem } from '../Table/TableNG/types'; + +export interface AdHocFilterModel extends AdHocFilterItem { + onClick: () => void; +} interface VizTooltipFooterProps { dataLinks: Array>; actions?: Array>; + adHocFilters?: AdHocFilterModel[]; annotate?: () => void; } @@ -41,7 +47,7 @@ function makeRenderLinksOrActions( if (oneClickItem != null) { return ( -
+
@@ -53,7 +59,7 @@ function makeRenderLinksOrActions( } return ( -
+
{items.map((item, i) => renderItem(item, i, styles))} @@ -79,7 +85,7 @@ const renderActions = makeRenderLinksOrActions( (item, i) => ); -export const VizTooltipFooter = ({ dataLinks, actions = [], annotate }: VizTooltipFooterProps) => { +export const VizTooltipFooter = ({ dataLinks, actions = [], annotate, adHocFilters = [] }: VizTooltipFooterProps) => { const styles = useStyles2(getStyles); const hasOneClickLink = useMemo(() => dataLinks.some((link) => link.oneClick === true), [dataLinks]); const hasOneClickAction = useMemo(() => actions.some((action) => action.oneClick === true), [actions]); @@ -88,8 +94,19 @@ export const VizTooltipFooter = ({ dataLinks, actions = [], annotate }: VizToolt
{!hasOneClickAction && renderDataLinks(dataLinks, styles)} {!hasOneClickLink && renderActions(actions, styles)} + {!hasOneClickLink && !hasOneClickAction && adHocFilters.length > 0 && ( +
+ {adHocFilters.map((item, index) => ( + + ))} +
+ )} {!hasOneClickLink && !hasOneClickAction && annotate != null && ( -
+
@@ -106,11 +123,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ flex: 1, padding: theme.spacing(0), }), - dataLinks: css({ - borderTop: `1px solid ${theme.colors.border.medium}`, - padding: theme.spacing(1), - }), - addAnnotations: css({ + footerSection: css({ borderTop: `1px solid ${theme.colors.border.medium}`, padding: theme.spacing(1), }), diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx index e19a6230cec..703b6a85ed1 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin2.tsx @@ -7,6 +7,7 @@ import uPlot from 'uplot'; import { GrafanaTheme2, LinkModel } from '@grafana/data'; import { DashboardCursorSync } from '@grafana/schema'; +import { AdHocFilterModel } from '../../../internal'; import { useStyles2 } from '../../../themes/ThemeContext'; import { RangeSelection1D, RangeSelection2D, OnSelectRangeCallback } from '../../PanelChrome'; import { getPortalContainer } from '../../Portal/Portal'; @@ -28,6 +29,7 @@ export const enum TooltipHoverMode { } type GetDataLinksCallback = (seriesIdx: number, dataIdx: number) => LinkModel[]; +type GetAdHocFiltersCallback = (seriesIdx: number, dataIdx: number) => AdHocFilterModel[]; interface TooltipPlugin2Props { config: UPlotConfigBuilder; @@ -43,6 +45,7 @@ interface TooltipPlugin2Props { onSelectRange?: OnSelectRangeCallback; getDataLinks?: GetDataLinksCallback; + getAdHocFilters?: GetAdHocFiltersCallback; render: ( u: uPlot, @@ -53,7 +56,8 @@ interface TooltipPlugin2Props { // selected time range (for annotation triggering) timeRange: TimeRange2 | null, viaSync: boolean, - dataLinks: LinkModel[] + dataLinks: LinkModel[], + adHocFilters: AdHocFilterModel[] ) => React.ReactNode; maxWidth?: number; @@ -107,6 +111,7 @@ const MIN_ZOOM_DIST = 5; const maybeZoomAction = (e?: MouseEvent | null) => e != null && !e.ctrlKey && !e.metaKey; const getDataLinksFallback: GetDataLinksCallback = () => []; +const getAdHocFiltersFallback: GetAdHocFiltersCallback = () => []; const userAgentIsMobile = /Android|iPhone|iPad/i.test(navigator.userAgent); @@ -124,6 +129,7 @@ export const TooltipPlugin2 = ({ syncMode = DashboardCursorSync.Off, syncScope = 'global', // eventsScope getDataLinks = getDataLinksFallback, + getAdHocFilters = getAdHocFiltersFallback, }: TooltipPlugin2Props) => { const domRef = useRef(null); const portalRoot = useRef(null); @@ -143,6 +149,9 @@ export const TooltipPlugin2 = ({ const getLinksRef = useRef(getDataLinks); getLinksRef.current = getDataLinks; + const getAdHocFiltersRef = useRef(getAdHocFilters); + getAdHocFiltersRef.current = getAdHocFilters; + useLayoutEffect(() => { sizeRef.current?.observer.disconnect(); @@ -202,6 +211,7 @@ export const TooltipPlugin2 = ({ let closestSeriesIdx: number | null = null; let viaSync = false; let dataLinks: LinkModel[] = []; + let adHocFilters: AdHocFilterModel[] = []; // for onceClick link rendering during mousemoves we use these pre-generated first links or actions // these will be wrong if the titles have interpolation using the hovered *value* @@ -273,7 +283,8 @@ export const TooltipPlugin2 = ({ dismiss, selectedRange, viaSync, - _isPinned ? dataLinks : closestSeriesIdx != null ? persistentLinks[closestSeriesIdx] : [] + _isPinned ? dataLinks : closestSeriesIdx != null ? persistentLinks[closestSeriesIdx] : [], + _isPinned ? adHocFilters : [] ) : null, dismiss, @@ -293,6 +304,7 @@ export const TooltipPlugin2 = ({ _isHovering = false; _plot!.setCursor({ left: -10, top: -10 }); dataLinks = []; + adHocFilters = []; scheduleRender(prevIsPinned); }; @@ -351,6 +363,7 @@ export const TooltipPlugin2 = ({ // if tooltip visible, not pinned, and within proximity to a series/point else if (_isHovering && !_isPinned && closestSeriesIdx != null) { dataLinks = getLinksRef.current(closestSeriesIdx, seriesIdxs[closestSeriesIdx]!); + adHocFilters = getAdHocFiltersRef.current(closestSeriesIdx, seriesIdxs[closestSeriesIdx]!); const oneClickLink = dataLinks.find((dataLink) => dataLink.oneClick === true); if (oneClickLink != null) { diff --git a/packages/grafana-ui/src/internal/index.ts b/packages/grafana-ui/src/internal/index.ts index 54bf72d16b0..0a549922564 100644 --- a/packages/grafana-ui/src/internal/index.ts +++ b/packages/grafana-ui/src/internal/index.ts @@ -83,7 +83,7 @@ export { ConfirmContent, type ConfirmContentProps } from '../components/ConfirmM export { EmotionPerfTest } from '../components/ThemeDemos/EmotionPerfTest'; export { VizTooltipContent } from '../components/VizTooltip/VizTooltipContent'; -export { VizTooltipFooter } from '../components/VizTooltip/VizTooltipFooter'; +export { VizTooltipFooter, type AdHocFilterModel } from '../components/VizTooltip/VizTooltipFooter'; export { VizTooltipHeader } from '../components/VizTooltip/VizTooltipHeader'; export { VizTooltipWrapper } from '../components/VizTooltip/VizTooltipWrapper'; export { VizTooltipRow } from '../components/VizTooltip/VizTooltipRow'; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b7ee60b6e55..bef148ef73c 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1868,6 +1868,13 @@ var ( HideFromDocs: true, Expression: "false", }, + { + Name: "adhocFiltersInTooltips", + Description: "Enable adhoc filter buttons in visualization tooltips", + Stage: FeatureStageExperimental, + Owner: grafanaDataProSquad, + FrontendOnly: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index d22d7e5b728..720d9a00434 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -241,3 +241,4 @@ pluginAssetProvider,experimental,@grafana/plugins-platform-backend,false,true,fa unifiedStorageSearchDualReaderEnabled,experimental,@grafana/search-and-storage,false,false,false dashboardDsAdHocFiltering,experimental,@grafana/datapro,false,false,true alertmanagerRemoteSecondaryWithRemoteState,experimental,@grafana/alerting-squad,false,false,false +adhocFiltersInTooltips,experimental,@grafana/datapro,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 7def9808abb..6f82a45302d 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -974,4 +974,8 @@ const ( // FlagAlertmanagerRemoteSecondaryWithRemoteState // Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications. FlagAlertmanagerRemoteSecondaryWithRemoteState = "alertmanagerRemoteSecondaryWithRemoteState" + + // FlagAdhocFiltersInTooltips + // Enable adhoc filter buttons in visualization tooltips + FlagAdhocFiltersInTooltips = "adhocFiltersInTooltips" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 6d738596653..ac1e306cd71 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -17,6 +17,19 @@ "expression": "true" } }, + { + "metadata": { + "name": "adhocFiltersInTooltips", + "resourceVersion": "1753712755564", + "creationTimestamp": "2025-07-28T14:25:55Z" + }, + "spec": { + "description": "Enable adhoc filter buttons in visualization tooltips", + "stage": "experimental", + "codeowner": "@grafana/datapro", + "frontend": true + } + }, { "metadata": { "name": "aiGeneratedDashboardChanges", diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx index fdb05575741..3b54eed78d4 100644 --- a/public/app/plugins/panel/barchart/BarChartPanel.tsx +++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx @@ -1,18 +1,19 @@ import { useMemo } from 'react'; import { PanelProps, VizOrientation } from '@grafana/data'; -import { PanelDataErrorView } from '@grafana/runtime'; +import { config, PanelDataErrorView } from '@grafana/runtime'; import { + AdHocFilterItem, TooltipDisplayMode, TooltipPlugin2, UPLOT_AXIS_FONT_SIZE, UPlotChart, VizLayout, measureText, - // usePanelContext, + usePanelContext, useTheme2, } from '@grafana/ui'; -import { TooltipHoverMode } from '@grafana/ui/internal'; +import { AdHocFilterModel, FILTER_FOR_OPERATOR, TooltipHoverMode } from '@grafana/ui/internal'; import { TimeSeriesTooltip } from '../timeseries/TimeSeriesTooltip'; @@ -30,6 +31,7 @@ export const BarChartPanel = (props: PanelProps) => { // const { dataLinkPostProcessor } = usePanelContext(); const theme = useTheme2(); + const { onAddAdHocFilter } = usePanelContext(); const { barWidth, @@ -160,7 +162,40 @@ export const BarChartPanel = (props: PanelProps) => { getDataLinks={(seriesIdx, dataIdx) => vizSeries[0].fields[seriesIdx].getLinks?.({ valueRowIndex: dataIdx }) ?? [] } - render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks) => { + getAdHocFilters={(_seriesIdx, dataIdx) => { + const xField = vizSeries[0].fields[0]; + + // Check if the field supports filtering + // We only show filters on filterable fields (xField.config.filterable). + // Fields will have been marked as filterable by the data source if that data source supports adhoc filtering + // (eg. Prom or Loki) and the field types support adhoc filtering (eg. string or number - depending on the data source). + // Fields may later be marked as not filterable. For example, fields created from Grafana Transforms that + // are derived from a data source, but are not present in the data source. + // We choose `xField` here because it contains the label-value pair, rather than `field` which is the numeric Value. + if ( + config.featureToggles.adhocFiltersInTooltips && + xField.config.filterable && + onAddAdHocFilter != null + ) { + const adHocFilterItem: AdHocFilterItem = { + key: xField.name, + operator: FILTER_FOR_OPERATOR, + value: String(xField.values[dataIdx]), + }; + + const adHocFilters: AdHocFilterModel[] = [ + { + ...adHocFilterItem, + onClick: () => onAddAdHocFilter(adHocFilterItem), + }, + ]; + + return adHocFilters; + } + + return []; + }} + render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync, dataLinks, adHocFilters) => { return ( ) => { maxHeight={options.tooltip.maxHeight} replaceVariables={replaceVariables} dataLinks={dataLinks} + adHocFilters={adHocFilters} hideZeros={options.tooltip.hideZeros} /> ); diff --git a/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx b/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx index 48d7cc1665a..36cf73bb58a 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx @@ -9,6 +9,7 @@ import { VizTooltipWrapper, getContentItems, VizTooltipItem, + AdHocFilterModel, } from '@grafana/ui/internal'; import { getFieldActions } from '../status-history/utils'; @@ -41,6 +42,7 @@ export interface TimeSeriesTooltipProps { replaceVariables?: InterpolateFunction; dataLinks: LinkModel[]; hideZeros?: boolean; + adHocFilters?: AdHocFilterModel[]; } export const TimeSeriesTooltip = ({ @@ -56,6 +58,7 @@ export const TimeSeriesTooltip = ({ replaceVariables = (str) => str, dataLinks, hideZeros, + adHocFilters, }: TimeSeriesTooltipProps) => { const xField = series.fields[0]; const xVal = formattedValueToString(xField.display!(xField.values[dataIdxs[0]!])); @@ -90,7 +93,9 @@ export const TimeSeriesTooltip = ({ const dataIdx = dataIdxs[seriesIdx]!; const actions = getFieldActions(series, field, replaceVariables, dataIdx); - footer = ; + footer = ( + + ); } } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index a2596532803..c3763c9a59a 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -8729,6 +8729,7 @@ "footer-add-annotation": "Add annotation", "footer-click-to-action": "Click to {{actionTitle}}", "footer-click-to-navigate": "Click to open {{linkTitle}}", + "footer-filter-for-value": "Filter for '{{value}}'", "timestamp": "Timestamp" }, "week-start-picker": {