From 91a0b312edc0d8b5d746aa252d2ea6d2480e3d08 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Thu, 12 Mar 2026 15:57:58 +0100 Subject: [PATCH] Add faceted labels filter for time series legend (#119918) * Add faceted labels extraction and filtering utilities - Add extractFacetedLabels to collect unique label values per key - Add resolveFacetedFilterNames for OR-within/AND-across filtering - Add FIELD_NAME_FACET_KEY constant for synthetic name facet - Extend SeriesVisibilityChangeMode with SetExactly - Widen onToggleSeriesVisibility signature to accept string[] | null Made-with: Cursor * Implement faceted labels filter UI and legend integration - Add FacetedLabelsFilter component with "By name" and "By labels" sections, select/deselect all, expand/collapse, and dimmed state indicator - Integrate filter into PlotLegend with popover and docked sidebar modes - Add filterAction prop to VizLegend, VizLegendList, and VizLegendTable - Add dismissOnScroll prop to Toggletip for scroll-aware popover dismissal - Enable faceted filter in the TimeSeries panel - Widen onToggleSeriesVisibility signature in PanelStateWrapper and ExploreGraph - Update @grafana/scenes to canary with SetExactly support - Add FacetedLabelsFilter component tests - Add dev dashboard for faceted labels scenarios Made-with: Cursor * Add enableFacetedFilter as timeseries legend option - Define TimeSeriesLegendOptions extending VizLegendOptions with enableFacetedFilter field (defaults to true) - Add toggle in timeseries panel editor under Legend category - Remove hardcoded enableFacetedFilter from TimeSeries component, let it flow via legend options spread - Explicitly disable faceted filter in Explore graph - Update dev dashboard with enableFacetedFilter in legend options - Regenerate locale files Made-with: Cursor * Fix docked filter buttons hidden behind dimmed overlay - Add zIndex to filterDockedActions so Clear all and Unpin buttons render above the dimmed FacetedLabelsFilter Made-with: Cursor * Guard faceted labels filter behind feature toggle - Add vizLegendFacetedFilter experimental toggle in registry.go - Gate panel editor option in module.tsx behind the toggle - Gate rendering in TimeSeries.tsx behind the toggle - Use TimeSeriesLegendOptions type for legend prop Made-with: Cursor * Consolidate FacetedLabelsFilter tests Made-with: Cursor * Restore limit support in bottom-placement legend - Revert to InlineList for bottom placement to preserve the series limit feature (useLimit + "show all" button) - filterAction is prepended before InlineList Made-with: Cursor * Add test for toggleAllForKey to fix coverage - Cover Select all / Deselect all button in FacetedLabelsFilter - Fixes Functions coverage regression for @grafana/dataviz-squad Made-with: Cursor * Update dev dashboard file count in search test - Account for new timeseries-faceted-labels.json dashboard Made-with: Cursor * Update search test snapshot for new dev dashboard - Add timeseries-faceted-labels to t00-all.json snapshot - Update totalHits from 16 to 17 Made-with: Cursor * Update search test snapshots for changed BM25 scores - Adding a dashboard changes maxDocs which shifts IDF values - Updated scores in all affected snapshot files Made-with: Cursor * Add E2E tests for faceted labels filter - Test filter toggle, popover sections, name selection - Test select all, clear all, pin to sidebar - Test dimmed state when legend click conflicts with filter - Add data-testid attributes to filter toggle and container Made-with: Cursor * Use gf-pin icon and move filter to left of name column - Replace link icon with gf-pin for Pin to sidebar button - Move filter toggle to left of name column header in table legend Made-with: Cursor * Bump @grafana/scenes to 7.1.5 - Replace canary version with stable 7.1.5 release Made-with: Cursor * Exclude node_modules from i18n extraction - Nested node_modules under packages/ can contain .d.ts files that fail to parse, causing extraction to exit with error Made-with: Cursor * Add unit tests for PlotLegend faceted filter - Test filter toggle visibility when enabled/disabled - Test popover interaction and onToggleSeriesVisibility callback - Test docked mode with clear all and unpin Made-with: Cursor --- .../migration/testdata/golden_checksums.json | 1 + .../timeseries-faceted-labels.json | 126 ++++++++ devenv/jsonnet/dev-dashboards.libsonnet | 1 + .../timeseries-faceted-labels-filter.spec.ts | 107 +++++++ i18next.config.ts | 1 + package.json | 4 +- packages/grafana-data/src/index.ts | 11 +- .../src/types/featureToggles.gen.ts | 5 + .../grafana-data/src/utils/labels.test.ts | 132 ++++++++- packages/grafana-data/src/utils/labels.ts | 82 ++++++ .../timeseries/panelcfg/x/types.gen.ts | 10 +- .../components/PanelChrome/PanelContext.ts | 2 +- .../src/components/PanelChrome/types.ts | 1 + .../src/components/Toggletip/Toggletip.tsx | 5 +- .../VizLegend/FacetedLabelsFilter.test.tsx | 92 ++++++ .../VizLegend/FacetedLabelsFilter.tsx | 199 +++++++++++++ .../src/components/VizLegend/VizLegend.tsx | 5 +- .../components/VizLegend/VizLegendList.tsx | 6 +- .../components/VizLegend/VizLegendTable.tsx | 15 +- .../src/components/VizLegend/types.ts | 3 +- .../src/components/uPlot/PlotLegend.test.tsx | 91 ++++++ .../src/components/uPlot/PlotLegend.tsx | 276 +++++++++++++++--- pkg/services/featuremgmt/registry.go | 8 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.json | 14 + pkg/tests/apis/dashboard/search_test.go | 2 +- .../dashboard/testdata/searchV0/t00-all.json | 11 +- .../searchV0/t01-query-single-word.json | 6 +- .../searchV0/t02-query-multiple-words.json | 4 +- .../searchV0/t03-with-text-panel.json | 2 +- .../searchV0/t04-title-ngram-prefix.json | 4 +- .../searchV0/t05-title-ngram-middle-word.json | 4 +- .../searchV0/t06-panel-title-orange.json | 24 +- .../core/components/TimeSeries/TimeSeries.tsx | 13 +- .../dashboard/dashgrid/PanelStateWrapper.tsx | 5 +- .../features/explore/Graph/ExploreGraph.tsx | 6 +- .../app/plugins/panel/timeseries/module.tsx | 13 + .../app/plugins/panel/timeseries/panelcfg.cue | 6 +- .../plugins/panel/timeseries/panelcfg.gen.ts | 10 +- public/locales/en-US/grafana.json | 15 +- yarn.lock | 45 ++- 41 files changed, 1271 insertions(+), 97 deletions(-) create mode 100644 devenv/dev-dashboards/panel-timeseries/timeseries-faceted-labels.json create mode 100644 e2e-playwright/panels-suite/timeseries-faceted-labels-filter.spec.ts create mode 100644 packages/grafana-ui/src/components/VizLegend/FacetedLabelsFilter.test.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend/FacetedLabelsFilter.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/PlotLegend.test.tsx diff --git a/apps/dashboard/pkg/migration/testdata/golden_checksums.json b/apps/dashboard/pkg/migration/testdata/golden_checksums.json index c34ed809e6f8..946a43266442 100644 --- a/apps/dashboard/pkg/migration/testdata/golden_checksums.json +++ b/apps/dashboard/pkg/migration/testdata/golden_checksums.json @@ -110,6 +110,7 @@ "dev-dashboards-output/panel-timeline/timeline-thresholds-mappings.v42.json": "89c9d54097d157e404d6e6527813883fb754b0507961111bd4a00e06f6f0b21f", "dev-dashboards-output/panel-timeseries/timeseries-bars-high-density.v42.json": "2bf342c03944869f07ca11454b9fe3634069bd5f581b00eaedf6d2ee16182ef9", "dev-dashboards-output/panel-timeseries/timeseries-by-value-color-schemes.v42.json": "5f7f401cb6d1333fd33afe7025ec5f4d8e34b6e4af7fe0ea04b144b43ff472cc", + "dev-dashboards-output/panel-timeseries/timeseries-faceted-labels.v42.json": "e19fc15e431bfa945a1d06b26346b72d1b5b879bcacbd09b56f53ab69310e091", "dev-dashboards-output/panel-timeseries/timeseries-formats.v42.json": "63d06391e6b2dfa7095d415aa313ccfaad5bc181b588b217b52656e537ab373c", "dev-dashboards-output/panel-timeseries/timeseries-gradient-area.v42.json": "c6c0d8e70f05577d4203e86ce82fc3ef9d971bf829c3f6add7512250581d5401", "dev-dashboards-output/panel-timeseries/timeseries-hue-gradients.v42.json": "70a767fbd244f07b6621a5758fdd0b031d2a6af05ab5fd31c09b316174b982ca", diff --git a/devenv/dev-dashboards/panel-timeseries/timeseries-faceted-labels.json b/devenv/dev-dashboards/panel-timeseries/timeseries-faceted-labels.json new file mode 100644 index 000000000000..8b5fdb5844f4 --- /dev/null +++ b/devenv/dev-dashboards/panel-timeseries/timeseries-faceted-labels.json @@ -0,0 +1,126 @@ +{ + "uid": "faceted-labels-demo", + "title": "Faceted labels demo", + "tags": ["gdev", "panel-tests"], + "editable": true, + "panels": [ + { + "id": 1, + "title": "Multiple names + labels (both sections)", + "description": "Has 'By name' (cpu, mem, disk) and 'By labels' (host, env). Eye icon should appear.", + "type": "timeseries", + "gridPos": { "h": 14, "w": 12, "x": 0, "y": 0 }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true, + "calcs": ["mean"], + "enableFacetedFilter": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "grafana-testdata-datasource" }, + "scenarioId": "raw_frame", + "rawFrameContent": "[{\"name\":\"System\",\"fields\":[{\"name\":\"time\",\"type\":\"time\",\"values\":[1700000000000,1700000060000,1700000120000,1700000180000,1700000240000,1700000300000]},{\"name\":\"cpu\",\"type\":\"number\",\"labels\":{\"host\":\"a\",\"env\":\"prod\"},\"values\":[45,52,48,55,50,47]},{\"name\":\"cpu\",\"type\":\"number\",\"labels\":{\"host\":\"b\",\"env\":\"prod\"},\"values\":[62,58,65,60,63,59]},{\"name\":\"cpu\",\"type\":\"number\",\"labels\":{\"host\":\"c\",\"env\":\"dev\"},\"values\":[30,35,28,33,31,29]},{\"name\":\"mem\",\"type\":\"number\",\"labels\":{\"host\":\"a\",\"env\":\"prod\"},\"values\":[72,74,71,75,73,72]},{\"name\":\"mem\",\"type\":\"number\",\"labels\":{\"host\":\"b\",\"env\":\"prod\"},\"values\":[85,82,88,84,86,83]},{\"name\":\"mem\",\"type\":\"number\",\"labels\":{\"host\":\"c\",\"env\":\"dev\"},\"values\":[55,58,53,57,56,54]},{\"name\":\"disk\",\"type\":\"number\",\"labels\":{\"host\":\"a\",\"env\":\"prod\"},\"values\":[40,40,41,41,42,42]},{\"name\":\"disk\",\"type\":\"number\",\"labels\":{\"host\":\"b\",\"env\":\"dev\"},\"values\":[65,66,65,67,66,68]}]}]" + } + ], + "datasource": { "type": "grafana-testdata-datasource" } + }, + { + "id": 2, + "title": "Labels only (single metric name)", + "description": "All fields named 'latency'. Only 'By labels' section appears (no 'By name').", + "type": "timeseries", + "gridPos": { "h": 14, "w": 12, "x": 12, "y": 0 }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true, + "calcs": ["mean"], + "enableFacetedFilter": true + }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "grafana-testdata-datasource" }, + "scenarioId": "raw_frame", + "rawFrameContent": "[{\"name\":\"Latency\",\"fields\":[{\"name\":\"time\",\"type\":\"time\",\"values\":[1700000000000,1700000060000,1700000120000,1700000180000,1700000240000,1700000300000]},{\"name\":\"latency\",\"type\":\"number\",\"labels\":{\"service\":\"gateway\",\"region\":\"us\"},\"values\":[23,25,22,28,24,26]},{\"name\":\"latency\",\"type\":\"number\",\"labels\":{\"service\":\"gateway\",\"region\":\"eu\"},\"values\":[18,21,17,28,19,23]},{\"name\":\"latency\",\"type\":\"number\",\"labels\":{\"service\":\"auth\",\"region\":\"us\"},\"values\":[31,34,29,36,32,30]},{\"name\":\"latency\",\"type\":\"number\",\"labels\":{\"service\":\"auth\",\"region\":\"eu\"},\"values\":[28,32,27,38,30,35]}]}]" + } + ], + "datasource": { "type": "grafana-testdata-datasource" } + }, + { + "id": 3, + "title": "Names only (no labels)", + "description": "Multiple field names (cpu, mem, disk) but no labels. Only 'By name' section appears.", + "type": "timeseries", + "gridPos": { "h": 14, "w": 8, "x": 0, "y": 28 }, + "options": { + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true, "enableFacetedFilter": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "grafana-testdata-datasource" }, + "scenarioId": "raw_frame", + "rawFrameContent": "[{\"name\":\"Metrics\",\"fields\":[{\"name\":\"time\",\"type\":\"time\",\"values\":[1700000000000,1700000060000,1700000120000,1700000180000,1700000240000,1700000300000]},{\"name\":\"cpu\",\"type\":\"number\",\"values\":[45,52,48,55,50,47]},{\"name\":\"mem\",\"type\":\"number\",\"values\":[72,74,71,75,73,72]},{\"name\":\"disk\",\"type\":\"number\",\"values\":[40,40,41,41,42,42]}]}]" + } + ], + "datasource": { "type": "grafana-testdata-datasource" } + }, + { + "id": 6, + "title": "No filter: single series", + "description": "One value field, no labels. Eye icon should NOT appear.", + "type": "timeseries", + "gridPos": { "h": 14, "w": 8, "x": 8, "y": 28 }, + "options": { + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true, "enableFacetedFilter": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "grafana-testdata-datasource" }, + "scenarioId": "raw_frame", + "rawFrameContent": "[{\"name\":\"Simple\",\"fields\":[{\"name\":\"time\",\"type\":\"time\",\"values\":[1700000000000,1700000060000,1700000120000,1700000180000,1700000240000,1700000300000]},{\"name\":\"value\",\"type\":\"number\",\"values\":[10,12,11,14,13,12]}]}]" + } + ], + "datasource": { "type": "grafana-testdata-datasource" } + }, + { + "id": 7, + "title": "No filter: same name, no labels", + "description": "Multiple fields all named 'value', no labels. Eye icon should NOT appear (single unique name, no labels).", + "type": "timeseries", + "gridPos": { "h": 14, "w": 8, "x": 16, "y": 28 }, + "options": { + "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true, "enableFacetedFilter": true }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "grafana-testdata-datasource" }, + "scenarioId": "raw_frame", + "rawFrameContent": "[{\"name\":\"Frame1\",\"fields\":[{\"name\":\"time\",\"type\":\"time\",\"values\":[1700000000000,1700000060000,1700000120000,1700000180000,1700000240000,1700000300000]},{\"name\":\"value\",\"type\":\"number\",\"values\":[10,12,11,14,13,12]}]},{\"name\":\"Frame2\",\"fields\":[{\"name\":\"time\",\"type\":\"time\",\"values\":[1700000000000,1700000060000,1700000120000,1700000180000,1700000240000,1700000300000]},{\"name\":\"value\",\"type\":\"number\",\"values\":[20,22,21,24,23,22]}]}]" + } + ], + "datasource": { "type": "grafana-testdata-datasource" } + } + ], + "time": { + "from": "2023-11-14T22:13:20.000Z", + "to": "2023-11-14T22:24:20.000Z" + }, + "timezone": "browser", + "schemaVersion": 39 +} diff --git a/devenv/jsonnet/dev-dashboards.libsonnet b/devenv/jsonnet/dev-dashboards.libsonnet index bbd89cda820c..c2c997b61b73 100644 --- a/devenv/jsonnet/dev-dashboards.libsonnet +++ b/devenv/jsonnet/dev-dashboards.libsonnet @@ -128,6 +128,7 @@ "timeseries": (import '../dev-dashboards/panel-timeseries/timeseries.json'), "timeseries-bars-high-density": (import '../dev-dashboards/panel-timeseries/timeseries-bars-high-density.json'), "timeseries-by-value-color-schemes": (import '../dev-dashboards/panel-timeseries/timeseries-by-value-color-schemes.json'), + "timeseries-faceted-labels": (import '../dev-dashboards/panel-timeseries/timeseries-faceted-labels.json'), "timeseries-formats": (import '../dev-dashboards/panel-timeseries/timeseries-formats.json'), "timeseries-gradient-area": (import '../dev-dashboards/panel-timeseries/timeseries-gradient-area.json'), "timeseries-hue-gradients": (import '../dev-dashboards/panel-timeseries/timeseries-hue-gradients.json'), diff --git a/e2e-playwright/panels-suite/timeseries-faceted-labels-filter.spec.ts b/e2e-playwright/panels-suite/timeseries-faceted-labels-filter.spec.ts new file mode 100644 index 000000000000..aad1b5038d2d --- /dev/null +++ b/e2e-playwright/panels-suite/timeseries-faceted-labels-filter.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +const DASHBOARD_UID = 'faceted-labels-demo'; +const FIRST_PANEL_TITLE = 'Multiple names + labels (both sections)'; + +test.use({ + featureToggles: { vizLegendFacetedFilter: true }, +}); + +test.describe('TimeSeries faceted labels filter', { tag: ['@panels', '@timeseries'] }, () => { + test('filter toggle appears and opens popover with label sections', async ({ + gotoDashboardPage, + page, + selectors, + }) => { + const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID }); + const panel = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(FIRST_PANEL_TITLE)); + await expect(panel).toBeVisible(); + + const filterToggle = page.getByTestId('faceted-labels-filter-toggle').first(); + await expect(filterToggle).toBeVisible(); + + await filterToggle.click(); + + const popover = page.getByTestId('toggletip-content'); + await expect(popover.getByText('By name')).toBeVisible(); + await expect(popover.getByText('By labels')).toBeVisible(); + await expect(popover.getByText('cpu', { exact: true })).toBeVisible(); + await expect(popover.getByText('mem', { exact: true })).toBeVisible(); + await expect(popover.getByText('disk', { exact: true })).toBeVisible(); + }); + + test('selecting a name filters legend series', async ({ gotoDashboardPage, page, selectors }) => { + const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID }); + const panel = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(FIRST_PANEL_TITLE)); + await expect(panel).toBeVisible(); + + await page.getByTestId('faceted-labels-filter-toggle').first().click(); + const popover = page.getByTestId('toggletip-content'); + await popover.getByText('cpu', { exact: true }).click(); + + await expect(popover.getByRole('button', { name: 'Clear all' })).toBeVisible(); + }); + + test('clear all resets filter', async ({ gotoDashboardPage, page, selectors }) => { + const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID }); + const panel = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(FIRST_PANEL_TITLE)); + await expect(panel).toBeVisible(); + + await page.getByTestId('faceted-labels-filter-toggle').first().click(); + const popover = page.getByTestId('toggletip-content'); + await popover.getByText('cpu', { exact: true }).click(); + + const clearAll = popover.getByRole('button', { name: 'Clear all' }); + await expect(clearAll).toBeVisible(); + await clearAll.click(); + + await expect(popover.getByText('Select all').first()).toBeVisible(); + }); + + test('select all selects all values for a key', async ({ gotoDashboardPage, page, selectors }) => { + const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID }); + const panel = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(FIRST_PANEL_TITLE)); + await expect(panel).toBeVisible(); + + await page.getByTestId('faceted-labels-filter-toggle').first().click(); + const popover = page.getByTestId('toggletip-content'); + + await popover.getByText('Select all', { exact: true }).first().click(); + + await expect(popover.getByText('Deselect all').first()).toBeVisible(); + }); + + test('filter becomes dimmed when legend item is clicked', async ({ gotoDashboardPage, page }) => { + await gotoDashboardPage({ + uid: DASHBOARD_UID, + queryParams: new URLSearchParams({ editPanel: '1' }), + }); + + await page.getByTestId('faceted-labels-filter-toggle').first().click(); + const popover = page.getByTestId('toggletip-content'); + await popover.getByText('cpu', { exact: true }).click(); + await popover.getByRole('button', { name: 'Pin to sidebar' }).click(); + + const filter = page.getByTestId('faceted-labels-filter').first(); + await expect(filter).toHaveCSS('opacity', '1'); + + const legendLabel = page.locator('button[class*="LegendLabel"]').first(); + await legendLabel.click(); + + await expect(filter).toHaveCSS('opacity', '0.5'); + }); + + test('pin to sidebar docks the filter', async ({ gotoDashboardPage, page, selectors }) => { + const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID }); + const panel = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(FIRST_PANEL_TITLE)); + await expect(panel).toBeVisible(); + + await page.getByTestId('faceted-labels-filter-toggle').first().click(); + const popover = page.getByTestId('toggletip-content'); + await popover.getByRole('button', { name: 'Pin to sidebar' }).click(); + + const unpinButton = page.getByRole('button', { name: 'Unpin' }); + await expect(unpinButton).toBeVisible(); + await expect(page.getByText('By name').first()).toBeVisible(); + }); +}); diff --git a/i18next.config.ts b/i18next.config.ts index ae0683186990..3d2baded480f 100644 --- a/i18next.config.ts +++ b/i18next.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ 'public/app/extensions/**/*', 'public/app/plugins/datasource/**/*', 'packages/*/dist/**/*', + '**/node_modules/**/*', ], input: ['public/**/*.{tsx,ts}', 'packages/grafana-ui/**/*.{tsx,ts}', 'packages/grafana-data/**/*.{tsx,ts}'], output: 'public/locales/{{language}}/{{namespace}}.json', diff --git a/package.json b/package.json index 78cf556da568..7e090cf91ae3 100644 --- a/package.json +++ b/package.json @@ -304,8 +304,8 @@ "@grafana/plugin-ui": "^0.13.1", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "7.1.1", - "@grafana/scenes-react": "7.1.1", + "@grafana/scenes": "7.1.5", + "@grafana/scenes-react": "7.1.5", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index e07ccffe1d1f..9409b9ffb5f0 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -251,7 +251,16 @@ export { CSVReader, toCSV, } from './utils/csv'; -export { parseLabels, findCommonLabels, findUniqueLabels, matchAllLabels, formatLabels } from './utils/labels'; +export { + parseLabels, + findCommonLabels, + findUniqueLabels, + matchAllLabels, + formatLabels, + extractFacetedLabels, + resolveFacetedFilterNames, + FIELD_NAME_FACET_KEY, +} from './utils/labels'; export { roundDecimals, guessDecimals } from './utils/numbers'; export { objRemoveUndefined, isEmptyObject } from './utils/object'; export { classicColors } from './utils/namedColorsPalette'; diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 88f9a5f818e7..048d8475013b 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1403,6 +1403,11 @@ export interface FeatureToggles { */ nestedFramesFieldOverrides?: boolean; /** + * Enable faceted labels filter for series visibility in the legend + * @default false + */ + vizLegendFacetedFilter?: boolean; + /** * Enable Y-axis scale configuration options for pre-bucketed heatmap data (heatmap-rows) * @default false */ diff --git a/packages/grafana-data/src/utils/labels.test.ts b/packages/grafana-data/src/utils/labels.test.ts index 5bf7d79a0935..07cd644776f3 100644 --- a/packages/grafana-data/src/utils/labels.test.ts +++ b/packages/grafana-data/src/utils/labels.test.ts @@ -1,6 +1,17 @@ +import { getFieldDisplayName } from '../field/fieldState'; import { Labels } from '../types/data'; +import { DataFrame, FieldType } from '../types/dataFrame'; -import { parseLabels, formatLabels, findCommonLabels, findUniqueLabels, matchAllLabels } from './labels'; +import { + parseLabels, + formatLabels, + findCommonLabels, + findUniqueLabels, + matchAllLabels, + extractFacetedLabels, + resolveFacetedFilterNames, + FIELD_NAME_FACET_KEY, +} from './labels'; describe('parseLabels()', () => { it('returns no labels on empty labels string', () => { @@ -77,3 +88,122 @@ describe('matchAllLabels()', () => { expect(matchAllLabels(undefined as unknown as Labels, { foo: 'bar' })).toBeTruthy(); }); }); + +function makeFrame(fields: Array<{ name?: string; labels?: Labels; type?: FieldType }>): DataFrame { + return { + name: 'test', + length: 0, + fields: fields.map((f) => ({ + name: f.name ?? 'value', + type: f.type ?? FieldType.number, + config: {}, + values: [], + labels: f.labels, + })), + }; +} + +describe('extractFacetedLabels()', () => { + it('returns empty object for empty input', () => { + expect(extractFacetedLabels([])).toEqual({}); + }); + + it('skips time fields', () => { + const frame = makeFrame([{ type: FieldType.time }, { labels: { job: 'grafana' } }]); + expect(extractFacetedLabels([frame])).toEqual({ job: ['grafana'] }); + }); + + it('collects deduplicated sorted values across frames, skipping unlabeled fields', () => { + const frame1 = makeFrame([ + { labels: { job: 'grafana', instance: 'localhost:3000' } }, + {}, + { labels: { job: 'grafana', instance: 'localhost:3001' } }, + ]); + const frame2 = makeFrame([{ labels: { job: 'prometheus', instance: 'localhost:9090' } }]); + + expect(extractFacetedLabels([frame1, frame2])).toEqual({ + job: ['grafana', 'prometheus'], + instance: ['localhost:3000', 'localhost:3001', 'localhost:9090'], + }); + }); + + it('adds __name__ facet when fields have multiple distinct names', () => { + const frame = makeFrame([ + { name: 'cpu', labels: { host: 'a' } }, + { name: 'mem', labels: { host: 'a' } }, + ]); + const result = extractFacetedLabels([frame]); + expect(result[FIELD_NAME_FACET_KEY]).toEqual(['cpu', 'mem']); + expect(result.host).toEqual(['a']); + }); + + it('omits __name__ facet when all fields share the same name', () => { + const frame = makeFrame([ + { name: 'cpu', labels: { host: 'a' } }, + { name: 'cpu', labels: { host: 'b' } }, + ]); + expect(extractFacetedLabels([frame])).toEqual({ host: ['a', 'b'] }); + expect(extractFacetedLabels([frame])[FIELD_NAME_FACET_KEY]).toBeUndefined(); + }); +}); + +describe('resolveFacetedFilterNames()', () => { + const frames: DataFrame[] = [ + makeFrame([ + { name: 'cpu', labels: { host: 'a', region: 'us' } }, + { name: 'cpu', labels: { host: 'b', region: 'eu' } }, + ]), + makeFrame([ + { name: 'mem', labels: { host: 'a', region: 'us' } }, + { name: 'mem', labels: { host: 'b', region: 'eu' } }, + ]), + ]; + + it('returns null when selection is empty', () => { + expect(resolveFacetedFilterNames(frames, {}, getFieldDisplayName)).toBeNull(); + }); + + it('returns null when all selected arrays are empty', () => { + expect(resolveFacetedFilterNames(frames, { host: [], region: [] }, getFieldDisplayName)).toBeNull(); + }); + + it('applies OR within a single key', () => { + const result = resolveFacetedFilterNames(frames, { host: ['a', 'b'] }, getFieldDisplayName); + expect(result).toEqual([ + 'cpu {host="a", region="us"}', + 'cpu {host="b", region="eu"}', + 'mem {host="a", region="us"}', + 'mem {host="b", region="eu"}', + ]); + }); + + it('applies AND across different keys', () => { + const result = resolveFacetedFilterNames(frames, { host: ['a'], region: ['eu'] }, getFieldDisplayName); + expect(result).toEqual([]); + }); + + it('matches fields using the __name__ facet', () => { + const result = resolveFacetedFilterNames(frames, { [FIELD_NAME_FACET_KEY]: ['cpu'] }, getFieldDisplayName); + expect(result).toEqual(['cpu {host="a", region="us"}', 'cpu {host="b", region="eu"}']); + }); + + it('combines __name__ and label filters with AND', () => { + const result = resolveFacetedFilterNames( + frames, + { + [FIELD_NAME_FACET_KEY]: ['mem'], + host: ['b'], + }, + getFieldDisplayName + ); + expect(result).toEqual(['mem {host="b", region="eu"}']); + }); + + it('excludes fields without the selected label key', () => { + const mixedFrames = [makeFrame([{ name: 'cpu', labels: { host: 'a' } }, { name: 'unlabeled' }])]; + const result = resolveFacetedFilterNames(mixedFrames, { host: ['a'] }, getFieldDisplayName); + expect(result).toHaveLength(1); + expect(result![0]).toContain('cpu'); + expect(result).not.toContainEqual(expect.stringContaining('unlabeled')); + }); +}); diff --git a/packages/grafana-data/src/utils/labels.ts b/packages/grafana-data/src/utils/labels.ts index e4c9709587f4..40f473c8927e 100644 --- a/packages/grafana-data/src/utils/labels.ts +++ b/packages/grafana-data/src/utils/labels.ts @@ -1,4 +1,10 @@ import { Labels } from '../types/data'; +import { DataFrame, Field, FieldType } from '../types/dataFrame'; + +/** + * Synthetic facet key representing the field/metric name as a filterable dimension. + */ +export const FIELD_NAME_FACET_KEY = '__name__'; /** * Regexp to extract Prometheus-style labels @@ -72,6 +78,82 @@ export function matchAllLabels(expect: Labels, against?: Labels): boolean { return true; } +/** + * Collects unique label values per key across all fields. + * Adds a synthetic `__name__` facet when fields have multiple distinct names. + */ +export function extractFacetedLabels(frames: DataFrame[]): Record { + const valuesByKey: Record> = {}; + const fieldNames = new Set(); + + for (const frame of frames) { + for (const field of frame.fields) { + if (field.type === FieldType.time) { + continue; + } + + fieldNames.add(field.name); + + if (field.labels) { + for (const [key, value] of Object.entries(field.labels)) { + (valuesByKey[key] ??= new Set()).add(value); + } + } + } + } + + const result: Record = {}; + + if (fieldNames.size > 1) { + result[FIELD_NAME_FACET_KEY] = Array.from(fieldNames).sort(); + } + + for (const key in valuesByKey) { + result[key] = Array.from(valuesByKey[key]).sort(); + } + + return result; +} + +/** + * Returns display names of fields matching the faceted selection (OR within key, AND across keys). + * Returns null when selection is empty. + */ +export function resolveFacetedFilterNames( + frames: DataFrame[], + selected: Record, + getDisplayName: (field: Field, frame: DataFrame, allFrames: DataFrame[]) => string +): string[] | null { + const activeKeys = Object.entries(selected).filter(([, values]) => values.length > 0); + + if (activeKeys.length === 0) { + return null; + } + + const names: string[] = []; + + for (const frame of frames) { + for (const field of frame.fields) { + if (field.type === FieldType.time) { + continue; + } + + const matches = activeKeys.every(([key, allowed]) => { + if (key === FIELD_NAME_FACET_KEY) { + return allowed.includes(field.name); + } + return field.labels?.[key] !== undefined && allowed.includes(field.labels[key]); + }); + + if (matches) { + names.push(getDisplayName(field, frame, frames)); + } + } + } + + return names; +} + /** * Serializes the given labels to a string. */ diff --git a/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/types.gen.ts b/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/types.gen.ts index 26c0f1148841..5f53114aeb2f 100644 --- a/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/timeseries/panelcfg/x/types.gen.ts @@ -14,9 +14,17 @@ import * as common from '@grafana/schema'; export const pluginVersion = "13.0.0-pre"; +export interface TimeSeriesLegendOptions extends common.VizLegendOptions { + enableFacetedFilter?: boolean; +} + +export const defaultTimeSeriesLegendOptions: Partial = { + enableFacetedFilter: true, +}; + export interface Options extends common.OptionsWithTimezones, common.OptionsWithAnnotations { disableKeyboardEvents?: boolean; - legend: common.VizLegendOptions; + legend: TimeSeriesLegendOptions; orientation?: common.VizOrientation; timeCompare?: common.TimeCompareOptions; tooltip: common.VizTooltipOptions; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts index 3e34eed16f7f..4fd660b52658 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts +++ b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts @@ -34,7 +34,7 @@ export interface PanelContext { */ onSeriesColorChange?: (label: string, color: string) => void; - onToggleSeriesVisibility?: (label: string, mode: SeriesVisibilityChangeMode) => void; + onToggleSeriesVisibility?: (label: string | string[] | null, mode: SeriesVisibilityChangeMode) => void; canAddAnnotations?: () => boolean; canEditAnnotations?: (dashboardUID?: string) => boolean; diff --git a/packages/grafana-ui/src/components/PanelChrome/types.ts b/packages/grafana-ui/src/components/PanelChrome/types.ts index 40701219c7b4..9205f3f8aa5c 100644 --- a/packages/grafana-ui/src/components/PanelChrome/types.ts +++ b/packages/grafana-ui/src/components/PanelChrome/types.ts @@ -7,6 +7,7 @@ export enum SeriesVisibilityChangeMode { ToggleSelection = 'select', AppendToSelection = 'append', + SetExactly = 'setExactly', } export type OnSelectRangeCallback = (selections: RangeSelection2D[]) => void; diff --git a/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx b/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx index 0b36236cb883..c2d24e3b617e 100644 --- a/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx +++ b/packages/grafana-ui/src/components/Toggletip/Toggletip.tsx @@ -47,6 +47,8 @@ export interface ToggletipProps { show?: boolean; /** Callback function to be called when the toggletip is opened */ onOpen?: () => void; + /** Dismiss the toggletip when an ancestor element is scrolled */ + dismissOnScroll?: boolean; } /** @@ -67,6 +69,7 @@ export const Toggletip = memo( fitContent = false, onOpen, show, + dismissOnScroll = false, }: ToggletipProps) => { const arrowRef = useRef(null); const grafanaTheme = useTheme2(); @@ -106,7 +109,7 @@ export const Toggletip = memo( }); const click = useClick(context); - const dismiss = useDismiss(context); + const dismiss = useDismiss(context, { ancestorScroll: dismissOnScroll }); const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]); diff --git a/packages/grafana-ui/src/components/VizLegend/FacetedLabelsFilter.test.tsx b/packages/grafana-ui/src/components/VizLegend/FacetedLabelsFilter.test.tsx new file mode 100644 index 000000000000..72403f7d5ebc --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend/FacetedLabelsFilter.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { FIELD_NAME_FACET_KEY } from '@grafana/data'; + +import { FacetedLabelsFilter, FacetedLabelsFilterProps } from './FacetedLabelsFilter'; + +function renderFilter(overrides: Partial = {}) { + const props: FacetedLabelsFilterProps = { + labels: { + [FIELD_NAME_FACET_KEY]: ['cpu', 'mem'], + host: ['a', 'b'], + region: ['eu', 'us'], + }, + selected: {}, + onChange: jest.fn(), + ...overrides, + }; + + return { ...render(), onChange: props.onChange as jest.Mock }; +} + +describe('FacetedLabelsFilter', () => { + it('returns null when labels are empty', () => { + const { container } = renderFilter({ labels: {} }); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders sections based on available facets', () => { + const { unmount } = renderFilter(); + expect(screen.getByText('By name')).toBeInTheDocument(); + expect(screen.getByText('By labels')).toBeInTheDocument(); + unmount(); + + const { unmount: u2 } = renderFilter({ labels: { host: ['a'] } }); + expect(screen.queryByText('By name')).not.toBeInTheDocument(); + u2(); + + renderFilter({ labels: { [FIELD_NAME_FACET_KEY]: ['cpu'] } }); + expect(screen.queryByText('By labels')).not.toBeInTheDocument(); + }); + + it('toggles checkbox values and shows deselect when all selected', async () => { + const { onChange, unmount } = renderFilter(); + await userEvent.click(screen.getByLabelText('cpu')); + expect(onChange).toHaveBeenCalledWith({ [FIELD_NAME_FACET_KEY]: ['cpu'] }); + unmount(); + + const { onChange: onChange2 } = renderFilter({ selected: { [FIELD_NAME_FACET_KEY]: ['cpu', 'mem'] } }); + expect(screen.getByText('Deselect all')).toBeInTheDocument(); + await userEvent.click(screen.getByLabelText('cpu')); + expect(onChange2).toHaveBeenCalledWith({ [FIELD_NAME_FACET_KEY]: ['mem'] }); + }); + + it('expands/collapses label groups and toggles values within them', async () => { + const { onChange } = renderFilter(); + expect(screen.queryByLabelText('a')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('host')); + expect(screen.getByLabelText('a')).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('a')); + expect(onChange).toHaveBeenCalledWith({ host: ['a'] }); + + await userEvent.click(screen.getByText('host')); + expect(screen.queryByLabelText('a')).not.toBeInTheDocument(); + }); + + it('shows selected count badge on collapsed label groups', () => { + renderFilter({ selected: { host: ['a'] } }); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('select all / deselect all toggles all values for a key', async () => { + const { onChange, unmount } = renderFilter(); + await userEvent.click(screen.getByText('Select all', { selector: 'button' })); + expect(onChange).toHaveBeenCalledWith({ [FIELD_NAME_FACET_KEY]: ['cpu', 'mem'] }); + unmount(); + + const { onChange: onChange2 } = renderFilter({ + selected: { [FIELD_NAME_FACET_KEY]: ['cpu', 'mem'] }, + }); + await userEvent.click(screen.getByText('Deselect all', { selector: 'button' })); + expect(onChange2).toHaveBeenCalledWith({ [FIELD_NAME_FACET_KEY]: [] }); + }); + + it('applies dimmed style when dimmed prop is true', () => { + const { container: dimmed } = renderFilter({ dimmed: true }); + const { container: normal } = renderFilter({ dimmed: false }); + expect((dimmed.firstChild as HTMLElement).className).not.toBe((normal.firstChild as HTMLElement).className); + }); +}); diff --git a/packages/grafana-ui/src/components/VizLegend/FacetedLabelsFilter.tsx b/packages/grafana-ui/src/components/VizLegend/FacetedLabelsFilter.tsx new file mode 100644 index 000000000000..d9ece9965226 --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend/FacetedLabelsFilter.tsx @@ -0,0 +1,199 @@ +import { css, cx } from '@emotion/css'; +import { useCallback, useState } from 'react'; + +import { FIELD_NAME_FACET_KEY, GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; + +import { useStyles2 } from '../../themes/ThemeContext'; +import { Checkbox } from '../Forms/Checkbox'; +import { Icon } from '../Icon/Icon'; + +export interface FacetedLabelsFilterProps { + /** Map of label keys to their sorted unique values, from extractFacetedLabels */ + labels: Record; + /** Currently selected label values, keyed by label key */ + selected: Record; + /** Called when the selection changes */ + onChange: (selected: Record) => void; + /** When true the filter is dimmed to indicate the legend has taken precedence */ + dimmed?: boolean; +} + +export function FacetedLabelsFilter({ labels, selected, onChange, dimmed }: FacetedLabelsFilterProps) { + const styles = useStyles2(getStyles); + const [expandedKeys, setExpandedKeys] = useState>({}); + const labelKeys = Object.keys(labels); + + const toggleExpanded = useCallback((key: string) => { + setExpandedKeys((prev) => ({ ...prev, [key]: !prev[key] })); + }, []); + + const toggleValue = useCallback( + (key: string, value: string) => { + const current = selected[key] ?? []; + const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value]; + + onChange({ ...selected, [key]: next }); + }, + [selected, onChange] + ); + + const toggleAllForKey = useCallback( + (key: string) => { + const values = labels[key]; + if (!values) { + return; + } + const current = selected[key] ?? []; + const allSelected = current.length === values.length; + onChange({ ...selected, [key]: allSelected ? [] : [...values] }); + }, + [labels, selected, onChange] + ); + + const seriesValues = labels[FIELD_NAME_FACET_KEY]; + const realLabelKeys = labelKeys.filter((key) => key !== FIELD_NAME_FACET_KEY); + + if (!seriesValues && realLabelKeys.length === 0) { + return null; + } + + const renderCheckboxList = (key: string, values: string[], className: string) => { + const selectedValues = selected[key] ?? []; + return ( +
+ {values.map((value) => ( + toggleValue(key, value)} + className={styles.checkbox} + /> + ))} + +
+ ); + }; + + return ( +
+ {seriesValues && ( +
+ + By name + + {renderCheckboxList(FIELD_NAME_FACET_KEY, seriesValues, styles.checkboxList)} +
+ )} + + {realLabelKeys.length > 0 && ( +
+ + By labels + + {realLabelKeys.map((key) => { + const selectedValues = selected[key] ?? []; + const isExpanded = expandedKeys[key] ?? false; + return ( +
+ + {isExpanded && renderCheckboxList(key, labels[key], styles.checkboxListIndented)} +
+ ); + })} +
+ )} +
+ ); +} + +FacetedLabelsFilter.displayName = 'FacetedLabelsFilter'; + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + minWidth: '150px', + padding: theme.spacing(1.5, 1, 1, 1), + gap: theme.spacing(1), + }), + dimmed: css({ + opacity: 0.5, + }), + section: css({ + display: 'flex', + flexDirection: 'column', + }), + sectionLabel: css({ + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.fontWeightMedium, + color: theme.colors.text.secondary, + marginBottom: theme.spacing(0.25), + }), + toggleAll: css({ + all: 'unset', + cursor: 'pointer', + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.link, + marginTop: theme.spacing(0.25), + '&:hover': { + textDecoration: 'underline', + }, + }), + labelGroup: css({ + display: 'flex', + flexDirection: 'column', + }), + labelKey: css({ + all: 'unset', + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + cursor: 'pointer', + padding: theme.spacing(0.25, 0), + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.primary, + '&:hover': { + color: theme.colors.text.maxContrast, + }, + }), + keyName: css({ + fontWeight: theme.typography.fontWeightMedium, + fontFamily: theme.typography.fontFamilyMonospace, + }), + count: css({ + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.primary.text, + fontWeight: theme.typography.fontWeightMedium, + }), + checkboxList: css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: theme.spacing(0.25), + padding: theme.spacing(0, 0, 0.5, 0.5), + }), + checkboxListIndented: css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: theme.spacing(0.25), + padding: theme.spacing(0, 0, 0.5, 2.5), + }), + checkbox: css({ + fontSize: theme.typography.bodySmall.fontSize, + fontFamily: theme.typography.fontFamilyMonospace, + }), +}); diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx index 565bb88ceb01..e4055b38cd71 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx @@ -32,6 +32,7 @@ export function VizLegend({ readonly, isSortable, limit, + filterAction, }: LegendProps) { const { eventBus, onToggleSeriesVisibility, onToggleLegendSort } = usePanelContext(); @@ -101,10 +102,11 @@ export function VizLegend({ readonly={readonly} items={items} limit={limit} + filterAction={filterAction} /> ); }, - [className, placement, onMouseOver, onMouseOut, onLegendLabelClick, itemRenderer, readonly, limit] + [className, placement, onMouseOver, onMouseOut, onLegendLabelClick, itemRenderer, readonly, limit, filterAction] ); switch (displayMode) { @@ -124,6 +126,7 @@ export function VizLegend({ readonly={readonly} isSortable={isSortable} limit={limit} + filterAction={filterAction} /> ); case LegendDisplayMode.List: diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx index aed810908eb7..79dd87842f4e 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendList.tsx @@ -25,6 +25,7 @@ export const VizLegendList = ({ className, readonly, limit = 0, + filterAction, }: Props) => { const styles = useStyles2(getStyles); @@ -41,7 +42,6 @@ export const VizLegendList = ({ ); } - // split into left & right items when bottom/default legend, else everything goes in leftItems const leftItems = useMemo( () => (placement === 'right' ? items : items.filter((item) => item.yAxis === 1)), [placement, items] @@ -61,6 +61,7 @@ export const VizLegendList = ({ return (
+ {filterAction && {filterAction}}
); @@ -75,11 +76,13 @@ export const VizLegendList = ({
{leftItems.length > 0 && (
+ {filterAction && {filterAction}}
)} {rightItems.length > 0 && (
+ {!leftItems.length && filterAction && {filterAction}}
)} @@ -120,6 +123,7 @@ const getStyles = (theme: GrafanaTheme2) => { }), section: css({ display: 'flex', + flexWrap: 'wrap', }), sectionRight: css({ justifyContent: 'flex-end', diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx index 4c142c137c18..a61b06e8b11c 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx @@ -31,6 +31,7 @@ export const VizLegendTable = ({ readonly, isSortable, limit = 0, + filterAction, }: VizLegendTableProps): JSX.Element => { const styles = useStyles2(getStyles); const header: Record = { @@ -59,12 +60,10 @@ export const VizLegendTable = ({ let sortMult = sortDesc ? -1 : 1; if (sortKey === nameSortKey) { - // string sort items.sort((a, b) => { return sortMult * naturalCompare(a.label, b.label); }); } else { - // numeric sort items.sort((a, b) => { const aVal = itemVals.get(a) ?? 0; const bVal = itemVals.get(b) ?? 0; @@ -112,6 +111,12 @@ export const VizLegendTable = ({ } }} > + {columnTitle === nameSortKey && filterAction && ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions + e.stopPropagation()}> + {filterAction} + + )} {columnTitle} {sortKey === columnTitle && } @@ -155,11 +160,15 @@ const getStyles = (theme: GrafanaTheme2) => ({ textAlign: 'left', paddingLeft: '30px', }), - // This needs to be padding-right - icon size(xs==12) to avoid jumping withIcon: css({ paddingRight: '4px', }), headerSortable: css({ cursor: 'pointer', }), + filterAction: css({ + marginLeft: theme.spacing(0.5), + display: 'inline-flex', + verticalAlign: 'middle', + }), }); diff --git a/packages/grafana-ui/src/components/VizLegend/types.ts b/packages/grafana-ui/src/components/VizLegend/types.ts index 43f7f2f0c327..6d851bfbc035 100644 --- a/packages/grafana-ui/src/components/VizLegend/types.ts +++ b/packages/grafana-ui/src/components/VizLegend/types.ts @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { JSX } from 'react'; +import type { JSX, ReactNode } from 'react'; import { DataFrameFieldIndex, DisplayValue } from '@grafana/data'; import { LegendDisplayMode, LegendPlacement, LineStyle } from '@grafana/schema'; @@ -28,6 +28,7 @@ export interface VizLegendBaseProps { ) => void; readonly?: boolean; limit?: number; + filterAction?: ReactNode; } export interface VizLegendTableProps extends VizLegendBaseProps { diff --git a/packages/grafana-ui/src/components/uPlot/PlotLegend.test.tsx b/packages/grafana-ui/src/components/uPlot/PlotLegend.test.tsx new file mode 100644 index 000000000000..12842c0abe9a --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/PlotLegend.test.tsx @@ -0,0 +1,91 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { createTheme, FieldType } from '@grafana/data'; +import { LegendDisplayMode } from '@grafana/schema'; + +import { PanelContextProvider } from '../PanelChrome/PanelContext'; +import { SeriesVisibilityChangeMode } from '../PanelChrome/types'; + +import { PlotLegend } from './PlotLegend'; +import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; + +const theme = createTheme(); + +function buildConfig(count: number): UPlotConfigBuilder { + const config = new UPlotConfigBuilder(); + for (let i = 0; i < count; i++) { + config.addSeries({ + dataFrameFieldIndex: { frameIndex: 0, fieldIndex: i + 1 }, + scaleKey: 'y', + show: true, + theme, + }); + } + return config; +} + +const defaultProps: React.ComponentProps = { + data: [ + { + length: 2, + fields: [ + { name: 'time', type: FieldType.time, values: [1, 2], config: {} }, + { name: 'cpu', type: FieldType.number, values: [1, 2], config: {}, labels: { host: 'a' } }, + { name: 'mem', type: FieldType.number, values: [1, 2], config: {}, labels: { host: 'b' } }, + ], + }, + ], + config: buildConfig(2), + placement: 'bottom', + displayMode: LegendDisplayMode.List, + calcs: [], + showLegend: true, + enableFacetedFilter: true, +}; + +function renderWithContext(overrides: Partial> = {}) { + const toggle = jest.fn(); + return { + toggle, + ...render( + + + + ), + }; +} + +describe('PlotLegend faceted filter', () => { + it('does not render filter when disabled', () => { + renderWithContext({ enableFacetedFilter: false }); + expect(screen.queryByTestId('faceted-labels-filter-toggle')).not.toBeInTheDocument(); + }); + + it('opens popover and calls onToggleSeriesVisibility on selection', async () => { + const { toggle } = renderWithContext(); + await userEvent.click(screen.getByTestId('faceted-labels-filter-toggle')); + + const popover = screen.getByTestId('toggletip-content'); + expect(within(popover).getByText('By name')).toBeInTheDocument(); + + await userEvent.click(within(popover).getByLabelText('cpu')); + expect(toggle).toHaveBeenCalledWith(expect.any(Array), SeriesVisibilityChangeMode.SetExactly); + }); + + it('docks filter, shows clear all when active, and resets on clear', async () => { + const { toggle } = renderWithContext(); + + await userEvent.click(screen.getByTestId('faceted-labels-filter-toggle')); + await userEvent.click(within(screen.getByTestId('toggletip-content')).getByText('Pin to sidebar')); + expect(screen.getByLabelText('Unpin')).toBeInTheDocument(); + + await userEvent.click(screen.getByLabelText('cpu')); + toggle.mockClear(); + + await userEvent.click(screen.getByLabelText('Clear all')); + expect(toggle).toHaveBeenCalledWith(null, SeriesVisibilityChangeMode.SetExactly); + }); +}); diff --git a/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx b/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx index 594fcd92a115..25c9446d8405 100644 --- a/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx +++ b/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx @@ -1,10 +1,25 @@ -import { memo } from 'react'; +import { css } from '@emotion/css'; +import { memo, useCallback, useMemo, useState } from 'react'; -import { DataFrame, getFieldDisplayName, getFieldSeriesColor } from '@grafana/data'; +import { + DataFrame, + GrafanaTheme2, + extractFacetedLabels, + getFieldDisplayName, + getFieldSeriesColor, + resolveFacetedFilterNames, +} from '@grafana/data'; +import { t } from '@grafana/i18n'; import { VizLegendOptions, AxisPlacement } from '@grafana/schema'; -import { useTheme2 } from '../../themes/ThemeContext'; +import { SeriesVisibilityChangeMode } from '../../components/PanelChrome/types'; +import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; +import { Button } from '../Button/Button'; +import { IconButton } from '../IconButton/IconButton'; +import { usePanelContext } from '../PanelChrome'; +import { Toggletip } from '../Toggletip/Toggletip'; import { VizLayout, VizLayoutLegendProps } from '../VizLayout/VizLayout'; +import { FacetedLabelsFilter } from '../VizLegend/FacetedLabelsFilter'; import { VizLegend } from '../VizLegend/VizLegend'; import { VizLegendItem } from '../VizLegend/types'; @@ -14,6 +29,7 @@ import { getDisplayValuesForCalcs } from './utils'; interface PlotLegendProps extends VizLegendOptions, Omit { data: DataFrame[]; config: UPlotConfigBuilder; + enableFacetedFilter?: boolean; } /** @@ -39,58 +55,230 @@ export function hasVisibleLegendSeries(config: UPlotConfigBuilder, data: DataFra }); } -export const PlotLegend = memo( - ({ data, config, placement, calcs, displayMode, limit, ...vizLayoutLegendProps }: PlotLegendProps) => { - const theme = useTheme2(); +export const PlotLegend = memo(function PlotLegend({ + data, + config, + placement, + calcs, + displayMode, + limit, + enableFacetedFilter = false, + ...vizLayoutLegendProps +}: PlotLegendProps) { + const theme = useTheme2(); + const styles = useStyles2(getPlotLegendStyles); + const { onToggleSeriesVisibility } = usePanelContext(); - const legendItems = config - .getSeries() - .map((s) => { - const seriesConfig = s.props; - const fieldIndex = seriesConfig.dataFrameFieldIndex; - const axisPlacement = config.getAxisPlacement(s.props.scaleKey); + const [selectedLabels, setSelectedLabels] = useState>({}); + const [filterDocked, setFilterDocked] = useState(false); - if (!fieldIndex) { - return undefined; - } + const facetedLabels = useMemo( + () => (enableFacetedFilter && onToggleSeriesVisibility ? extractFacetedLabels(data) : {}), + [enableFacetedFilter, onToggleSeriesVisibility, data] + ); + const hasFacetedLabels = Object.keys(facetedLabels).length > 0; + const hasActiveFilters = Object.values(selectedLabels).some((v) => v.length > 0); - const field = data[fieldIndex.frameIndex]?.fields[fieldIndex.fieldIndex]; + const legendItems = config + .getSeries() + .map((s) => { + const seriesConfig = s.props; + const fieldIndex = seriesConfig.dataFrameFieldIndex; + const axisPlacement = config.getAxisPlacement(s.props.scaleKey); - if (!field || field.config.custom?.hideFrom?.legend) { - return undefined; - } + if (!fieldIndex) { + return undefined; + } - const label = getFieldDisplayName(field, data[fieldIndex.frameIndex]!, data); - const scaleColor = getFieldSeriesColor(field, theme); - const seriesColor = scaleColor.color; + const field = data[fieldIndex.frameIndex]?.fields[fieldIndex.fieldIndex]; - return { - disabled: !(seriesConfig.show ?? true), - fieldIndex, - color: seriesColor, - label, - yAxis: axisPlacement === AxisPlacement.Left || axisPlacement === AxisPlacement.Bottom ? 1 : 2, - getDisplayValues: () => getDisplayValuesForCalcs(calcs, field, theme), - getItemKey: () => `${label}-${fieldIndex.frameIndex}-${fieldIndex.fieldIndex}`, - lineStyle: seriesConfig.lineStyle, - }; - }) - .filter((i): i is VizLegendItem => i !== undefined); + if (!field || field.config.custom?.hideFrom?.legend) { + return undefined; + } + const label = getFieldDisplayName(field, data[fieldIndex.frameIndex]!, data); + const scaleColor = getFieldSeriesColor(field, theme); + const seriesColor = scaleColor.color; + + return { + disabled: !(seriesConfig.show ?? true), + fieldIndex, + color: seriesColor, + label, + yAxis: axisPlacement === AxisPlacement.Left || axisPlacement === AxisPlacement.Bottom ? 1 : 2, + getDisplayValues: () => getDisplayValuesForCalcs(calcs, field, theme), + getItemKey: () => `${label}-${fieldIndex.frameIndex}-${fieldIndex.fieldIndex}`, + lineStyle: seriesConfig.lineStyle, + }; + }) + .filter((i): i is VizLegendItem => i !== undefined); + + const legendHasPrecedence = useMemo(() => { + if (!hasActiveFilters) { + return false; + } + + const expectedVisible = resolveFacetedFilterNames(data, selectedLabels, getFieldDisplayName); + if (!expectedVisible) { + return false; + } + + const actuallyVisible = new Set(legendItems.filter((item) => !item.disabled).map((item) => item.label)); + + if (expectedVisible.length !== actuallyVisible.size) { + return true; + } + + return !expectedVisible.every((name) => actuallyVisible.has(name)); + }, [hasActiveFilters, data, selectedLabels, legendItems]); + + const handleLabelsChange = useCallback( + (selected: Record) => { + setSelectedLabels(selected); + const visibleNames = resolveFacetedFilterNames(data, selected, getFieldDisplayName); + onToggleSeriesVisibility?.(visibleNames, SeriesVisibilityChangeMode.SetExactly); + }, + [data, onToggleSeriesVisibility] + ); + + const handleClearFilters = useCallback(() => { + setSelectedLabels({}); + onToggleSeriesVisibility?.(null, SeriesVisibilityChangeMode.SetExactly); + }, [onToggleSeriesVisibility]); + + const handleToggleFilterDock = useCallback(() => { + setFilterDocked((prev) => !prev); + }, []); + + const facetedFilter = hasFacetedLabels ? ( + + ) : null; + + const filterToggle = facetedFilter ? ( + + {facetedFilter} +
+ + {hasActiveFilters && ( + + )} +
+
+ } + placement="bottom-start" + fitContent + dismissOnScroll + > + + + ) : null; + + const legend = ( + + ); + + if (filterDocked && facetedFilter) { return ( - +
+
+
+ {hasActiveFilters && ( + + )} + +
+ {facetedFilter} +
+
{legend}
+
); } -); + + return ( + + {legend} + + ); +}); PlotLegend.displayName = 'PlotLegend'; + +const getPlotLegendStyles = (theme: GrafanaTheme2) => ({ + legendWithFilter: css({ + display: 'flex', + width: '100%', + height: '100%', + gap: theme.spacing(1), + overflow: 'hidden', + }), + legendContent: css({ + flex: 1, + minWidth: 0, + overflow: 'auto', + }), + filterContent: css({ + position: 'relative', + flexShrink: 0, + overflow: 'auto', + borderRight: `1px solid ${theme.colors.border.weak}`, + paddingRight: theme.spacing(1), + }), + filterDockedActions: css({ + position: 'absolute', + zIndex: 1, + right: theme.spacing(0.5), + top: theme.spacing(0.5), + display: 'flex', + gap: theme.spacing(0.25), + color: theme.colors.text.secondary, + }), + filterPopoverContent: css({ + margin: theme.spacing(-3, -2), + maxHeight: 400, + overflow: 'auto', + }), + filterPopoverFooter: css({ + display: 'flex', + gap: theme.spacing(1), + borderTop: `1px solid ${theme.colors.border.medium}`, + padding: theme.spacing(1), + }), +}); diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 52490c8b8c2d..c174335297b1 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2212,6 +2212,14 @@ var ( Owner: grafanaDatavizSquad, Expression: "false", }, + { + Name: "vizLegendFacetedFilter", + Description: "Enable faceted labels filter for series visibility in the legend", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDatavizSquad, + Expression: "false", + }, { Name: "heatmapRowsAxisOptions", Description: "Enable Y-axis scale configuration options for pre-bucketed heatmap data (heatmap-rows)", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 33d701cdb209..25a8e1b435d1 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -275,6 +275,7 @@ Created,Name,Stage,Owner,requiresDevMode,RequiresRestart,FrontendOnly 2025-12-01,externalVizSuggestions,experimental,@grafana/dataviz-squad,false,false,true 2026-03-02,vizLegendSeriesLimit,experimental,@grafana/dataviz-squad,false,false,true 2026-03-06,nestedFramesFieldOverrides,preview,@grafana/dataviz-squad,false,false,true +2026-03-10,vizLegendFacetedFilter,experimental,@grafana/dataviz-squad,false,false,true 2025-12-18,heatmapRowsAxisOptions,experimental,@grafana/dataviz-squad,false,false,true 2025-10-17,preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true 2025-10-31,jaegerEnableGrpcEndpoint,experimental,@grafana/oss-big-tent,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index eb66e34ead48..3efc1b473278 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -4712,6 +4712,20 @@ "expression": "false" } }, + { + "metadata": { + "name": "vizLegendFacetedFilter", + "resourceVersion": "1773129262183", + "creationTimestamp": "2026-03-10T07:54:22Z" + }, + "spec": { + "description": "Enable faceted labels filter for series visibility in the legend", + "stage": "experimental", + "codeowner": "@grafana/dataviz-squad", + "frontend": true, + "expression": "false" + } + }, { "metadata": { "name": "vizLegendSeriesLimit", diff --git a/pkg/tests/apis/dashboard/search_test.go b/pkg/tests/apis/dashboard/search_test.go index 72b81ac14d6b..bc2563c05ba0 100644 --- a/pkg/tests/apis/dashboard/search_test.go +++ b/pkg/tests/apis/dashboard/search_test.go @@ -95,7 +95,7 @@ func TestIntegrationSearchDevDashboards(t *testing.T) { return nil }) require.NoError(t, err) - require.Equal(t, 16, fileCount, "file count from %s", devenv) + require.Equal(t, 17, fileCount, "file count from %s", devenv) // Helper to call search callSearch := func(user apis.User, params map[string]string) dashboardV0.SearchResults { diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t00-all.json b/pkg/tests/apis/dashboard/testdata/searchV0/t00-all.json index 35b7cff03027..edbb4142e607 100644 --- a/pkg/tests/apis/dashboard/testdata/searchV0/t00-all.json +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t00-all.json @@ -1,6 +1,15 @@ { - "totalHits": 16, + "totalHits": 17, "hits": [ + { + "resource": "dashboards", + "name": "timeseries-faceted-labels", + "title": "Faceted labels demo", + "tags": [ + "gdev", + "panel-tests" + ] + }, { "resource": "dashboards", "name": "timeseries", diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json b/pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json index a5a686a43643..9b6536d947fe 100644 --- a/pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t01-query-single-word.json @@ -10,7 +10,7 @@ "panel-tests", "graph-ng" ], - "score": 0.671 + "score": 0.691 }, { "resource": "dashboards", @@ -21,8 +21,8 @@ "panel-tests", "graph-ng" ], - "score": 0.644 + "score": 0.662 } ], - "maxScore": 0.671 + "maxScore": 0.691 } \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t02-query-multiple-words.json b/pkg/tests/apis/dashboard/testdata/searchV0/t02-query-multiple-words.json index f9bbce5b2747..b99e1bd192e9 100644 --- a/pkg/tests/apis/dashboard/testdata/searchV0/t02-query-multiple-words.json +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t02-query-multiple-words.json @@ -10,8 +10,8 @@ "panel-tests", "graph-ng" ], - "score": 0.052 + "score": 0.053 } ], - "maxScore": 0.052 + "maxScore": 0.053 } \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t03-with-text-panel.json b/pkg/tests/apis/dashboard/testdata/searchV0/t03-with-text-panel.json index 22bb87938e18..a758e4dfc06b 100644 --- a/pkg/tests/apis/dashboard/testdata/searchV0/t03-with-text-panel.json +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t03-with-text-panel.json @@ -14,5 +14,5 @@ } } ], - "maxScore": 1.099 + "maxScore": 1.125 } \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t04-title-ngram-prefix.json b/pkg/tests/apis/dashboard/testdata/searchV0/t04-title-ngram-prefix.json index 4644d878cd08..a73c2e750b95 100644 --- a/pkg/tests/apis/dashboard/testdata/searchV0/t04-title-ngram-prefix.json +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t04-title-ngram-prefix.json @@ -10,8 +10,8 @@ "panel-tests", "graph-ng" ], - "score": 1.02 + "score": 1.041 } ], - "maxScore": 1.02 + "maxScore": 1.041 } \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t05-title-ngram-middle-word.json b/pkg/tests/apis/dashboard/testdata/searchV0/t05-title-ngram-middle-word.json index 4644d878cd08..a73c2e750b95 100644 --- a/pkg/tests/apis/dashboard/testdata/searchV0/t05-title-ngram-middle-word.json +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t05-title-ngram-middle-word.json @@ -10,8 +10,8 @@ "panel-tests", "graph-ng" ], - "score": 1.02 + "score": 1.041 } ], - "maxScore": 1.02 + "maxScore": 1.041 } \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t06-panel-title-orange.json b/pkg/tests/apis/dashboard/testdata/searchV0/t06-panel-title-orange.json index cf3ca1227f93..c6c250fc958b 100644 --- a/pkg/tests/apis/dashboard/testdata/searchV0/t06-panel-title-orange.json +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t06-panel-title-orange.json @@ -26,16 +26,16 @@ "value": 5 }, { - "message": "idf(docFreq=1, maxDocs=16)", - "value": 2.428 + "message": "idf(docFreq=1, maxDocs=17)", + "value": 2.485 }, { "message": "queryNorm", - "value": 0.024 + "value": 0.023 } ], "message": "queryWeight(fields.panel_title:orange^5.000000), product of:", - "value": 0.29 + "value": 0.292 }, { "children": [ @@ -46,20 +46,20 @@ { "children": [ { - "message": "fieldNorm(field=fields.panel_title), b=0.750000, fieldLength=38.000002, avgFieldLength=17.000000)", - "value": 1.926 + "message": "fieldNorm(field=fields.panel_title), b=0.750000, fieldLength=38.000002, avgFieldLength=16.000000)", + "value": 2.031 } ], - "message": "saturation(term:), k1=1.200000/(tf=1.732051 + k1*fieldNorm=1.926471))", - "value": 0.297 + "message": "saturation(term:), k1=1.200000/(tf=1.732051 + k1*fieldNorm=2.031250))", + "value": 0.288 }, { - "message": "idf(docFreq=1, maxDocs=16)", - "value": 2.428 + "message": "idf(docFreq=1, maxDocs=17)", + "value": 2.485 } ], "message": "fieldWeight(fields.panel_title:orange in \u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0001), as per bm25 model, product of:", - "value": 1.248 + "value": 1.239 } ], "message": "weight(fields.panel_title:orange^5.000000 in \u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0001), product of:", @@ -85,4 +85,4 @@ } ], "maxScore": 0.09 -} \ No newline at end of file +} diff --git a/public/app/core/components/TimeSeries/TimeSeries.tsx b/public/app/core/components/TimeSeries/TimeSeries.tsx index 5831f5dee791..bf69ff4f07b6 100644 --- a/public/app/core/components/TimeSeries/TimeSeries.tsx +++ b/public/app/core/components/TimeSeries/TimeSeries.tsx @@ -1,8 +1,10 @@ import { useCallback } from 'react'; import { DataFrame, TimeRange } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { useTheme2 } from '@grafana/ui'; import { hasVisibleLegendSeries, PlotLegend, UPlotConfigBuilder } from '@grafana/ui/internal'; +import { TimeSeriesLegendOptions } from 'app/plugins/panel/timeseries/panelcfg.gen'; import { GraphNG, GraphNGProps, PropDiffFn } from '../GraphNG/GraphNG'; @@ -10,7 +12,9 @@ import { getXAxisConfig, preparePlotConfigBuilder } from './utils'; const propsToDiff: Array = ['legend', 'options', 'annotationLanes', 'theme']; -type TimeSeriesProps = Omit; +type TimeSeriesProps = Omit & { + legend: TimeSeriesLegendOptions; +}; export function TimeSeries(props: TimeSeriesProps) { const { timeZone, options, renderers, tweakAxis, tweakScale, legend, frames } = props; @@ -36,12 +40,13 @@ export function TimeSeries(props: TimeSeriesProps) { ); const renderLegend = useCallback( - (config: UPlotConfigBuilder) => { - if (!config || (legend && !legend.showLegend) || !hasVisibleLegendSeries(config, frames)) { + (uPlotConfig: UPlotConfigBuilder) => { + if (!uPlotConfig || (legend && !legend.showLegend) || !hasVisibleLegendSeries(uPlotConfig, frames)) { return null; } - return ; + const enableFacetedFilter = config.featureToggles.vizLegendFacetedFilter && legend?.enableFacetedFilter; + return ; }, [legend, frames] ); diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx index 7087c9c611ce..d7a75f1ad97f 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx @@ -165,7 +165,10 @@ export class PanelStateWrapper extends PureComponent { this.onFieldConfigChange(changeSeriesColorConfigFactory(label, color, this.props.panel.fieldConfig)); }; - onSeriesVisibilityChange = (label: string, mode: SeriesVisibilityChangeMode) => { + onSeriesVisibilityChange = (label: string | string[] | null, mode: SeriesVisibilityChangeMode) => { + if (typeof label !== 'string') { + return; + } this.onFieldConfigChange( seriesVisibilityConfigFactory(label, mode, this.props.panel.fieldConfig, this.state.data.series) ); diff --git a/public/app/features/explore/Graph/ExploreGraph.tsx b/public/app/features/explore/Graph/ExploreGraph.tsx index d4514cd81092..01ba6d0c4bd1 100644 --- a/public/app/features/explore/Graph/ExploreGraph.tsx +++ b/public/app/features/explore/Graph/ExploreGraph.tsx @@ -180,7 +180,10 @@ export function ExploreGraph({ eventBus, // TODO: Re-enable DashboardCursorSync.Crosshair when #81505 is fixed sync: () => DashboardCursorSync.Off, - onToggleSeriesVisibility(label: string, mode: SeriesVisibilityChangeMode) { + onToggleSeriesVisibility(label: string | string[] | null, mode: SeriesVisibilityChangeMode) { + if (typeof label !== 'string') { + return; + } setFieldConfig(seriesVisibilityConfigFactory(label, mode, fieldConfig, data)); }, }; @@ -208,6 +211,7 @@ export function ExploreGraph({ showLegend: true, placement: 'bottom', calcs: [], + enableFacetedFilter: false, ...vizLegendOverrides, }, }), diff --git a/public/app/plugins/panel/timeseries/module.tsx b/public/app/plugins/panel/timeseries/module.tsx index 11849d200af1..252bcd1b6701 100644 --- a/public/app/plugins/panel/timeseries/module.tsx +++ b/public/app/plugins/panel/timeseries/module.tsx @@ -20,6 +20,19 @@ export const plugin = new PanelPlugin(TimeSeriesPanel) commonOptionsBuilder.addTooltipOptions(builder, false, true, optsWithHideZeros); commonOptionsBuilder.addLegendOptions(builder, true, true, config.featureToggles.vizLegendSeriesLimit); + const legendCategory = [t('timeseries.legend.category', 'Legend')]; + + if (config.featureToggles.vizLegendFacetedFilter) { + builder.addBooleanSwitch({ + path: 'legend.enableFacetedFilter', + name: t('timeseries.legend.name-faceted-filter', 'Faceted filter'), + category: legendCategory, + description: t('timeseries.legend.description-faceted-filter', 'Show series visibility filter based on labels'), + defaultValue: true, + showIf: (c) => c.legend.showLegend, + }); + } + builder.addCustomEditor({ id: 'timezone', name: t('timeseries.name-time-zone', 'Time zone'), diff --git a/public/app/plugins/panel/timeseries/panelcfg.cue b/public/app/plugins/panel/timeseries/panelcfg.cue index ff9784f5f442..4aa5f03382c9 100644 --- a/public/app/plugins/panel/timeseries/panelcfg.cue +++ b/public/app/plugins/panel/timeseries/panelcfg.cue @@ -22,11 +22,15 @@ composableKinds: PanelCfg: lineage: { schemas: [{ version: [0, 0] schema: { + TimeSeriesLegendOptions: { + common.VizLegendOptions + enableFacetedFilter?: bool | *true + } @cuetsy(kind="interface") Options: { common.OptionsWithTimezones common.OptionsWithAnnotations - legend: common.VizLegendOptions + legend: TimeSeriesLegendOptions tooltip: common.VizTooltipOptions timeCompare?: common.TimeCompareOptions orientation?: common.VizOrientation diff --git a/public/app/plugins/panel/timeseries/panelcfg.gen.ts b/public/app/plugins/panel/timeseries/panelcfg.gen.ts index 5ceec84815b1..2d129f3da109 100644 --- a/public/app/plugins/panel/timeseries/panelcfg.gen.ts +++ b/public/app/plugins/panel/timeseries/panelcfg.gen.ts @@ -12,9 +12,17 @@ import * as common from '@grafana/schema'; +export interface TimeSeriesLegendOptions extends common.VizLegendOptions { + enableFacetedFilter?: boolean; +} + +export const defaultTimeSeriesLegendOptions: Partial = { + enableFacetedFilter: true, +}; + export interface Options extends common.OptionsWithTimezones, common.OptionsWithAnnotations { disableKeyboardEvents?: boolean; - legend: common.VizLegendOptions; + legend: TimeSeriesLegendOptions; orientation?: common.VizOrientation; timeCompare?: common.TimeCompareOptions; tooltip: common.VizTooltipOptions; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 5b4758cd680e..e610612f2ce3 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -10237,7 +10237,15 @@ "remove-button": "Remove {{children}}" }, "viz-legend": { - "right-axis-indicator": "(right y-axis)" + "clear-filters": "Clear all", + "faceted-by-labels": "By labels", + "faceted-by-name": "By name", + "faceted-deselect-all": "Deselect all", + "faceted-select-all": "Select all", + "pin-filter": "Pin to sidebar", + "right-axis-indicator": "(right y-axis)", + "series-visibility": "Series visibility", + "unpin-sidebar": "Unpin" }, "viz-tooltip": { "actions-confirmation-input-placeholder": "Are you sure you want to {{ actionTitle }}?", @@ -14927,6 +14935,11 @@ "label-threshold": "Threshold" } }, + "legend": { + "category": "Legend", + "description-faceted-filter": "Show series visibility filter based on labels", + "name-faceted-filter": "Faceted filter" + }, "line-style-editor": { "line-fill-options": { "label-dash": "Dash", diff --git a/yarn.lock b/yarn.lock index 22199b0a9171..386e179ce508 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3890,13 +3890,13 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:7.1.1": - version: 7.1.1 - resolution: "@grafana/scenes-react@npm:7.1.1" +"@grafana/scenes-react@npm:7.1.5": + version: 7.1.5 + resolution: "@grafana/scenes-react@npm:7.1.5" dependencies: "@emotion/css": "npm:11.10.5" "@emotion/react": "npm:11.10.5" - "@grafana/scenes": "npm:7.1.1" + "@grafana/scenes": "npm:7.1.5" lodash: "npm:^4.17.21" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" @@ -3910,7 +3910,7 @@ __metadata: react-dom: ^18.0.0 react-router-dom: ^6.28.0 rxjs: ^7.8.1 - checksum: 10/b1e8fea520aa36e9ac38fec5a7ff5de1e5b74380c3e9ddcafc64c69131d6c3327d621e51b4983998ccf6d90ec0920a9bf09ba96925e6bf30fd98555faa677f28 + checksum: 10/e922dbbb7d41d085b5c101833dc712f4233cf8b3077a5fe6ae357cf5cfc045b4311a278297a6f68f1a5629428a77abaec3dd71cf6372a65d4030ba20e370c1b9 languageName: node linkType: hard @@ -3945,6 +3945,37 @@ __metadata: languageName: node linkType: hard +"@grafana/scenes@npm:7.1.5": + version: 7.1.5 + resolution: "@grafana/scenes@npm:7.1.5" + dependencies: + "@emotion/css": "npm:11.10.5" + "@emotion/react": "npm:11.10.5" + "@floating-ui/react": "npm:^0.26.16" + "@leeoniya/ufuzzy": "npm:^1.0.16" + "@tanstack/react-virtual": "npm:^3.9.0" + history: "npm:^4.9.0" + lodash: "npm:^4.17.21" + react-grid-layout: "npm:^1.3.4" + react-select: "npm:^5.10.2" + react-use: "npm:^17.5.0" + react-virtualized-auto-sizer: "npm:^1.0.24" + uuid: "npm:^9.0.0" + peerDependencies: + "@grafana/data": ">=11.6" + "@grafana/e2e-selectors": ">=11.6" + "@grafana/i18n": "*" + "@grafana/runtime": ">=11.6" + "@grafana/schema": ">=11.6" + "@grafana/ui": ">=11.6" + react: ^18.0.0 + react-dom: ^18.0.0 + react-router-dom: ^6.28.0 + rxjs: ^7.8.1 + checksum: 10/8dbbced96964dce8afcb6f0ecb34231a1746c647ef938399d277679f71704017031f915bee0d2147ba3da438ed929cfc6edab3ef9a3b515a47f0cdba704e8f71 + languageName: node + linkType: hard + "@grafana/schema@npm:13.0.0-pre, @grafana/schema@workspace:*, @grafana/schema@workspace:packages/grafana-schema": version: 0.0.0-use.local resolution: "@grafana/schema@workspace:packages/grafana-schema" @@ -20245,8 +20276,8 @@ __metadata: "@grafana/plugin-ui": "npm:^0.13.1" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" - "@grafana/scenes": "npm:7.1.1" - "@grafana/scenes-react": "npm:7.1.1" + "@grafana/scenes": "npm:7.1.5" + "@grafana/scenes-react": "npm:7.1.5" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/test-utils": "workspace:*"