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
This commit is contained in:
Dominik Prokop
2026-03-12 15:57:58 +01:00
committed by GitHub
parent aae6b52020
commit 91a0b312ed
41 changed files with 1271 additions and 97 deletions
@@ -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",
@@ -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
}
+1
View File
@@ -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'),
@@ -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();
});
});
+1
View File
@@ -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',
+2 -2
View File
@@ -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:*",
+10 -1
View File
@@ -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';
+5
View File
@@ -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
*/
+131 -1
View File
@@ -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'));
});
});
+82
View File
@@ -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<string, string[]> {
const valuesByKey: Record<string, Set<string>> = {};
const fieldNames = new Set<string>();
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<string>()).add(value);
}
}
}
}
const result: Record<string, string[]> = {};
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<string, string[]>,
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.
*/
@@ -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<TimeSeriesLegendOptions> = {
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;
@@ -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;
@@ -7,6 +7,7 @@
export enum SeriesVisibilityChangeMode {
ToggleSelection = 'select',
AppendToSelection = 'append',
SetExactly = 'setExactly',
}
export type OnSelectRangeCallback = (selections: RangeSelection2D[]) => void;
@@ -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]);
@@ -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<FacetedLabelsFilterProps> = {}) {
const props: FacetedLabelsFilterProps = {
labels: {
[FIELD_NAME_FACET_KEY]: ['cpu', 'mem'],
host: ['a', 'b'],
region: ['eu', 'us'],
},
selected: {},
onChange: jest.fn(),
...overrides,
};
return { ...render(<FacetedLabelsFilter {...props} />), 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);
});
});
@@ -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<string, string[]>;
/** Currently selected label values, keyed by label key */
selected: Record<string, string[]>;
/** Called when the selection changes */
onChange: (selected: Record<string, string[]>) => 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<Record<string, boolean>>({});
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 (
<div className={className}>
{values.map((value) => (
<Checkbox
key={value}
label={value}
value={selectedValues.includes(value)}
onChange={() => toggleValue(key, value)}
className={styles.checkbox}
/>
))}
<button type="button" className={styles.toggleAll} onClick={() => toggleAllForKey(key)}>
{selectedValues.length === values.length ? (
<Trans i18nKey="grafana-ui.viz-legend.faceted-deselect-all">Deselect all</Trans>
) : (
<Trans i18nKey="grafana-ui.viz-legend.faceted-select-all">Select all</Trans>
)}
</button>
</div>
);
};
return (
<div className={cx(styles.container, dimmed && styles.dimmed)} data-testid="faceted-labels-filter">
{seriesValues && (
<div className={styles.section}>
<span className={styles.sectionLabel}>
<Trans i18nKey="grafana-ui.viz-legend.faceted-by-name">By name</Trans>
</span>
{renderCheckboxList(FIELD_NAME_FACET_KEY, seriesValues, styles.checkboxList)}
</div>
)}
{realLabelKeys.length > 0 && (
<div className={styles.section}>
<span className={styles.sectionLabel}>
<Trans i18nKey="grafana-ui.viz-legend.faceted-by-labels">By labels</Trans>
</span>
{realLabelKeys.map((key) => {
const selectedValues = selected[key] ?? [];
const isExpanded = expandedKeys[key] ?? false;
return (
<div key={key} className={styles.labelGroup}>
<button type="button" className={styles.labelKey} onClick={() => toggleExpanded(key)}>
<Icon name={isExpanded ? 'angle-down' : 'angle-right'} size="sm" />
<span className={styles.keyName}>{key}</span>
{selectedValues.length > 0 && <span className={styles.count}>{selectedValues.length}</span>}
</button>
{isExpanded && renderCheckboxList(key, labels[key], styles.checkboxListIndented)}
</div>
);
})}
</div>
)}
</div>
);
}
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,
}),
});
@@ -32,6 +32,7 @@ export function VizLegend<T>({
readonly,
isSortable,
limit,
filterAction,
}: LegendProps<T>) {
const { eventBus, onToggleSeriesVisibility, onToggleLegendSort } = usePanelContext();
@@ -101,10 +102,11 @@ export function VizLegend<T>({
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<T>({
readonly={readonly}
isSortable={isSortable}
limit={limit}
filterAction={filterAction}
/>
);
case LegendDisplayMode.List:
@@ -25,6 +25,7 @@ export const VizLegendList = <T extends unknown>({
className,
readonly,
limit = 0,
filterAction,
}: Props<T>) => {
const styles = useStyles2(getStyles);
@@ -41,7 +42,6 @@ export const VizLegendList = <T extends unknown>({
);
}
// 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 = <T extends unknown>({
return (
<div className={cx(styles.rightWrapper, className)}>
{filterAction && <span className={styles.itemRight}>{filterAction}</span>}
<List items={leftItems} renderItem={renderItem} getItemKey={getItemKey} limit={limit} />
</div>
);
@@ -75,11 +76,13 @@ export const VizLegendList = <T extends unknown>({
<div className={cx(styles.bottomWrapper, className)}>
{leftItems.length > 0 && (
<div className={styles.section}>
{filterAction && <span className={styles.itemBottom}>{filterAction}</span>}
<InlineList items={leftItems} renderItem={renderItem} getItemKey={getItemKey} limit={limit} />
</div>
)}
{rightItems.length > 0 && (
<div className={cx(styles.section, styles.sectionRight)}>
{!leftItems.length && filterAction && <span className={styles.itemBottom}>{filterAction}</span>}
<InlineList items={rightItems} renderItem={renderItem} getItemKey={getItemKey} limit={limit} />
</div>
)}
@@ -120,6 +123,7 @@ const getStyles = (theme: GrafanaTheme2) => {
}),
section: css({
display: 'flex',
flexWrap: 'wrap',
}),
sectionRight: css({
justifyContent: 'flex-end',
@@ -31,6 +31,7 @@ export const VizLegendTable = <T extends unknown>({
readonly,
isSortable,
limit = 0,
filterAction,
}: VizLegendTableProps<T>): JSX.Element => {
const styles = useStyles2(getStyles);
const header: Record<string, string> = {
@@ -59,12 +60,10 @@ export const VizLegendTable = <T extends unknown>({
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 = <T extends unknown>({
}
}}
>
{columnTitle === nameSortKey && filterAction && (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<span className={styles.filterAction} onClick={(e) => e.stopPropagation()}>
{filterAction}
</span>
)}
{columnTitle}
{sortKey === columnTitle && <Icon size="xs" name={sortDesc ? 'angle-down' : 'angle-up'} />}
</th>
@@ -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',
}),
});
@@ -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<T> {
) => void;
readonly?: boolean;
limit?: number;
filterAction?: ReactNode;
}
export interface VizLegendTableProps<T> extends VizLegendBaseProps<T> {
@@ -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<typeof PlotLegend> = {
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<React.ComponentProps<typeof PlotLegend>> = {}) {
const toggle = jest.fn();
return {
toggle,
...render(
<PanelContextProvider
value={{ eventsScope: 'test', eventBus: { publish: jest.fn() } as never, onToggleSeriesVisibility: toggle }}
>
<PlotLegend {...defaultProps} {...overrides} />
</PanelContextProvider>
),
};
}
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);
});
});
@@ -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<VizLayoutLegendProps, 'children'> {
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<VizLegendItem | undefined>((s) => {
const seriesConfig = s.props;
const fieldIndex = seriesConfig.dataFrameFieldIndex;
const axisPlacement = config.getAxisPlacement(s.props.scaleKey);
const [selectedLabels, setSelectedLabels] = useState<Record<string, string[]>>({});
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<VizLegendItem | undefined>((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<string, string[]>) => {
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 ? (
<FacetedLabelsFilter
labels={facetedLabels}
selected={selectedLabels}
onChange={handleLabelsChange}
dimmed={legendHasPrecedence}
/>
) : null;
const filterToggle = facetedFilter ? (
<Toggletip
content={
<div className={styles.filterPopoverContent}>
{facetedFilter}
<div className={styles.filterPopoverFooter}>
<Button variant="secondary" size="sm" icon="gf-pin" onClick={handleToggleFilterDock}>
{t('grafana-ui.viz-legend.pin-filter', 'Pin to sidebar')}
</Button>
{hasActiveFilters && (
<Button variant="secondary" size="sm" icon="times" onClick={handleClearFilters}>
{t('grafana-ui.viz-legend.clear-filters', 'Clear all')}
</Button>
)}
</div>
</div>
}
placement="bottom-start"
fitContent
dismissOnScroll
>
<IconButton
name="eye"
size="md"
tooltip={t('grafana-ui.viz-legend.series-visibility', 'Series visibility')}
variant={hasActiveFilters ? 'primary' : undefined}
data-testid="faceted-labels-filter-toggle"
/>
</Toggletip>
) : null;
const legend = (
<VizLegend
placement={placement}
items={legendItems}
displayMode={displayMode}
sortBy={vizLayoutLegendProps.sortBy}
sortDesc={vizLayoutLegendProps.sortDesc}
isSortable={true}
limit={limit}
filterAction={!filterDocked ? filterToggle : undefined}
/>
);
if (filterDocked && facetedFilter) {
return (
<VizLayout.Legend placement={placement} {...vizLayoutLegendProps}>
<VizLegend
placement={placement}
items={legendItems}
displayMode={displayMode}
sortBy={vizLayoutLegendProps.sortBy}
sortDesc={vizLayoutLegendProps.sortDesc}
isSortable={true}
limit={limit}
/>
<div className={styles.legendWithFilter}>
<div className={styles.filterContent}>
<div className={styles.filterDockedActions}>
{hasActiveFilters && (
<IconButton
name="filter-minus"
size="sm"
tooltip={t('grafana-ui.viz-legend.clear-filters', 'Clear all')}
onClick={handleClearFilters}
/>
)}
<IconButton
name="times"
size="sm"
tooltip={t('grafana-ui.viz-legend.unpin-sidebar', 'Unpin')}
onClick={handleToggleFilterDock}
/>
</div>
{facetedFilter}
</div>
<div className={styles.legendContent}>{legend}</div>
</div>
</VizLayout.Legend>
);
}
);
return (
<VizLayout.Legend placement={placement} {...vizLayoutLegendProps}>
{legend}
</VizLayout.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),
}),
});
+8
View File
@@ -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)",
+1
View File
@@ -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
1 Created Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
275 2025-12-01 externalVizSuggestions experimental @grafana/dataviz-squad false false true
276 2026-03-02 vizLegendSeriesLimit experimental @grafana/dataviz-squad false false true
277 2026-03-06 nestedFramesFieldOverrides preview @grafana/dataviz-squad false false true
278 2026-03-10 vizLegendFacetedFilter experimental @grafana/dataviz-squad false false true
279 2025-12-18 heatmapRowsAxisOptions experimental @grafana/dataviz-squad false false true
280 2025-10-17 preventPanelChromeOverflow preview @grafana/grafana-frontend-platform false false true
281 2025-10-31 jaegerEnableGrpcEndpoint experimental @grafana/oss-big-tent false false false
+14
View File
@@ -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",
+1 -1
View File
@@ -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 {
+10 -1
View File
@@ -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",
@@ -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
}
@@ -10,8 +10,8 @@
"panel-tests",
"graph-ng"
],
"score": 0.052
"score": 0.053
}
],
"maxScore": 0.052
"maxScore": 0.053
}
@@ -14,5 +14,5 @@
}
}
],
"maxScore": 1.099
"maxScore": 1.125
}
@@ -10,8 +10,8 @@
"panel-tests",
"graph-ng"
],
"score": 1.02
"score": 1.041
}
],
"maxScore": 1.02
"maxScore": 1.041
}
@@ -10,8 +10,8 @@
"panel-tests",
"graph-ng"
],
"score": 1.02
"score": 1.041
}
],
"maxScore": 1.02
"maxScore": 1.041
}
@@ -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
}
}
@@ -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<string | PropDiffFn> = ['legend', 'options', 'annotationLanes', 'theme'];
type TimeSeriesProps = Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend' | 'theme'>;
type TimeSeriesProps = Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend' | 'theme' | 'legend'> & {
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 <PlotLegend data={frames} config={config} {...legend} />;
const enableFacetedFilter = config.featureToggles.vizLegendFacetedFilter && legend?.enableFacetedFilter;
return <PlotLegend data={frames} config={uPlotConfig} {...legend} enableFacetedFilter={enableFacetedFilter} />;
},
[legend, frames]
);
@@ -165,7 +165,10 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
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)
);
@@ -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,
},
}),
@@ -20,6 +20,19 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(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'),
@@ -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
+9 -1
View File
@@ -12,9 +12,17 @@
import * as common from '@grafana/schema';
export interface TimeSeriesLegendOptions extends common.VizLegendOptions {
enableFacetedFilter?: boolean;
}
export const defaultTimeSeriesLegendOptions: Partial<TimeSeriesLegendOptions> = {
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;
+14 -1
View File
@@ -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",
+38 -7
View File
@@ -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:*"