From 310662a4d070464c335d5eb4755b5fee5985f7fa Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 8 Dec 2025 10:27:40 +0000 Subject: [PATCH 01/21] QueryEditor: Expose `aria-labelledby` prop, reenable storybook a11y tests (#114925) * expose aria-label prop, reenable storybook a11y tests * extract translations * expose aria-labelledby instead --- eslint-suppressions.json | 7 +------ .../components/QueryField/QueryField.story.tsx | 16 +++++++++++++--- .../src/components/QueryField/QueryField.tsx | 4 +++- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index f90a21cb7c5..9ecdfaed054 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -710,11 +710,6 @@ "count": 1 } }, - "packages/grafana-ui/src/components/QueryField/QueryField.story.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, "packages/grafana-ui/src/components/QueryField/QueryField.tsx": { "react-prefer-function-component/react-prefer-function-component": { "count": 1 @@ -4677,4 +4672,4 @@ "count": 1 } } -} +} \ No newline at end of file diff --git a/packages/grafana-ui/src/components/QueryField/QueryField.story.tsx b/packages/grafana-ui/src/components/QueryField/QueryField.story.tsx index f0f672fa120..24aaee5af3a 100644 --- a/packages/grafana-ui/src/components/QueryField/QueryField.story.tsx +++ b/packages/grafana-ui/src/components/QueryField/QueryField.story.tsx @@ -1,6 +1,9 @@ import { Meta, StoryFn } from '@storybook/react'; +import { useId } from 'react'; import { TypeaheadInput } from '../../types/completion'; +import { Field } from '../Forms/Field'; +import { Label } from '../Forms/Label'; import { QueryField, QueryFieldProps } from './QueryField'; @@ -24,8 +27,6 @@ const meta: Meta = { 'syntaxLoaded', ], }, - // TODO fix a11y issue in story and remove this - a11y: { test: 'off' }, }, argTypes: { query: { @@ -34,7 +35,16 @@ const meta: Meta = { }, }; -export const Basic: StoryFn = (args: Omit) => ; +export const Basic: StoryFn = (args: Omit) => { + const id = useId(); + // have to manually set an id on the label + // can't use htmlFor as QueryField is a contenteditable div, not an input + return ( + Query field}> + + + ); +}; Basic.args = { onTypeahead: async (_input: TypeaheadInput) => ({ diff --git a/packages/grafana-ui/src/components/QueryField/QueryField.tsx b/packages/grafana-ui/src/components/QueryField/QueryField.tsx index b9cd4fcd67c..a0583ee7121 100644 --- a/packages/grafana-ui/src/components/QueryField/QueryField.tsx +++ b/packages/grafana-ui/src/components/QueryField/QueryField.tsx @@ -25,6 +25,7 @@ import { makeValue, SCHEMA } from '../../utils/slate'; export interface QueryFieldProps extends Themeable2 { additionalPlugins?: Plugin[]; + ['aria-labelledby']?: string; cleanText?: (text: string) => string; disabled?: boolean; // We have both value and local state. This is usually an antipattern but we need to keep local state @@ -201,7 +202,7 @@ export class UnThemedQueryField extends PureComponent { this.editor = editor; }} + aria-labelledby={ariaLabelledby} schema={SCHEMA} autoCorrect={false} readOnly={this.props.disabled} From dab64addf8eeb66d216cca335a4370180f8c47b3 Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Mon, 8 Dec 2025 11:27:55 +0100 Subject: [PATCH 02/21] SchemaV2: Always set ad hoc filters to empty array when converting to v2 (#114914) * Always set ad hoc filters to empty array when converting to v2 * Update test files --- ...ha1.elasticsearch_complex.v42.v2alpha1.json | 4 ++-- ...pha1.elasticsearch_complex.v42.v2beta1.json | 4 ++-- ...alpha1.influxdb-templated.v42.v2alpha1.json | 4 ++-- ...0alpha1.influxdb-templated.v42.v2beta1.json | 4 ++-- .../v0alpha1.loki_fakedata.v42.v2alpha1.json | 4 ++-- .../v0alpha1.loki_fakedata.v42.v2beta1.json | 4 ++-- .../v0alpha1.table_footer.v42.v2alpha1.json | 2 +- .../v0alpha1.table_footer.v42.v2beta1.json | 2 +- ...alpha1.table_kitchen_sink.v42.v2alpha1.json | 2 +- ...0alpha1.table_kitchen_sink.v42.v2beta1.json | 2 +- ...a1.table_v12_2_migrations.v42.v2alpha1.json | 2 +- ...ha1.table_v12_2_migrations.v42.v2beta1.json | 2 +- .../conversion/v1beta1_to_v2alpha1.go | 18 +++++++++++++++--- 13 files changed, 33 insertions(+), 21 deletions(-) diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-elasticsearch/v0alpha1.elasticsearch_complex.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-elasticsearch/v0alpha1.elasticsearch_complex.v42.v2alpha1.json index 4ba14d53c0c..253b6471087 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-elasticsearch/v0alpha1.elasticsearch_complex.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-elasticsearch/v0alpha1.elasticsearch_complex.v42.v2alpha1.json @@ -7234,9 +7234,9 @@ "type": "elasticsearch", "uid": "gdev-elasticsearch" }, - "baseFilters": null, + "baseFilters": [], "filters": [], - "defaultKeys": null, + "defaultKeys": [], "hide": "dontHide", "skipUrlSync": false, "allowCustomValue": true diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-elasticsearch/v0alpha1.elasticsearch_complex.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-elasticsearch/v0alpha1.elasticsearch_complex.v42.v2beta1.json index 7de7561d8af..54edeecb540 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-elasticsearch/v0alpha1.elasticsearch_complex.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-elasticsearch/v0alpha1.elasticsearch_complex.v42.v2beta1.json @@ -7335,9 +7335,9 @@ }, "spec": { "name": "adhoc", - "baseFilters": null, + "baseFilters": [], "filters": [], - "defaultKeys": null, + "defaultKeys": [], "hide": "dontHide", "skipUrlSync": false, "allowCustomValue": true diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-influxdb/v0alpha1.influxdb-templated.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-influxdb/v0alpha1.influxdb-templated.v42.v2alpha1.json index c04c33b8edb..3b776907f83 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-influxdb/v0alpha1.influxdb-templated.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-influxdb/v0alpha1.influxdb-templated.v42.v2alpha1.json @@ -307,9 +307,9 @@ "kind": "AdhocVariable", "spec": { "name": "adhoc", - "baseFilters": null, + "baseFilters": [], "filters": [], - "defaultKeys": null, + "defaultKeys": [], "hide": "dontHide", "skipUrlSync": false, "allowCustomValue": true diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-influxdb/v0alpha1.influxdb-templated.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-influxdb/v0alpha1.influxdb-templated.v42.v2beta1.json index 731506b101b..a6ebf10b8f0 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-influxdb/v0alpha1.influxdb-templated.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-influxdb/v0alpha1.influxdb-templated.v42.v2beta1.json @@ -317,9 +317,9 @@ "datasource": {}, "spec": { "name": "adhoc", - "baseFilters": null, + "baseFilters": [], "filters": [], - "defaultKeys": null, + "defaultKeys": [], "hide": "dontHide", "skipUrlSync": false, "allowCustomValue": true diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_fakedata.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_fakedata.v42.v2alpha1.json index 495d5ae8ced..d8a362c5029 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_fakedata.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_fakedata.v42.v2alpha1.json @@ -604,9 +604,9 @@ "type": "loki", "uid": "PDDA8E780A17E7EF1" }, - "baseFilters": null, + "baseFilters": [], "filters": [], - "defaultKeys": null, + "defaultKeys": [], "label": "Ad-hoc", "hide": "dontHide", "skipUrlSync": false, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_fakedata.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_fakedata.v42.v2beta1.json index 0a0142cb3ed..029c2bcf218 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_fakedata.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/datasource-loki/v0alpha1.loki_fakedata.v42.v2beta1.json @@ -616,9 +616,9 @@ }, "spec": { "name": "adhoc", - "baseFilters": null, + "baseFilters": [], "filters": [], - "defaultKeys": null, + "defaultKeys": [], "label": "Ad-hoc", "hide": "dontHide", "skipUrlSync": false, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.v2alpha1.json index bf4adacd2bc..2697100f4cd 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.v2alpha1.json @@ -1885,7 +1885,7 @@ "name": "Filters", "baseFilters": [], "filters": [], - "defaultKeys": null, + "defaultKeys": [], "hide": "dontHide", "skipUrlSync": false, "allowCustomValue": true diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.v2beta1.json index 89f22b62bf0..ec3f1d1ed5c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_footer.v42.v2beta1.json @@ -1931,7 +1931,7 @@ "name": "Filters", "baseFilters": [], "filters": [], - "defaultKeys": null, + "defaultKeys": [], "hide": "dontHide", "skipUrlSync": false, "allowCustomValue": true diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.v2alpha1.json index c06c7e81260..d2ec399b434 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.v2alpha1.json @@ -2184,7 +2184,7 @@ "name": "Filters", "baseFilters": [], "filters": [], - "defaultKeys": null, + "defaultKeys": [], "hide": "dontHide", "skipUrlSync": false, "allowCustomValue": true diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.v2beta1.json index 4b98f1b4c55..a2ea184c554 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_kitchen_sink.v42.v2beta1.json @@ -2219,7 +2219,7 @@ "name": "Filters", "baseFilters": [], "filters": [], - "defaultKeys": null, + "defaultKeys": [], "hide": "dontHide", "skipUrlSync": false, "allowCustomValue": true diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.v2alpha1.json index de3fd99993b..b8e6725ca5a 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.v2alpha1.json @@ -2499,7 +2499,7 @@ "name": "Filters", "baseFilters": [], "filters": [], - "defaultKeys": null, + "defaultKeys": [], "hide": "dontHide", "skipUrlSync": false, "allowCustomValue": true diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.v2beta1.json index fb856d2bdea..4a7ea3bcf33 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-table/v0alpha1.table_v12_2_migrations.v42.v2beta1.json @@ -2542,7 +2542,7 @@ "name": "Filters", "baseFilters": [], "filters": [], - "defaultKeys": null, + "defaultKeys": [], "hide": "dontHide", "skipUrlSync": false, "allowCustomValue": true diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index 231c0ad4131..236a4337efb 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -1539,26 +1539,38 @@ func buildAdhocVariable(ctx context.Context, varMap map[string]interface{}, comm }, } - // Transform baseFilters if they exist + // Transform baseFilters if they exist, otherwise default to empty array if baseFilters, exists := varMap["baseFilters"]; exists { if baseFiltersArray, ok := baseFilters.([]interface{}); ok { adhocVar.Spec.BaseFilters = transformAdHocFilters(baseFiltersArray) } } + // Ensure baseFilters is always set (default to empty array if not present or invalid) + if adhocVar.Spec.BaseFilters == nil { + adhocVar.Spec.BaseFilters = []dashv2alpha1.DashboardAdHocFilterWithLabels{} + } - // Transform filters if they exist + // Transform filters if they exist, otherwise default to empty array if filters, exists := varMap["filters"]; exists { if filtersArray, ok := filters.([]interface{}); ok { adhocVar.Spec.Filters = transformAdHocFilters(filtersArray) } } + // Ensure filters is always set (default to empty array if not present or invalid) + if adhocVar.Spec.Filters == nil { + adhocVar.Spec.Filters = []dashv2alpha1.DashboardAdHocFilterWithLabels{} + } - // Transform defaultKeys if they exist + // Transform defaultKeys if they exist, otherwise default to empty array if defaultKeys, exists := varMap["defaultKeys"]; exists { if defaultKeysArray, ok := defaultKeys.([]interface{}); ok { adhocVar.Spec.DefaultKeys = transformMetricFindValues(defaultKeysArray) } } + // Ensure defaultKeys is always set (default to empty array if not present or invalid) + if adhocVar.Spec.DefaultKeys == nil { + adhocVar.Spec.DefaultKeys = []dashv2alpha1.DashboardMetricFindValue{} + } // Only include datasource if datasourceUID exists (matching frontend behavior) if datasourceUID != "" { From 7c5457a75edcd8afb06921a50630184928415970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 8 Dec 2025 11:36:34 +0100 Subject: [PATCH 03/21] e2e: update @grafana/plugin-e2e version (#114958) --- package.json | 2 +- yarn.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 5d7935110eb..e9ce0640360 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "@emotion/eslint-plugin": "11.12.0", "@grafana/eslint-config": "8.2.0", "@grafana/eslint-plugin": "link:./packages/grafana-eslint-rules", - "@grafana/plugin-e2e": "^3.0.1", + "@grafana/plugin-e2e": "^3.0.3", "@grafana/test-utils": "workspace:*", "@manypkg/get-packages": "^3.0.0", "@npmcli/package-json": "^6.0.0", diff --git a/yarn.lock b/yarn.lock index a9d3d1732c9..057e3613d68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3445,17 +3445,17 @@ __metadata: languageName: unknown linkType: soft -"@grafana/plugin-e2e@npm:^3.0.1": - version: 3.0.1 - resolution: "@grafana/plugin-e2e@npm:3.0.1" +"@grafana/plugin-e2e@npm:^3.0.3": + version: 3.0.3 + resolution: "@grafana/plugin-e2e@npm:3.0.3" dependencies: - "@grafana/e2e-selectors": "npm:^12.2.0-255920" + "@grafana/e2e-selectors": "npm:^12.4.0-19890644192" semver: "npm:^7.5.4" uuid: "npm:^13.0.0" yaml: "npm:^2.3.4" peerDependencies: "@playwright/test": ^1.52.0 - checksum: 10/1cd95c1e4365f93e884d7ae4d77961e038cd4fcc235e313dd9f96bd0e21090c5aa26a765bedf45cc13cc0dc913da79007172fd9680088b356ef485672ce34681 + checksum: 10/d9247d52cc5b65c91983212790610b0c2470d705fa4e37178d04cbf681c4fc80bb08b2c39ea6f0061cb0d78242f108a5c8e6fa1ad2f2209d6f15d34000cb1d00 languageName: node linkType: hard @@ -19437,7 +19437,7 @@ __metadata: "@grafana/llm": "npm:0.22.1" "@grafana/monaco-logql": "npm:^0.0.8" "@grafana/o11y-ds-frontend": "workspace:*" - "@grafana/plugin-e2e": "npm:^3.0.1" + "@grafana/plugin-e2e": "npm:^3.0.3" "@grafana/plugin-ui": "npm:^0.11.1" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" From 5a8a730cfed856417189ba40ad59f9f4d3a998cf Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Mon, 8 Dec 2025 12:09:56 +0100 Subject: [PATCH 04/21] UI Extensions: Add `openInNewTab` property to link extensions (#114831) feat: add `openInNewTab` property for link extensions --- packages/grafana-data/src/types/pluginExtensions.ts | 7 +++++++ .../plugins/extensions/registry/AddedLinksRegistry.test.ts | 2 ++ .../plugins/extensions/registry/AddedLinksRegistry.ts | 4 +++- .../features/plugins/extensions/usePluginLinks.test.tsx | 3 +++ public/app/features/plugins/extensions/usePluginLinks.tsx | 2 ++ public/app/features/plugins/extensions/utils.tsx | 3 +++ 6 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 40d20b88c38..cc2a97b4a50 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -30,6 +30,7 @@ export type PluginExtensionLink = PluginExtensionBase & { onClick?: (event?: React.MouseEvent) => void; icon?: IconName; category?: string; + openInNewTab?: boolean; }; export type PluginExtensionComponentMeta = Omit; @@ -86,6 +87,7 @@ export type PluginExtensionAddedComponentConfig = PluginExtensionCon */ component: React.ComponentType; }; + export type PluginExtensionAddedFunctionConfig = PluginExtensionConfigBase & { /** * The target extension points where the component will be added @@ -106,6 +108,7 @@ export type PluginAddedLinksConfigureFunc = (context: Re onClick: (event: React.MouseEvent | undefined, helpers: PluginExtensionEventHelpers) => void; icon: IconName; category: string; + openInNewTab: boolean; }> | undefined; @@ -137,6 +140,10 @@ export type PluginExtensionAddedLinkConfig = Pl // (Optional) A category to be used when grouping the options in the ui category?: string; + + // (Optional) If true, opens the link in a new tab (renders with target="_blank") + // (Important: this is not guaranteed, depends on the extension point if it implements it.) + openInNewTab?: boolean; }; export type PluginExtensionExposedComponentConfig = PluginExtensionConfigBase & { diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts index 63ab6b55090..07574745d71 100644 --- a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.test.ts @@ -95,6 +95,7 @@ describe('AddedLinksRegistry', () => { path: `/a/${pluginId}/declare-incident`, targets: 'plugins/myorg-basic-app/start', configure: jest.fn().mockImplementation((context) => ({ title: context?.title })), + openInNewTab: true, }, ], }); @@ -120,6 +121,7 @@ describe('AddedLinksRegistry', () => { path: `/a/${pluginId}/declare-incident`, extensionPointId: 'plugins/myorg-basic-app/start', configure: expect.any(Function), + openInNewTab: true, }, ], }); diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts index b3f293675ce..c3dd9faf6f1 100644 --- a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts @@ -21,6 +21,7 @@ export type AddedLinkRegistryItem = { configure?: PluginAddedLinksConfigureFunc; icon?: IconName; category?: string; + openInNewTab?: boolean; }; export class AddedLinksRegistry extends Registry { @@ -40,13 +41,14 @@ export class AddedLinksRegistry extends Registry { title: '2', description: '2', path: `/a/${pluginId}/2`, + openInNewTab: true, }, { targets: 'plugins/another-extension/v1', @@ -181,7 +182,9 @@ describe('usePluginLinks()', () => { expect(result.current.links.length).toBe(2); expect(result.current.links[0].title).toBe('1'); + expect(result.current.links[0].openInNewTab).toBeUndefined(); expect(result.current.links[1].title).toBe('2'); + expect(result.current.links[1].openInNewTab).toBe(true); }); it('should dynamically update the extensions registered for a certain extension point', () => { diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx index 70518ab9450..9ec5d215f94 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.tsx @@ -51,6 +51,7 @@ export function usePluginLinks({ title: addedLink.title, description: addedLink.description ?? '', onClick: typeof addedLink.onClick, + openInNewTab: addedLink.openInNewTab ? 'true' : 'false', }); // Only limit if the `limitPerPlugin` is set @@ -84,6 +85,7 @@ export function usePluginLinks({ description: overrides?.description || addedLink.description || '', path: isString(path) ? getLinkExtensionPathWithTracking(pluginId, path, extensionPointId) : undefined, category: overrides?.category || addedLink.category, + openInNewTab: overrides?.openInNewTab ?? addedLink.openInNewTab, }; extensions.push(extension); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index 9bc9171a49f..adb1ed8616d 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -474,12 +474,14 @@ export function getLinkExtensionOverrides( return undefined; } + // Only allowing to override the following properties let { title = config.title, description = config.description, path = config.path, icon = config.icon, category = config.category, + openInNewTab = config.openInNewTab, ...rest } = overrides; @@ -504,6 +506,7 @@ export function getLinkExtensionOverrides( path, icon, category, + openInNewTab, }; } catch (error) { if (error instanceof Error) { From 5bd73f32640aa152c6a07ce5bb3c831731d0c67d Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 8 Dec 2025 11:11:50 +0000 Subject: [PATCH 05/21] Icon: Use empty `` as fallback to prevent layout shift (#114901) render an empty `` instead to better match styles in other components --- packages/grafana-ui/src/components/Icon/Icon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grafana-ui/src/components/Icon/Icon.tsx b/packages/grafana-ui/src/components/Icon/Icon.tsx index fa4e1fac6d3..fae4b284a01 100644 --- a/packages/grafana-ui/src/components/Icon/Icon.tsx +++ b/packages/grafana-ui/src/components/Icon/Icon.tsx @@ -93,7 +93,7 @@ export const Icon = React.memo( // this prevents content layout shift whilst the icon asynchronously loads // which happens even if the icon is in the cache(!) loader={ - Date: Mon, 8 Dec 2025 15:02:59 +0200 Subject: [PATCH 06/21] IAM: Add search for teams in app platform (#113503) * add legacy search (wip) * fix search field name * implement team search endpoint * generate openapi spec * generate endpoints for frontend * minor fixes * fix issues found while testing * add more fields to search result * add basic unit tests * add more unit tests * improve getColumns() func in legacy search * configure search endpoint in team.cue * add team search handler * add the searchTeams endpoint to manifest.cue * make gofmt * update openapi spec * generate frontend endpoints * remove unused field * move fields defiitions to separate builder * fix legacy search * fix unit tests * fix unit test * address feedback * fix unit test * update openapi specs * yarn generate-apis * add missing unit tests --- apps/iam/kinds/manifest.cue | 28 ++ ...etsearchteams_request_params_object_gen.go | 33 ++ ...getsearchteams_request_params_types_gen.go | 12 + .../getsearchteams_response_body_types_gen.go | 33 ++ ...etsearchteams_response_object_types_gen.go | 37 +++ apps/iam/pkg/apis/iam/v0alpha1/register.go | 1 + apps/iam/pkg/apis/iam/v0alpha1/team_search.go | 35 +++ .../pkg/apis/iam/v0alpha1/zz_openapi_gen.go | 177 +++++++++++ apps/iam/pkg/apis/iam_manifest.go | 138 ++++++++- .../rtkq/iam/v0alpha1/endpoints.gen.ts | 36 +++ pkg/registry/apis/iam/authorizer.go | 10 + pkg/registry/apis/iam/models.go | 1 + pkg/registry/apis/iam/register.go | 13 +- pkg/registry/apis/iam/team/legacy_search.go | 141 +++++++++ .../apis/iam/team/legacy_search_test.go | 107 +++++++ pkg/registry/apis/iam/team_search.go | 192 ++++++++++++ pkg/registry/apis/iam/team_search_test.go | 286 ++++++++++++++++++ pkg/server/wire_gen.go | 4 +- pkg/services/team/search/search.go | 82 +++++ pkg/services/team/search/search_test.go | 227 ++++++++++++++ pkg/services/team/teamtest/team.go | 17 +- .../unified/search/builders/document.go | 7 +- .../unified/search/builders/document_test.go | 12 + .../unified/search/builders/team_search.go | 87 ++++++ .../team-with-email-and-external-uid-out.json | 18 ++ .../doc/team-with-email-and-external-uid.json | 13 + .../iam.grafana.app-v0alpha1.json | 225 ++++++++++++++ 27 files changed, 1957 insertions(+), 15 deletions(-) create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_object_gen.go create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_types_gen.go create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_body_types_gen.go create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_object_types_gen.go create mode 100644 apps/iam/pkg/apis/iam/v0alpha1/team_search.go create mode 100644 pkg/registry/apis/iam/team/legacy_search.go create mode 100644 pkg/registry/apis/iam/team/legacy_search_test.go create mode 100644 pkg/registry/apis/iam/team_search.go create mode 100644 pkg/registry/apis/iam/team_search_test.go create mode 100644 pkg/services/team/search/search.go create mode 100644 pkg/services/team/search/search_test.go create mode 100644 pkg/storage/unified/search/builders/team_search.go create mode 100644 pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid-out.json create mode 100644 pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid.json diff --git a/apps/iam/kinds/manifest.cue b/apps/iam/kinds/manifest.cue index bc9c3d47ad7..c6f609cbe15 100644 --- a/apps/iam/kinds/manifest.cue +++ b/apps/iam/kinds/manifest.cue @@ -22,4 +22,32 @@ v0alpha1: { serviceaccountv0alpha1, externalGroupMappingv0alpha1 ] + routes: { + namespaced: { + "/searchTeams": { + "GET": { + request: { + query: { + query?: string + } + } + response: { + #TeamHit: { + name: string + title: string + email: string + provisioned: bool + externalUID: string + } + offset: int64 + totalHits: int64 + hits: [...#TeamHit] + queryCost: float64 + maxScore: float64 + } + responseMetadata: objectMeta: false + } + } + } + } } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_object_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_object_gen.go new file mode 100644 index 00000000000..76ba539c798 --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_object_gen.go @@ -0,0 +1,33 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +import ( + "github.com/grafana/grafana-app-sdk/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +type GetSearchTeamsRequestParamsObject struct { + metav1.TypeMeta `json:",inline"` + GetSearchTeamsRequestParams `json:",inline"` +} + +func NewGetSearchTeamsRequestParamsObject() *GetSearchTeamsRequestParamsObject { + return &GetSearchTeamsRequestParamsObject{} +} + +func (o *GetSearchTeamsRequestParamsObject) DeepCopyObject() runtime.Object { + dst := NewGetSearchTeamsRequestParamsObject() + o.DeepCopyInto(dst) + return dst +} + +func (o *GetSearchTeamsRequestParamsObject) DeepCopyInto(dst *GetSearchTeamsRequestParamsObject) { + dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion + dst.TypeMeta.Kind = o.TypeMeta.Kind + dstGetSearchTeamsRequestParams := GetSearchTeamsRequestParams{} + _ = resource.CopyObjectInto(&dstGetSearchTeamsRequestParams, &o.GetSearchTeamsRequestParams) +} + +var _ runtime.Object = NewGetSearchTeamsRequestParamsObject() diff --git a/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_types_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_types_gen.go new file mode 100644 index 00000000000..ffa5067c41b --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_request_params_types_gen.go @@ -0,0 +1,12 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +type GetSearchTeamsRequestParams struct { + Query *string `json:"query,omitempty"` +} + +// NewGetSearchTeamsRequestParams creates a new GetSearchTeamsRequestParams object. +func NewGetSearchTeamsRequestParams() *GetSearchTeamsRequestParams { + return &GetSearchTeamsRequestParams{} +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_body_types_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_body_types_gen.go new file mode 100644 index 00000000000..9fabe93260d --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_body_types_gen.go @@ -0,0 +1,33 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +// +k8s:openapi-gen=true +type VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit struct { + Name string `json:"name"` + Title string `json:"title"` + Email string `json:"email"` + Provisioned bool `json:"provisioned"` + ExternalUID string `json:"externalUID"` +} + +// NewVersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit creates a new VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit object. +func NewVersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit() *VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit { + return &VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit{} +} + +// +k8s:openapi-gen=true +type GetSearchTeamsBody struct { + Offset int64 `json:"offset"` + TotalHits int64 `json:"totalHits"` + Hits []VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit `json:"hits"` + QueryCost float64 `json:"queryCost"` + MaxScore float64 `json:"maxScore"` +} + +// NewGetSearchTeamsBody creates a new GetSearchTeamsBody object. +func NewGetSearchTeamsBody() *GetSearchTeamsBody { + return &GetSearchTeamsBody{ + Hits: []VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit{}, + } +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_object_types_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_object_types_gen.go new file mode 100644 index 00000000000..685bd7d85be --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/getsearchteams_response_object_types_gen.go @@ -0,0 +1,37 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +package v0alpha1 + +import ( + "github.com/grafana/grafana-app-sdk/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// +k8s:openapi-gen=true +type GetSearchTeams struct { + metav1.TypeMeta `json:",inline"` + GetSearchTeamsBody `json:",inline"` +} + +func NewGetSearchTeams() *GetSearchTeams { + return &GetSearchTeams{} +} + +func (t *GetSearchTeamsBody) DeepCopyInto(dst *GetSearchTeamsBody) { + _ = resource.CopyObjectInto(dst, t) +} + +func (o *GetSearchTeams) DeepCopyObject() runtime.Object { + dst := NewGetSearchTeams() + o.DeepCopyInto(dst) + return dst +} + +func (o *GetSearchTeams) DeepCopyInto(dst *GetSearchTeams) { + dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion + dst.TypeMeta.Kind = o.TypeMeta.Kind + o.GetSearchTeamsBody.DeepCopyInto(&dst.GetSearchTeamsBody) +} + +var _ runtime.Object = NewGetSearchTeams() diff --git a/apps/iam/pkg/apis/iam/v0alpha1/register.go b/apps/iam/pkg/apis/iam/v0alpha1/register.go index be70a0e1714..05dbc30e684 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/register.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/register.go @@ -317,6 +317,7 @@ func AddAuthNKnownTypes(scheme *runtime.Scheme) error { &ServiceAccountList{}, &Team{}, &TeamList{}, + &GetSearchTeams{}, &TeamBinding{}, &TeamBindingList{}, &ExternalGroupMapping{}, diff --git a/apps/iam/pkg/apis/iam/v0alpha1/team_search.go b/apps/iam/pkg/apis/iam/v0alpha1/team_search.go new file mode 100644 index 00000000000..545552a4795 --- /dev/null +++ b/apps/iam/pkg/apis/iam/v0alpha1/team_search.go @@ -0,0 +1,35 @@ +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +k8s:deepcopy-gen=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type TeamSearchResults struct { + metav1.TypeMeta `json:",inline"` + + // Where the query started from + Offset int64 `json:"offset,omitempty"` + + // The number of matching results + TotalHits int64 `json:"totalHits"` + + // The team body + Hits []TeamHit `json:"hits"` + + // Cost of running the query + QueryCost float64 `json:"queryCost,omitempty"` + + // Max score + MaxScore float64 `json:"maxScore,omitempty"` +} + +// +k8s:deepcopy-gen=true +type TeamHit struct { + Name string `json:"name"` + Title string `json:"title"` + Email string `json:"email,omitempty"` + Provisioned bool `json:"provisioned,omitempty"` + ExternalUID string `json:"externalUID,omitempty"` +} diff --git a/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go index e3b024799d7..a41d51e8d70 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go @@ -24,6 +24,8 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ExternalGroupMappingTeamRef": schema_pkg_apis_iam_v0alpha1_ExternalGroupMappingTeamRef(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetGroups": schema_pkg_apis_iam_v0alpha1_GetGroups(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetGroupsBody": schema_pkg_apis_iam_v0alpha1_GetGroupsBody(ref), + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeams": schema_pkg_apis_iam_v0alpha1_GetSearchTeams(ref), + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeamsBody": schema_pkg_apis_iam_v0alpha1_GetSearchTeamsBody(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRole": schema_pkg_apis_iam_v0alpha1_GlobalRole(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRoleBinding": schema_pkg_apis_iam_v0alpha1_GlobalRoleBinding(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRoleBindingList": schema_pkg_apis_iam_v0alpha1_GlobalRoleBindingList(ref), @@ -80,6 +82,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserStatus": schema_pkg_apis_iam_v0alpha1_UserStatus(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.UserstatusOperatorState": schema_pkg_apis_iam_v0alpha1_UserstatusOperatorState(ref), "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1Kinds7RoutesGroupsGETResponseExternalGroupMapping": schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1Kinds7RoutesGroupsGETResponseExternalGroupMapping(ref), + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit": schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit(ref), } } @@ -564,6 +567,132 @@ func schema_pkg_apis_iam_v0alpha1_GetGroupsBody(ref common.ReferenceCallback) co } } +func schema_pkg_apis_iam_v0alpha1_GetSearchTeams(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "offset": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "totalHits": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "hits": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit"), + }, + }, + }, + }, + }, + "queryCost": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"number"}, + Format: "double", + }, + }, + "maxScore": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"number"}, + Format: "double", + }, + }, + }, + Required: []string{"offset", "totalHits", "hits", "queryCost", "maxScore"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit"}, + } +} + +func schema_pkg_apis_iam_v0alpha1_GetSearchTeamsBody(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "offset": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "totalHits": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "hits": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit"), + }, + }, + }, + }, + }, + "queryCost": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"number"}, + Format: "double", + }, + }, + "maxScore": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"number"}, + Format: "double", + }, + }, + }, + Required: []string{"offset", "totalHits", "hits", "queryCost", "maxScore"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit"}, + } +} + func schema_pkg_apis_iam_v0alpha1_GlobalRole(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -2956,3 +3085,51 @@ func schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1Kinds7RoutesGroupsGETResponseE }, } } + +func schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "title": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "email": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "provisioned": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "externalUID": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"name", "title", "email", "provisioned", "externalUID"}, + }, + }, + } +} diff --git a/apps/iam/pkg/apis/iam_manifest.go b/apps/iam/pkg/apis/iam_manifest.go index 9fe171525b4..5c27c228ed2 100644 --- a/apps/iam/pkg/apis/iam_manifest.go +++ b/apps/iam/pkg/apis/iam_manifest.go @@ -157,9 +157,139 @@ var appManifestData = app.ManifestData{ }, }, Routes: app.ManifestVersionRoutes{ - Namespaced: map[string]spec3.PathProps{}, - Cluster: map[string]spec3.PathProps{}, - Schemas: map[string]spec.Schema{}, + Namespaced: map[string]spec3.PathProps{ + "/searchTeams": { + Get: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + + OperationId: "getSearchTeams", + + Parameters: []*spec3.Parameter{ + + { + ParameterProps: spec3.ParameterProps{ + Name: "query", + In: "query", + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + }, + }, + }, + + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + Default: &spec3.Response{ + ResponseProps: spec3.ResponseProps{ + Description: "Default OK response", + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + }, + }, + "hits": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + }, + }, + "kind": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + }, + }, + "maxScore": { + SchemaProps: spec.SchemaProps{ + Type: []string{"number"}, + }, + }, + "offset": { + SchemaProps: spec.SchemaProps{ + Type: []string{"integer"}, + }, + }, + "queryCost": { + SchemaProps: spec.SchemaProps{ + Type: []string{"number"}, + }, + }, + "totalHits": { + SchemaProps: spec.SchemaProps{ + Type: []string{"integer"}, + }, + }, + }, + Required: []string{ + "offset", + "totalHits", + "hits", + "queryCost", + "maxScore", + "apiVersion", + "kind", + }, + }}, + }}, + }, + }, + }, + }}, + }, + }, + }, + }, + Cluster: map[string]spec3.PathProps{}, + Schemas: map[string]spec.Schema{ + "getSearchTeamsTeamHit": { + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "email": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + "externalUID": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + "name": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + "provisioned": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + }, + }, + "title": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + }, + }, + }, + Required: []string{ + "name", + "title", + "email", + "provisioned", + "externalUID", + }, + }, + }, + }, }, }, }, @@ -196,6 +326,8 @@ func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exist var customRouteToGoResponseType = map[string]any{ "v0alpha1|Team|groups|GET": v0alpha1.GetGroups{}, + + "v0alpha1||/searchTeams|GET": v0alpha1.GetSearchTeams{}, } // ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists. diff --git a/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts index 88ce5985631..8b0690f57ba 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/iam/v0alpha1/endpoints.gen.ts @@ -3,6 +3,7 @@ export const addTagTypes = [ 'API Discovery', 'Display', 'ExternalGroupMapping', + 'Search', 'ServiceAccount', 'SSOSetting', 'TeamBinding', @@ -152,6 +153,18 @@ const injectedRtkApi = api }), invalidatesTags: ['ExternalGroupMapping'], }), + getSearchTeams: build.query({ + query: (queryArg) => ({ + url: `/searchTeams`, + params: { + query: queryArg.query, + limit: queryArg.limit, + offset: queryArg.offset, + page: queryArg.page, + }, + }), + providesTags: ['Search'], + }), listServiceAccount: build.query({ query: (queryArg) => ({ url: `/serviceaccounts`, @@ -862,6 +875,27 @@ export type UpdateExternalGroupMappingApiArg = { force?: boolean; patch: Patch; }; +export type GetSearchTeamsApiResponse = /** status 200 undefined */ { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + hits: any[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + maxScore: number; + offset: number; + queryCost: number; + totalHits: number; +}; +export type GetSearchTeamsApiArg = { + /** team name query string */ + query?: string; + /** limit the number of results */ + limit?: number; + /** start the query at the given offset */ + offset?: number; + /** page number to start from */ + page?: number; +}; export type ListServiceAccountApiResponse = /** status 200 OK */ ServiceAccountList; export type ListServiceAccountApiArg = { /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ @@ -2084,6 +2118,8 @@ export const { useReplaceExternalGroupMappingMutation, useDeleteExternalGroupMappingMutation, useUpdateExternalGroupMappingMutation, + useGetSearchTeamsQuery, + useLazyGetSearchTeamsQuery, useListServiceAccountQuery, useLazyListServiceAccountQuery, useCreateServiceAccountMutation, diff --git a/pkg/registry/apis/iam/authorizer.go b/pkg/registry/apis/iam/authorizer.go index 05c8da97c2e..a3997638e1e 100644 --- a/pkg/registry/apis/iam/authorizer.go +++ b/pkg/registry/apis/iam/authorizer.go @@ -51,6 +51,9 @@ func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient auth resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = authorizer resourceAuthorizer[iamv0.TeamResourceInfo.GetName()] = authorizer + serviceAuthorizer := gfauthorizer.NewServiceAuthorizer() + resourceAuthorizer["searchTeams"] = serviceAuthorizer + return &iamAuthorizer{resourceAuthorizer: resourceAuthorizer} } @@ -77,6 +80,13 @@ func newLegacyAccessClient(ac accesscontrol.AccessControl, store legacy.LegacyId utils.VerbList: true, }, }, + accesscontrol.ResourceAuthorizerOptions{ + Resource: "searchTeams", + Unchecked: map[string]bool{ + utils.VerbGet: true, + utils.VerbList: true, + }, + }, accesscontrol.ResourceAuthorizerOptions{ Resource: iamv0.TeamResourceInfo.GetName(), Attr: "id", diff --git a/pkg/registry/apis/iam/models.go b/pkg/registry/apis/iam/models.go index 35efb2f2155..b851122ffd9 100644 --- a/pkg/registry/apis/iam/models.go +++ b/pkg/registry/apis/iam/models.go @@ -80,6 +80,7 @@ type IdentityAccessManagementAPIBuilder struct { dual dualwrite.Service unified resource.ResourceClient userSearchClient resourcepb.ResourceIndexClient + teamSearch *TeamSearchHandler teamGroupsHandler externalgroupmapping.TeamGroupsHandler diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 32e2c9fefef..b9da9e6f2c5 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -29,6 +29,7 @@ import ( grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" iamauthorizer "github.com/grafana/grafana/pkg/registry/apis/iam/authorizer" "github.com/grafana/grafana/pkg/registry/apis/iam/externalgroupmapping" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" @@ -46,7 +47,9 @@ import ( "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ssosettings" + teamservice "github.com/grafana/grafana/pkg/services/team" legacyuser "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/storage/unified/apistore" @@ -56,6 +59,7 @@ import ( const MaxConcurrentZanzanaWrites = 20 func RegisterAPIService( + cfg *setting.Cfg, features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, ssoService ssosettings.Service, @@ -66,12 +70,14 @@ func RegisterAPIService( reg prometheus.Registerer, coreRolesStorage CoreRoleStorageBackend, rolesStorage RoleStorageBackend, + tracing *tracing.TracingService, roleBindingsStorage RoleBindingStorageBackend, externalGroupMappingStorageBackend ExternalGroupMappingStorageBackend, teamGroupsHandlerImpl externalgroupmapping.TeamGroupsHandler, dual dualwrite.Service, unified resource.ResourceClient, userService legacyuser.Service, + teamService teamservice.Service, ) (*IdentityAccessManagementAPIBuilder, error) { dbProvider := legacysql.NewDatabaseProvider(sql) store := legacy.NewLegacySQLStores(dbProvider) @@ -109,6 +115,7 @@ func RegisterAPIService( unified: unified, userSearchClient: resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(), unified, user.NewUserLegacySearchClient(userService), features), + teamSearch: NewTeamSearchHandler(tracing, dual, team.NewLegacyTeamSearchClient(teamService), unified, features), } apiregistration.RegisterAPI(builder) @@ -502,7 +509,11 @@ func (b *IdentityAccessManagementAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenA func (b *IdentityAccessManagementAPIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes { defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} }) - return b.display.GetAPIRoutes(defs) + + routes := b.teamSearch.GetAPIRoutes(defs) + routes.Namespace = append(routes.Namespace, b.display.GetAPIRoutes(defs).Namespace...) + + return routes } func (b *IdentityAccessManagementAPIBuilder) GetAuthorizer() authorizer.Authorizer { diff --git a/pkg/registry/apis/iam/team/legacy_search.go b/pkg/registry/apis/iam/team/legacy_search.go new file mode 100644 index 00000000000..938062bd9e6 --- /dev/null +++ b/pkg/registry/apis/iam/team/legacy_search.go @@ -0,0 +1,141 @@ +package team + +import ( + "context" + "fmt" + "log/slog" + "math" + "strconv" + + "google.golang.org/grpc" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/team" + res "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + "github.com/grafana/grafana/pkg/storage/unified/search/builders" +) + +const ( + TeamResource = "teams" + TeamResourceGroup = "iam.grafana.com" +) + +// LegacyTeamSearchClient is a client for searching for teams in the legacy search engine. +type LegacyTeamSearchClient struct { + resourcepb.ResourceIndexClient + teamService team.Service + log *slog.Logger +} + +// NewLegacyTeamSearchClient creates a new LegacyTeamSearchClient. +func NewLegacyTeamSearchClient(teamService team.Service) *LegacyTeamSearchClient { + return &LegacyTeamSearchClient{ + teamService: teamService, + log: slog.Default().With("logger", "legacy-team-search-client"), + } +} + +// Search searches for teams in the legacy search engine. +func (c *LegacyTeamSearchClient) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest, _ ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) { + signedInUser, err := identity.GetRequester(ctx) + if err != nil { + return nil, err + } + + if req.Limit > 100 { + req.Limit = 100 + } + if req.Limit <= 0 { + req.Limit = 1 + } + + if req.Page > math.MaxInt32 || req.Page < 0 { + return nil, fmt.Errorf("invalid page number: %d", req.Page) + } + + query := &team.SearchTeamsQuery{ + SignedInUser: signedInUser, + Limit: int(req.Limit), + Page: int(req.Page), + Query: req.Query, + OrgID: signedInUser.GetOrgID(), + } + + res, err := c.teamService.SearchTeams(ctx, query) + if err != nil { + return nil, err + } + + columns := getColumns(req.Fields) + list := &resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{ + Columns: columns, + }, + } + + namespace := signedInUser.GetNamespace() + + for _, t := range res.Teams { + cells := createCells(t, req.Fields) + list.Results.Rows = append(list.Results.Rows, &resourcepb.ResourceTableRow{ + Key: getResourceKey(t, namespace), + Cells: cells, + }) + } + + list.TotalHits = res.TotalCount + + return list, nil +} + +func getResourceKey(t *team.TeamDTO, namespace string) *resourcepb.ResourceKey { + return &resourcepb.ResourceKey{ + Namespace: namespace, + Group: TeamResourceGroup, + Resource: TeamResource, + Name: t.UID, + } +} + +func getColumns(fields []string) []*resourcepb.ResourceTableColumnDefinition { + columns := getDefaultColumns() + + for _, field := range fields { + if col, ok := builders.TeamSearchTableColumnDefinitions[field]; ok { + columns = append(columns, col) + } + } + + return columns +} + +func getDefaultColumns() []*resourcepb.ResourceTableColumnDefinition { + searchFields := res.StandardSearchFields() + return []*resourcepb.ResourceTableColumnDefinition{ + searchFields.Field(res.SEARCH_FIELD_NAME), + searchFields.Field(res.SEARCH_FIELD_TITLE), + } +} + +func createCells(t *team.TeamDTO, fields []string) [][]byte { + cells := createDefaultCells(t) + for _, field := range fields { + switch field { + case builders.TEAM_SEARCH_EMAIL: + cells = append(cells, []byte(t.Email)) + case builders.TEAM_SEARCH_PROVISIONED: + cells = append(cells, []byte(strconv.FormatBool(t.IsProvisioned))) + case builders.TEAM_SEARCH_EXTERNAL_UID: + cells = append(cells, []byte(t.ExternalUID)) + } + } + return cells +} + +func createDefaultCells(t *team.TeamDTO) [][]byte { + return [][]byte{ + []byte(t.UID), + []byte(t.Name), + } +} diff --git a/pkg/registry/apis/iam/team/legacy_search_test.go b/pkg/registry/apis/iam/team/legacy_search_test.go new file mode 100644 index 00000000000..f7d70f355c3 --- /dev/null +++ b/pkg/registry/apis/iam/team/legacy_search_test.go @@ -0,0 +1,107 @@ +package team + +import ( + "context" + "errors" + "math" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/team" + "github.com/grafana/grafana/pkg/services/team/teamtest" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" +) + +func TestLegacyTeamSearchClient_Search(t *testing.T) { + t.Run("search by query", func(t *testing.T) { + mockTeamService := teamtest.NewFakeService() + client := NewLegacyTeamSearchClient(mockTeamService) + + ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1, Namespace: "default"}) + req := &resourcepb.ResourceSearchRequest{ + Limit: 10, + Page: 1, + Query: "test", + Fields: []string{"name", "email", "provisioned", "externalUID"}, + } + + mockTeamService.ExpectedSearchTeamsResult = team.SearchTeamQueryResult{ + Teams: []*team.TeamDTO{ + { + UID: "testTeamUID", + Name: "test team", + Email: "test@example.com", + IsProvisioned: true, + ExternalUID: "testExternalUID", + }, + }, + TotalCount: 1, + Page: 1, + PerPage: 10, + } + + resp, err := client.Search(ctx, req) + + require.NoError(t, err) + require.Equal(t, int64(1), resp.TotalHits) + require.Len(t, resp.Results.Rows, 1) + require.Len(t, resp.Results.Columns, 5) + require.Equal(t, "default", resp.Results.Rows[0].Key.Namespace) + require.Equal(t, "iam.grafana.com", resp.Results.Rows[0].Key.Group) + require.Equal(t, "teams", resp.Results.Rows[0].Key.Resource) + require.Equal(t, "testTeamUID", resp.Results.Rows[0].Key.Name) + require.Equal(t, "testTeamUID", string(resp.Results.Rows[0].Cells[0])) + require.Equal(t, "test team", string(resp.Results.Rows[0].Cells[1])) + require.Equal(t, "test@example.com", string(resp.Results.Rows[0].Cells[2])) + require.Equal(t, "true", string(resp.Results.Rows[0].Cells[3])) + require.Equal(t, "testExternalUID", string(resp.Results.Rows[0].Cells[4])) + }) + + t.Run("returns error if page is negative", func(t *testing.T) { + mockTeamService := teamtest.NewFakeService() + client := NewLegacyTeamSearchClient(mockTeamService) + ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1, Namespace: "default"}) + req := &resourcepb.ResourceSearchRequest{ + Limit: 10, + Page: -1, + } + + _, err := client.Search(ctx, req) + require.Error(t, err) + require.Equal(t, "invalid page number: -1", err.Error()) + }) + + t.Run("returns error if page is greater than math.MaxInt32", func(t *testing.T) { + mockTeamService := teamtest.NewFakeService() + client := NewLegacyTeamSearchClient(mockTeamService) + ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1, Namespace: "default"}) + req := &resourcepb.ResourceSearchRequest{ + Limit: 10, + Page: math.MaxInt32 + 1, + } + + _, err := client.Search(ctx, req) + require.Error(t, err) + require.Equal(t, "invalid page number: 2147483648", err.Error()) + }) + + t.Run("returns error if search teams fails", func(t *testing.T) { + mockTeamService := teamtest.NewFakeService() + client := NewLegacyTeamSearchClient(mockTeamService) + ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1, Namespace: "default"}) + req := &resourcepb.ResourceSearchRequest{ + Limit: 10, + Page: 1, + Query: "test", + } + + mockTeamService.ExpectedError = errors.New("search teams failed") + + _, err := client.Search(ctx, req) + require.Error(t, err) + require.Equal(t, "search teams failed", err.Error()) + }) +} diff --git a/pkg/registry/apis/iam/team_search.go b/pkg/registry/apis/iam/team_search.go new file mode 100644 index 00000000000..786bf9be834 --- /dev/null +++ b/pkg/registry/apis/iam/team_search.go @@ -0,0 +1,192 @@ +package iam + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + + "go.opentelemetry.io/otel/trace" + common "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + + iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/apiserver/builder" + "github.com/grafana/grafana/pkg/services/featuremgmt" + teamsearch "github.com/grafana/grafana/pkg/services/team/search" + "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + "github.com/grafana/grafana/pkg/storage/unified/search/builders" + "github.com/grafana/grafana/pkg/util/errhttp" +) + +type TeamSearchHandler struct { + log log.Logger + client resourcepb.ResourceIndexClient + tracer trace.Tracer + features featuremgmt.FeatureToggles +} + +func NewTeamSearchHandler(tracer trace.Tracer, dual dualwrite.Service, legacyTeamSearcher resourcepb.ResourceIndexClient, resourceClient resource.ResourceClient, features featuremgmt.FeatureToggles) *TeamSearchHandler { + searchClient := resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0alpha1.TeamResourceInfo.GroupResource(), resourceClient, legacyTeamSearcher, features) + + return &TeamSearchHandler{ + client: searchClient, + log: log.New("grafana-apiserver.teams.search"), + tracer: tracer, + features: features, + } +} + +func (s *TeamSearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *builder.APIRoutes { + searchResults := defs["github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeams"].Schema + + return &builder.APIRoutes{ + Namespace: []builder.APIRouteHandler{ + { + Path: "searchTeams", + Spec: &spec3.PathProps{ + Get: &spec3.Operation{ + OperationProps: spec3.OperationProps{ + Tags: []string{"Search"}, + Description: "Team search", + Parameters: []*spec3.Parameter{ + { + ParameterProps: spec3.ParameterProps{ + Name: "namespace", + In: "path", + Required: true, + Example: "default", + Description: "workspace", + Schema: spec.StringProperty(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "query", + In: "query", + Description: "team name query string", + Required: false, + Schema: spec.StringProperty(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "limit", + In: "query", + Description: "limit the number of results", + Required: false, + Schema: spec.Int64Property(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "offset", + In: "query", + Description: "start the query at the given offset", + Required: false, + Schema: spec.Int64Property(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "page", + In: "query", + Description: "page number to start from", + Required: false, + Schema: spec.Int64Property(), + }, + }, + }, + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + StatusCodeResponses: map[int]*spec3.Response{ + 200: { + ResponseProps: spec3.ResponseProps{ + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &searchResults, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Handler: s.DoTeamSearch, + }, + }, + } +} + +func (s *TeamSearchHandler) DoTeamSearch(w http.ResponseWriter, r *http.Request) { + ctx, span := s.tracer.Start(r.Context(), "team.search") + defer span.End() + + queryParams, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + limit := 50 + offset := 0 + page := 1 + if queryParams.Has("limit") { + limit, _ = strconv.Atoi(queryParams.Get("limit")) + } + if queryParams.Has("offset") { + offset, _ = strconv.Atoi(queryParams.Get("offset")) + if offset > 0 { + page = (offset / limit) + 1 + } + } else if queryParams.Has("page") { + page, _ = strconv.Atoi(queryParams.Get("page")) + offset = (page - 1) * limit + } + + searchRequest := &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{}, + Query: queryParams.Get("query"), + Limit: int64(limit), + Offset: int64(offset), + Page: int64(page), + Explain: queryParams.Has("explain") && queryParams.Get("explain") != "false", + Fields: []string{ + builders.TEAM_SEARCH_EMAIL, + builders.TEAM_SEARCH_PROVISIONED, + builders.TEAM_SEARCH_EXTERNAL_UID, + }, + } + + result, err := s.client.Search(ctx, searchRequest) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + searchResults, err := teamsearch.ParseResults(result, searchRequest.Offset) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + if err := s.write(w, searchResults); err != nil { + s.log.Error("failed to write team search results", "error", err) + errhttp.Write(ctx, err, w) + return + } +} + +func (s *TeamSearchHandler) write(w http.ResponseWriter, obj any) error { + w.Header().Set("Content-Type", "application/json") + return json.NewEncoder(w).Encode(obj) +} diff --git a/pkg/registry/apis/iam/team_search_test.go b/pkg/registry/apis/iam/team_search_test.go new file mode 100644 index 00000000000..ccc1abbf18c --- /dev/null +++ b/pkg/registry/apis/iam/team_search_test.go @@ -0,0 +1,286 @@ +package iam + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" +) + +func TestTeamSearchFallback(t *testing.T) { + testCases := []struct { + name string + mode rest.DualWriterMode + expectedLegacyCalled bool + expectedUnifiedCalled bool + }{ + {name: "mode 0", mode: rest.Mode0, expectedLegacyCalled: true, expectedUnifiedCalled: false}, + {name: "mode 1", mode: rest.Mode1, expectedLegacyCalled: true, expectedUnifiedCalled: false}, + {name: "mode 2", mode: rest.Mode2, expectedLegacyCalled: true, expectedUnifiedCalled: false}, + {name: "mode 3", mode: rest.Mode3, expectedLegacyCalled: false, expectedUnifiedCalled: true}, + {name: "mode 4", mode: rest.Mode4, expectedLegacyCalled: false, expectedUnifiedCalled: true}, + {name: "mode 5", mode: rest.Mode5, expectedLegacyCalled: false, expectedUnifiedCalled: true}, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + mockClient := &MockClient{} + mockLegacyClient := &MockClient{} + + cfg := &setting.Cfg{ + UnifiedStorage: map[string]setting.UnifiedStorageConfig{ + "teams.iam.grafana.app": {DualWriterMode: testCase.mode}, + }, + } + dual := dualwrite.ProvideStaticServiceForTests(cfg) + searchHandler := NewTeamSearchHandler(tracing.NewNoopTracerService(), dual, mockLegacyClient, mockClient, nil) + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/teams/search", nil) + req.Header.Add("content-type", "application/json") + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoTeamSearch(rr, req) + + if !testCase.expectedUnifiedCalled && mockClient.LastSearchRequest != nil { + t.Fatalf("expected Unified Search NOT to be called, but it was") + } + if testCase.expectedLegacyCalled && mockLegacyClient.LastSearchRequest == nil { + t.Fatalf("expected Legacy Search to be called, but it was not") + } + }) + } +} + +func TestTeamSearchHandler(t *testing.T) { + t.Run("search using default team search fields", func(t *testing.T) { + mockClient := &MockClient{} + + features := featuremgmt.WithFeatures() + searchHandler := TeamSearchHandler{ + log: log.New("grafana-apiserver.teams.search"), + client: mockClient, + tracer: tracing.NewNoopTracerService(), + features: features, + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/teams/search", nil) + req.Header.Add("content-type", "application/json") + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoTeamSearch(rr, req) + + if mockClient.LastSearchRequest == nil { + t.Fatalf("expected Search to be called, but it was not") + } + expectedFields := []string{"email", "provisioned", "externalUID"} + if fmt.Sprintf("%v", mockClient.LastSearchRequest.Fields) != fmt.Sprintf("%v", expectedFields) { + t.Errorf("expected fields %v, got %v", expectedFields, mockClient.LastSearchRequest.Fields) + } + }) + + t.Run("returns error if search fails", func(t *testing.T) { + mockClient := &MockClient{ + MockError: errors.New("search failed"), + } + + features := featuremgmt.WithFeatures() + searchHandler := TeamSearchHandler{ + log: log.New("grafana-apiserver.teams.search"), + client: mockClient, + tracer: tracing.NewNoopTracerService(), + features: features, + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/teams/search?query=test", nil) + req.Header.Add("content-type", "application/json") + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoTeamSearch(rr, req) + + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected StatusInternalServerError, got %d", rr.Code) + } + }) + + t.Run("should calculate offset and page parameters", func(t *testing.T) { + limit := 50 + for i, tt := range []struct { + offset int + page int + expectedOffset int + expectedPage int + }{ + { + offset: 0, + page: 0, + expectedOffset: 0, + expectedPage: 1, + }, + { + offset: 0, + page: 1, + expectedOffset: 0, + expectedPage: 1, + }, + { + offset: 0, + page: 2, + expectedOffset: 50, + expectedPage: 2, + }, + { + offset: 0, + page: 3, + expectedOffset: 100, + expectedPage: 3, + }, + { + offset: 50, + page: 0, + expectedOffset: 50, + expectedPage: 2, + }, + { + offset: 100, + page: 0, + expectedOffset: 100, + expectedPage: 3, + }, + { + offset: 149, + page: 0, + expectedOffset: 149, + expectedPage: 3, + }, + { + offset: 150, + page: 0, + expectedOffset: 150, + expectedPage: 4, + }, + } { + mockClient := &MockClient{} + + cfg := &setting.Cfg{ + UnifiedStorage: map[string]setting.UnifiedStorageConfig{ + "teams.iam.grafana.app": {DualWriterMode: rest.Mode0}, + }, + } + dual := dualwrite.ProvideStaticServiceForTests(cfg) + searchHandler := NewTeamSearchHandler(tracing.NewNoopTracerService(), dual, mockClient, mockClient, nil) + + rr := httptest.NewRecorder() + endpoint := fmt.Sprintf("/teams/search?limit=%d", limit) + if tt.offset > 0 { + endpoint = fmt.Sprintf("%s&offset=%d", endpoint, tt.offset) + } + if tt.page > 0 { + endpoint = fmt.Sprintf("%s&page=%d", endpoint, tt.page) + } + + req := httptest.NewRequest("GET", endpoint, nil) + req.Header.Add("content-type", "application/json") + req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) + + searchHandler.DoTeamSearch(rr, req) + + if mockClient.LastSearchRequest == nil { + t.Fatalf("expected Team Search to be called, but it was not") + } + + require.Equal(t, tt.expectedOffset, int(mockClient.LastSearchRequest.Offset), fmt.Sprintf("mismatch offset in test %d", i)) + require.Equal(t, tt.expectedPage, int(mockClient.LastSearchRequest.Page), fmt.Sprintf("mismatch page in test %d", i)) + } + }) +} + +type MockClient struct { + resourcepb.ResourceIndexClient + resource.ResourceIndex + + // Capture the last SearchRequest for assertions + LastSearchRequest *resourcepb.ResourceSearchRequest + + MockResponses []*resourcepb.ResourceSearchResponse + MockError error + MockCalls []*resourcepb.ResourceSearchRequest + CallCount int +} + +func (m *MockClient) Search(ctx context.Context, in *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) { + if m.MockError != nil { + return nil, m.MockError + } + + m.LastSearchRequest = in + m.MockCalls = append(m.MockCalls, in) + + var response *resourcepb.ResourceSearchResponse + if m.CallCount < len(m.MockResponses) { + response = m.MockResponses[m.CallCount] + } + + m.CallCount = m.CallCount + 1 + + return response, nil +} +func (m *MockClient) GetStats(ctx context.Context, in *resourcepb.ResourceStatsRequest, opts ...grpc.CallOption) (*resourcepb.ResourceStatsResponse, error) { + return nil, nil +} +func (m *MockClient) CountManagedObjects(ctx context.Context, in *resourcepb.CountManagedObjectsRequest, opts ...grpc.CallOption) (*resourcepb.CountManagedObjectsResponse, error) { + return nil, nil +} +func (m *MockClient) Watch(ctx context.Context, in *resourcepb.WatchRequest, opts ...grpc.CallOption) (resourcepb.ResourceStore_WatchClient, error) { + return nil, nil +} +func (m *MockClient) Delete(ctx context.Context, in *resourcepb.DeleteRequest, opts ...grpc.CallOption) (*resourcepb.DeleteResponse, error) { + return nil, nil +} +func (m *MockClient) Create(ctx context.Context, in *resourcepb.CreateRequest, opts ...grpc.CallOption) (*resourcepb.CreateResponse, error) { + return nil, nil +} +func (m *MockClient) Update(ctx context.Context, in *resourcepb.UpdateRequest, opts ...grpc.CallOption) (*resourcepb.UpdateResponse, error) { + return nil, nil +} +func (m *MockClient) Read(ctx context.Context, in *resourcepb.ReadRequest, opts ...grpc.CallOption) (*resourcepb.ReadResponse, error) { + return nil, nil +} +func (m *MockClient) GetBlob(ctx context.Context, in *resourcepb.GetBlobRequest, opts ...grpc.CallOption) (*resourcepb.GetBlobResponse, error) { + return nil, nil +} +func (m *MockClient) PutBlob(ctx context.Context, in *resourcepb.PutBlobRequest, opts ...grpc.CallOption) (*resourcepb.PutBlobResponse, error) { + return nil, nil +} +func (m *MockClient) List(ctx context.Context, in *resourcepb.ListRequest, opts ...grpc.CallOption) (*resourcepb.ListResponse, error) { + return nil, nil +} +func (m *MockClient) ListManagedObjects(ctx context.Context, in *resourcepb.ListManagedObjectsRequest, opts ...grpc.CallOption) (*resourcepb.ListManagedObjectsResponse, error) { + return nil, nil +} +func (m *MockClient) IsHealthy(ctx context.Context, in *resourcepb.HealthCheckRequest, opts ...grpc.CallOption) (*resourcepb.HealthCheckResponse, error) { + return nil, nil +} +func (m *MockClient) BulkProcess(ctx context.Context, opts ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error) { + return nil, nil +} +func (m *MockClient) UpdateIndex(ctx context.Context, reason string) error { + return nil +} diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index d1a2ecee64f..d3cf6da6b5a 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -873,7 +873,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient) storageBackendImpl := noopstorage.ProvideStorageBackend() noopTeamGroupsREST := externalgroupmapping.ProvideNoopTeamGroupsREST() - identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService) + identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService, teamService) if err != nil { return nil, err } @@ -1526,7 +1526,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient) storageBackendImpl := noopstorage.ProvideStorageBackend() noopTeamGroupsREST := externalgroupmapping.ProvideNoopTeamGroupsREST() - identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService) + identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, storageBackendImpl, storageBackendImpl, tracingService, storageBackendImpl, storageBackendImpl, noopTeamGroupsREST, dualwriteService, resourceClient, userService, teamService) if err != nil { return nil, err } diff --git a/pkg/services/team/search/search.go b/pkg/services/team/search/search.go new file mode 100644 index 00000000000..51c6113add3 --- /dev/null +++ b/pkg/services/team/search/search.go @@ -0,0 +1,82 @@ +package search + +import ( + "fmt" + + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + "github.com/grafana/grafana/pkg/storage/unified/search/builders" +) + +func ParseResults(result *resourcepb.ResourceSearchResponse, offset int64) (v0alpha1.TeamSearchResults, error) { + if result == nil { + return v0alpha1.TeamSearchResults{}, nil + } else if result.Error != nil { + return v0alpha1.TeamSearchResults{}, fmt.Errorf("%d error searching: %s: %s", result.Error.Code, result.Error.Message, result.Error.Details) + } else if result.Results == nil { + return v0alpha1.TeamSearchResults{}, nil + } + + titleIDX := -1 + emailIDX := -1 + provisionedIDX := -1 + externalUIDIDX := -1 + + for i, v := range result.Results.Columns { + if v == nil { + continue + } + + switch v.Name { + case resource.SEARCH_FIELD_TITLE: + titleIDX = i + case builders.TEAM_SEARCH_EMAIL: + emailIDX = i + case builders.TEAM_SEARCH_PROVISIONED: + provisionedIDX = i + case builders.TEAM_SEARCH_EXTERNAL_UID: + externalUIDIDX = i + } + } + + sr := v0alpha1.TeamSearchResults{ + Offset: offset, + TotalHits: result.TotalHits, + QueryCost: result.QueryCost, + MaxScore: result.MaxScore, + Hits: make([]v0alpha1.TeamHit, len(result.Results.Rows)), + } + + for i, row := range result.Results.Rows { + if len(row.Cells) != len(result.Results.Columns) { + return v0alpha1.TeamSearchResults{}, fmt.Errorf("error parsing team search response: mismatch number of columns and cells") + } + + hit := &v0alpha1.TeamHit{ + Name: row.Key.Name, + } + + if titleIDX >= 0 && row.Cells[titleIDX] != nil { + hit.Title = string(row.Cells[titleIDX]) + } else { + hit.Title = "(no title)" + } + + if emailIDX >= 0 && row.Cells[emailIDX] != nil { + hit.Email = string(row.Cells[emailIDX]) + } + + if provisionedIDX >= 0 && row.Cells[provisionedIDX] != nil { + hit.Provisioned = string(row.Cells[provisionedIDX]) == "true" + } + + if externalUIDIDX >= 0 && row.Cells[externalUIDIDX] != nil { + hit.ExternalUID = string(row.Cells[externalUIDIDX]) + } + + sr.Hits[i] = *hit + } + + return sr, nil +} diff --git a/pkg/services/team/search/search_test.go b/pkg/services/team/search/search_test.go new file mode 100644 index 00000000000..2dc5c6ef588 --- /dev/null +++ b/pkg/services/team/search/search_test.go @@ -0,0 +1,227 @@ +package search + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" +) + +func TestParseResults(t *testing.T) { + t.Run("should parse results", func(t *testing.T) { + searchResp := &resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{ + Columns: []*resourcepb.ResourceTableColumnDefinition{ + { + Name: "title", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: "email", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: "provisioned", + Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN, + }, + { + Name: "externalUID", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + }, + Rows: []*resourcepb.ResourceTableRow{ + { + Key: &resourcepb.ResourceKey{ + Name: "uid", + Resource: "team", + }, + Cells: [][]byte{ + []byte("Team 1"), + []byte("team1@example.com"), + []byte("true"), + []byte("team1-uid"), + }, + }, + }, + }, + TotalHits: 1, + } + + results, err := ParseResults(searchResp, 0) + require.NoError(t, err) + require.Len(t, results.Hits, 1) + require.Equal(t, "Team 1", results.Hits[0].Title) + require.Equal(t, "team1@example.com", results.Hits[0].Email) + require.True(t, results.Hits[0].Provisioned) + require.Equal(t, "team1-uid", results.Hits[0].ExternalUID) + }) + + t.Run("should handle nil result", func(t *testing.T) { + results, err := ParseResults(nil, 0) + require.NoError(t, err) + require.Empty(t, results.Hits) + require.Zero(t, results.TotalHits) + }) + + t.Run("should handle nil Results", func(t *testing.T) { + searchResp := &resourcepb.ResourceSearchResponse{ + Results: nil, + TotalHits: 0, + } + + results, err := ParseResults(searchResp, 0) + require.NoError(t, err) + require.Empty(t, results.Hits) + require.Zero(t, results.TotalHits) + }) + + t.Run("should handle nil Results.Rows", func(t *testing.T) { + searchResp := &resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{ + Columns: []*resourcepb.ResourceTableColumnDefinition{ + { + Name: "title", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + }, + Rows: nil, + }, + TotalHits: 0, + } + + results, err := ParseResults(searchResp, 0) + require.NoError(t, err) + require.Empty(t, results.Hits) + require.Zero(t, results.TotalHits) + }) + + t.Run("should return error for mismatched number of columns and cells", func(t *testing.T) { + searchResp := &resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{ + Columns: []*resourcepb.ResourceTableColumnDefinition{ + { + Name: "title", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: "email", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: "provisioned", + Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN, + }, + }, + Rows: []*resourcepb.ResourceTableRow{ + { + Key: &resourcepb.ResourceKey{ + Name: "uid", + Resource: "team", + }, + Cells: [][]byte{ + []byte("Team 1"), + []byte("team1@example.com"), + }, + }, + }, + }, + TotalHits: 1, + } + + results, err := ParseResults(searchResp, 0) + require.Error(t, err) + require.Contains(t, err.Error(), "mismatch number of columns and cells") + require.Empty(t, results.Hits) + }) + + t.Run("should return error for error response", func(t *testing.T) { + searchResp := &resourcepb.ResourceSearchResponse{ + Error: &resourcepb.ErrorResult{ + Code: 500, + Message: "Internal server error", + Details: &resourcepb.ErrorDetails{ + Name: "test-resource", + }, + }, + } + + results, err := ParseResults(searchResp, 0) + require.Error(t, err) + require.Contains(t, err.Error(), "500 error searching: Internal server error") + require.Empty(t, results.Hits) + }) + + t.Run("should use (no title) fallback when title cell is nil", func(t *testing.T) { + searchResp := &resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{ + Columns: []*resourcepb.ResourceTableColumnDefinition{ + { + Name: "title", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: "email", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + }, + Rows: []*resourcepb.ResourceTableRow{ + { + Key: &resourcepb.ResourceKey{ + Name: "uid", + Resource: "team", + }, + Cells: [][]byte{ + nil, // title cell is nil + []byte("team1@example.com"), + }, + }, + }, + }, + TotalHits: 1, + } + + results, err := ParseResults(searchResp, 0) + require.NoError(t, err) + require.Len(t, results.Hits, 1) + require.Equal(t, "(no title)", results.Hits[0].Title) + require.Equal(t, "team1@example.com", results.Hits[0].Email) + }) + + t.Run("should use (no title) fallback when title column is missing", func(t *testing.T) { + searchResp := &resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{ + Columns: []*resourcepb.ResourceTableColumnDefinition{ + { + Name: "email", + Type: resourcepb.ResourceTableColumnDefinition_STRING, + }, + { + Name: "provisioned", + Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN, + }, + }, + Rows: []*resourcepb.ResourceTableRow{ + { + Key: &resourcepb.ResourceKey{ + Name: "uid", + Resource: "team", + }, + Cells: [][]byte{ + []byte("team1@example.com"), + []byte("true"), + }, + }, + }, + }, + TotalHits: 1, + } + + results, err := ParseResults(searchResp, 0) + require.NoError(t, err) + require.Len(t, results.Hits, 1) + require.Equal(t, "(no title)", results.Hits[0].Title) + require.Equal(t, "team1@example.com", results.Hits[0].Email) + require.True(t, results.Hits[0].Provisioned) + }) +} diff --git a/pkg/services/team/teamtest/team.go b/pkg/services/team/teamtest/team.go index 3b88d05d5d3..7b9c87df5fb 100644 --- a/pkg/services/team/teamtest/team.go +++ b/pkg/services/team/teamtest/team.go @@ -7,13 +7,14 @@ import ( ) type FakeService struct { - ExpectedTeam team.Team - ExpectedIsMember bool - ExpectedIsAdmin bool - ExpectedTeamDTO *team.TeamDTO - ExpectedTeamsByUser []*team.TeamDTO - ExpectedMembers []*team.TeamMemberDTO - ExpectedError error + ExpectedTeam team.Team + ExpectedIsMember bool + ExpectedIsAdmin bool + ExpectedTeamDTO *team.TeamDTO + ExpectedTeamsByUser []*team.TeamDTO + ExpectedMembers []*team.TeamMemberDTO + ExpectedSearchTeamsResult team.SearchTeamQueryResult + ExpectedError error } func NewFakeService() *FakeService { @@ -39,7 +40,7 @@ func (s *FakeService) DeleteTeam(ctx context.Context, cmd *team.DeleteTeamComman } func (s *FakeService) SearchTeams(ctx context.Context, query *team.SearchTeamsQuery) (team.SearchTeamQueryResult, error) { - return team.SearchTeamQueryResult{}, s.ExpectedError + return s.ExpectedSearchTeamsResult, s.ExpectedError } func (s *FakeService) GetTeamByID(ctx context.Context, query *team.GetTeamByIDQuery) (*team.TeamDTO, error) { diff --git a/pkg/storage/unified/search/builders/document.go b/pkg/storage/unified/search/builders/document.go index 675f95b4b78..9920839f122 100644 --- a/pkg/storage/unified/search/builders/document.go +++ b/pkg/storage/unified/search/builders/document.go @@ -67,5 +67,10 @@ func All(sql db.DB, sprinkles DashboardStats) ([]resource.DocumentBuilderInfo, e return nil, err } - return []resource.DocumentBuilderInfo{dashboards, users, extGroupMappings}, nil + teams, err := GetTeamSearchBuilder() + if err != nil { + return nil, err + } + + return []resource.DocumentBuilderInfo{dashboards, users, extGroupMappings, teams}, nil } diff --git a/pkg/storage/unified/search/builders/document_test.go b/pkg/storage/unified/search/builders/document_test.go index bee719aeefa..6c8d02d6cb8 100644 --- a/pkg/storage/unified/search/builders/document_test.go +++ b/pkg/storage/unified/search/builders/document_test.go @@ -70,6 +70,18 @@ func TestExternalGroupMappingDocumentBuilder(t *testing.T) { }) } +func TestTeamSearchBuilder(t *testing.T) { + info, err := GetTeamSearchBuilder() + require.NoError(t, err) + doSnapshotTests(t, info.Builder, "team", &resourcepb.ResourceKey{ + Namespace: "default", + Group: "iam.grafana.app", + Resource: "searchTeams", + }, []string{ + "with-email-and-external-uid", + }) +} + func TestDashboardDocumentBuilder(t *testing.T) { key := &resourcepb.ResourceKey{ Namespace: "default", diff --git a/pkg/storage/unified/search/builders/team_search.go b/pkg/storage/unified/search/builders/team_search.go new file mode 100644 index 00000000000..4b09074b3dc --- /dev/null +++ b/pkg/storage/unified/search/builders/team_search.go @@ -0,0 +1,87 @@ +package builders + +import ( + "bytes" + "context" + "encoding/json" + + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" +) + +const ( + TEAM_SEARCH_EMAIL = "email" + TEAM_SEARCH_PROVISIONED = "provisioned" + TEAM_SEARCH_EXTERNAL_UID = "externalUID" +) + +var TeamSearchTableColumnDefinitions = map[string]*resourcepb.ResourceTableColumnDefinition{ + TEAM_SEARCH_EMAIL: { + Name: TEAM_SEARCH_EMAIL, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + Description: "Email of the team", + }, + TEAM_SEARCH_PROVISIONED: { + Name: TEAM_SEARCH_PROVISIONED, + Type: resourcepb.ResourceTableColumnDefinition_BOOLEAN, + Description: "Whether the team is provisioned", + }, + TEAM_SEARCH_EXTERNAL_UID: { + Name: TEAM_SEARCH_EXTERNAL_UID, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + Description: "External UID of the team", + }, +} + +func GetTeamSearchBuilder() (resource.DocumentBuilderInfo, error) { + values := make([]*resourcepb.ResourceTableColumnDefinition, 0, len(TeamSearchTableColumnDefinitions)) + for _, v := range TeamSearchTableColumnDefinitions { + values = append(values, v) + } + fields, err := resource.NewSearchableDocumentFields(values) + + return resource.DocumentBuilderInfo{ + GroupResource: schema.GroupResource{ + Group: "iam.grafana.app", + Resource: "searchTeams", + }, + Fields: fields, + Builder: new(teamSearchBuilder), + }, err +} + +var _ resource.DocumentBuilder = new(teamSearchBuilder) + +type teamSearchBuilder struct{} + +func (t *teamSearchBuilder) BuildDocument(ctx context.Context, key *resourcepb.ResourceKey, rv int64, value []byte) (*resource.IndexableDocument, error) { + team := &v0alpha1.Team{} + err := json.NewDecoder(bytes.NewReader(value)).Decode(team) + if err != nil { + return nil, err + } + + obj, err := utils.MetaAccessor(team) + if err != nil { + return nil, err + } + + doc := resource.NewIndexableDocument(key, rv, obj) + + doc.Fields = make(map[string]any) + if team.Spec.Email != "" { + doc.Fields[TEAM_SEARCH_EMAIL] = team.Spec.Email + } + if team.Spec.Provisioned { + doc.Fields[TEAM_SEARCH_PROVISIONED] = team.Spec.Provisioned + } + if team.Spec.ExternalUID != "" { + doc.Fields[TEAM_SEARCH_EXTERNAL_UID] = team.Spec.ExternalUID + } + + return doc, nil +} diff --git a/pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid-out.json b/pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid-out.json new file mode 100644 index 00000000000..1c05f3c4dc9 --- /dev/null +++ b/pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid-out.json @@ -0,0 +1,18 @@ +{ + "key": { + "namespace": "default", + "group": "iam.grafana.app", + "resource": "searchTeams", + "name": "with-email-and-external-uid" + }, + "name": "with-email-and-external-uid", + "rv": 1234, + "title": "Team With Email And External UID", + "title_ngram": "Team With Email And External UID", + "title_phrase": "team with email and external uid", + "fields": { + "email": "test@example.com", + "externalUID": "external-uid", + "provisioned": true + } +} \ No newline at end of file diff --git a/pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid.json b/pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid.json new file mode 100644 index 00000000000..89bbefdb13f --- /dev/null +++ b/pkg/storage/unified/search/builders/testdata/doc/team-with-email-and-external-uid.json @@ -0,0 +1,13 @@ +{ + "apiVersion": "iam.grafana.app/v0alpha1", + "kind": "Team", + "metadata": { + "name": "team-with-email-and-external-uid" + }, + "spec": { + "title": "Team With Email And External UID", + "email": "test@example.com", + "provisioned": true, + "externalUID": "external-uid" + } +} \ No newline at end of file diff --git a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json index 8449498a061..ea0e4039581 100644 --- a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json @@ -912,6 +912,115 @@ } ] }, + "/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/searchTeams": { + "get": { + "tags": [ + "Search" + ], + "description": "Team search", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "workspace", + "required": true, + "schema": { + "type": "string" + }, + "example": "default" + }, + { + "name": "query", + "in": "query", + "description": "team name query string", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "limit the number of results", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "offset", + "in": "query", + "description": "start the query at the given offset", + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "page", + "in": "query", + "description": "page number to start from", + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "offset", + "totalHits", + "hits", + "queryCost", + "maxScore" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "hits": { + "type": "array", + "items": { + "default": {} + } + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "maxScore": { + "type": "number", + "format": "double", + "default": 0 + }, + "offset": { + "type": "integer", + "format": "int64", + "default": 0 + }, + "queryCost": { + "type": "number", + "format": "double", + "default": 0 + }, + "totalHits": { + "type": "integer", + "format": "int64", + "default": 0 + } + } + } + } + } + } + } + } + }, "/apis/iam.grafana.app/v0alpha1/namespaces/{namespace}/serviceaccounts": { "get": { "tags": [ @@ -6373,6 +6482,90 @@ } } }, + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeams": { + "type": "object", + "required": [ + "offset", + "totalHits", + "hits", + "queryCost", + "maxScore" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "hits": { + "type": "array", + "items": { + "default": {} + } + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "maxScore": { + "type": "number", + "format": "double", + "default": 0 + }, + "offset": { + "type": "integer", + "format": "int64", + "default": 0 + }, + "queryCost": { + "type": "number", + "format": "double", + "default": 0 + }, + "totalHits": { + "type": "integer", + "format": "int64", + "default": 0 + } + } + }, + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GetSearchTeamsBody": { + "type": "object", + "required": [ + "offset", + "totalHits", + "hits", + "queryCost", + "maxScore" + ], + "properties": { + "hits": { + "type": "array", + "items": { + "default": {} + } + }, + "maxScore": { + "type": "number", + "format": "double", + "default": 0 + }, + "offset": { + "type": "integer", + "format": "int64", + "default": 0 + }, + "queryCost": { + "type": "number", + "format": "double", + "default": 0 + }, + "totalHits": { + "type": "integer", + "format": "int64", + "default": 0 + } + } + }, "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.GlobalRole": { "type": "object", "required": [ @@ -7726,6 +7919,38 @@ } } }, + "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.VersionsV0alpha1RoutesNamespacedSearchTeamsGETResponseTeamHit": { + "type": "object", + "required": [ + "name", + "title", + "email", + "provisioned", + "externalUID" + ], + "properties": { + "email": { + "type": "string", + "default": "" + }, + "externalUID": { + "type": "string", + "default": "" + }, + "name": { + "type": "string", + "default": "" + }, + "provisioned": { + "type": "boolean", + "default": false + }, + "title": { + "type": "string", + "default": "" + } + } + }, "io.k8s.apimachinery.pkg.apis.meta.v1.APIResource": { "description": "APIResource specifies the name of a resource and whether it is namespaced.", "type": "object", From 692712961b339b8647d4b2b4baad71854deaad83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Mon, 8 Dec 2025 15:20:01 +0100 Subject: [PATCH 07/21] datasources: querier: configurable concurrent-query-limit (#114585) --- pkg/registry/apis/query/query.go | 6 +++--- pkg/registry/apis/query/register.go | 11 +++++++---- pkg/services/query/query.go | 4 ++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/pkg/registry/apis/query/query.go b/pkg/registry/apis/query/query.go index 987251bcdb0..99323eb2721 100644 --- a/pkg/registry/apis/query/query.go +++ b/pkg/registry/apis/query/query.go @@ -338,8 +338,8 @@ func prepareQuery( }, nil } -func handlePreparedQuery(ctx context.Context, pq *preparedQuery) (*backend.QueryDataResponse, error) { - resp, err := service.QueryData(ctx, pq.logger, pq.cache, pq.exprSvc, pq.mReq, pq.builder, pq.headers) +func handlePreparedQuery(ctx context.Context, pq *preparedQuery, concurrentQueryLimit int) (*backend.QueryDataResponse, error) { + resp, err := service.QueryData(ctx, pq.logger, pq.cache, pq.exprSvc, pq.mReq, pq.builder, pq.headers, concurrentQueryLimit) pq.reportMetrics() return resp, err } @@ -357,7 +357,7 @@ func handleQuery( responder.Error(err) return nil, err } - return handlePreparedQuery(ctx, pq) + return handlePreparedQuery(ctx, pq, b.concurrentQueryLimit) } type responderWrapper struct { diff --git a/pkg/registry/apis/query/register.go b/pkg/registry/apis/query/register.go index 221b589b511..e7ab7fb9b07 100644 --- a/pkg/registry/apis/query/register.go +++ b/pkg/registry/apis/query/register.go @@ -3,10 +3,11 @@ package query import ( "context" "encoding/json" + "runtime" "github.com/prometheus/client_golang/prometheus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" + apiruntime "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/registry/rest" @@ -62,6 +63,7 @@ func NewQueryAPIBuilder( tracer tracing.Tracer, legacyDatasourceLookup service.LegacyDataSourceLookup, connections DataSourceConnectionProvider, + concurrentQueryLimit int, ) (*QueryAPIBuilder, error) { // Include well typed query definitions var queryTypes *query.QueryTypeDefinitionList @@ -80,7 +82,7 @@ func NewQueryAPIBuilder( } return &QueryAPIBuilder{ - concurrentQueryLimit: 4, + concurrentQueryLimit: concurrentQueryLimit, log: log.New("query_apiserver"), instanceProvider: instanceProvider, authorizer: ar, @@ -142,6 +144,7 @@ func RegisterAPIService( tracer, legacyDatasourceLookup, &connectionsProvider{dsService: dataSourcesService, registry: reg}, + cfg.SectionWithEnvOverrides("query").Key("concurrent_query_limit").MustInt(runtime.NumCPU()), ) apiregistration.RegisterAPI(builder) return builder, err @@ -151,7 +154,7 @@ func (b *QueryAPIBuilder) GetGroupVersion() schema.GroupVersion { return query.SchemeGroupVersion } -func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { +func addKnownTypes(scheme *apiruntime.Scheme, gv schema.GroupVersion) { scheme.AddKnownTypes(gv, &query.DataSourceApiServer{}, &query.DataSourceApiServerList{}, @@ -165,7 +168,7 @@ func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { ) } -func (b *QueryAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { +func (b *QueryAPIBuilder) InstallSchema(scheme *apiruntime.Scheme) error { addKnownTypes(scheme, query.SchemeGroupVersion) metav1.AddToGroupVersion(scheme, query.SchemeGroupVersion) return scheme.SetVersionPriority(query.SchemeGroupVersion) diff --git a/pkg/services/query/query.go b/pkg/services/query/query.go index 34abc9a2dbf..dd646fa2acc 100644 --- a/pkg/services/query/query.go +++ b/pkg/services/query/query.go @@ -226,7 +226,7 @@ func buildErrorResponses(err error, queries []*simplejson.Json) splitResponse { return splitResponse{er, http.Header{}} } -func QueryData(ctx context.Context, log log.Logger, dscache datasources.CacheService, exprService *expr.Service, reqDTO dtos.MetricRequest, qsDatasourceClientBuilder dsquerierclient.QSDatasourceClientBuilder, headers map[string]string) (*backend.QueryDataResponse, error) { +func QueryData(ctx context.Context, log log.Logger, dscache datasources.CacheService, exprService *expr.Service, reqDTO dtos.MetricRequest, qsDatasourceClientBuilder dsquerierclient.QSDatasourceClientBuilder, headers map[string]string, concurrentQueryLimit int) (*backend.QueryDataResponse, error) { s := &ServiceImpl{ log: log, dataSourceCache: dscache, @@ -234,7 +234,7 @@ func QueryData(ctx context.Context, log log.Logger, dscache datasources.CacheSer dataSourceRequestValidator: validations.ProvideValidator(), qsDatasourceClientBuilder: qsDatasourceClientBuilder, headers: headers, - concurrentQueryLimit: 16, // TODO: make it configurable + concurrentQueryLimit: concurrentQueryLimit, } user, err := identity.GetRequester(ctx) From ccdb6ff2617dfe6a73368a9cf5547482a60eb248 Mon Sep 17 00:00:00 2001 From: Lauren <61048546+laurenashleigh@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:54:46 +0000 Subject: [PATCH 08/21] Alerting: Fix alert instances count display (#114965) Alerting: fix alert instances count display --- .../components/rules/RuleDetailsMatchingInstances.tsx | 9 +++++---- public/locales/en-US/grafana.json | 5 ++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx index b8162030af5..7a7e5d8b22c 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx @@ -44,20 +44,21 @@ interface ShowMoreInstancesProps { function ShowMoreInstances({ stats, onClick, href }: ShowMoreInstancesProps) { const styles = useStyles2(getStyles); + const { visibleItemsCount, totalItemsCount } = stats; return (
- Showing {'{{visibleItems}}'} out of {'{{totalItems}}'} instances + Showing {{ visibleItemsCount }} out of {{ totalItemsCount }} instances
- - Show all {'{{totalItems}}'} alert instances + + Show all {{ totalItemsCount }} alert instances
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index cc056e38526..1eb2c98e9ef 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2334,9 +2334,8 @@ "label-tenant-sources": "Tenant sources" }, "rule-details-matching-instances": { - "button-show-all_one": "Show all {{totalItems}} alert instances", - "button-show-all_other": "Show all {{totalItems}} alert instances", - "showing-count": "Showing {{visibleItems}} out of {{totalItems}} instances" + "button-show-all": "Show all {{totalItemsCount}} alert instances", + "showing-count": "Showing {{visibleItemsCount}} out of {{totalItemsCount}} instances" }, "rule-editor": { "get-content": { From b50cf6e0676e6753952aad437f251b5022416711 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Mon, 8 Dec 2025 15:10:16 +0000 Subject: [PATCH 09/21] FieldColor: Group new accessible options within the select menu (#114690) * group new accessible options within the select menu * move comment --- packages/grafana-data/src/field/fieldColor.ts | 24 ++++++++++ .../core/components/OptionsUI/fieldColor.tsx | 47 +++++++++++++------ public/locales/en-US/grafana.json | 6 +++ 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/packages/grafana-data/src/field/fieldColor.ts b/packages/grafana-data/src/field/fieldColor.ts index 7016c208994..78e0b632619 100644 --- a/packages/grafana-data/src/field/fieldColor.ts +++ b/packages/grafana-data/src/field/fieldColor.ts @@ -9,6 +9,8 @@ import { import stringHash from 'string-hash'; import tinycolor from 'tinycolor2'; +import { t } from '@grafana/i18n'; + import { getContrastRatio } from '../themes/colorManipulator'; import { GrafanaTheme2 } from '../themes/types'; import { reduceField } from '../transformations/fieldReducer'; @@ -30,10 +32,14 @@ export interface FieldColorMode extends RegistryItem { isContinuous?: boolean; isByValue?: boolean; useSeriesName?: boolean; + group?: string; } /** @internal */ export const fieldColorModeRegistry = new Registry(() => { + const accessibleGroup = t('grafana-data.field.fieldColor.accessibleGroup', 'Accessible'); + const otherGroup = t('grafana-data.field.fieldColor.otherGroup', 'Others'); + return [ { id: FieldColorModeId.Fixed, @@ -88,6 +94,7 @@ export const fieldColorModeRegistry = new Registry(() => { isContinuous: true, isByValue: true, interpolator: interpolateViridis, + group: accessibleGroup, }), new FieldColorSchemeMode({ id: FieldColorModeId.ContinuousMagma, @@ -95,6 +102,7 @@ export const fieldColorModeRegistry = new Registry(() => { isContinuous: true, isByValue: true, interpolator: interpolateMagma, + group: accessibleGroup, }), new FieldColorSchemeMode({ id: FieldColorModeId.ContinuousPlasma, @@ -102,6 +110,7 @@ export const fieldColorModeRegistry = new Registry(() => { isContinuous: true, isByValue: true, interpolator: interpolatePlasma, + group: accessibleGroup, }), new FieldColorSchemeMode({ id: FieldColorModeId.ContinuousInferno, @@ -109,6 +118,7 @@ export const fieldColorModeRegistry = new Registry(() => { isContinuous: true, isByValue: true, interpolator: interpolateInferno, + group: accessibleGroup, }), new FieldColorSchemeMode({ id: FieldColorModeId.ContinuousCividis, @@ -116,6 +126,7 @@ export const fieldColorModeRegistry = new Registry(() => { isContinuous: true, isByValue: true, interpolator: interpolateCividis, + group: accessibleGroup, }), new FieldColorSchemeMode({ id: FieldColorModeId.ContinuousGrYlRd, @@ -123,6 +134,7 @@ export const fieldColorModeRegistry = new Registry(() => { isContinuous: true, isByValue: true, getColors: (theme: GrafanaTheme2) => ['green', 'yellow', 'red'], + group: otherGroup, }), new FieldColorSchemeMode({ id: FieldColorModeId.ContinuousRdYlGr, @@ -130,6 +142,7 @@ export const fieldColorModeRegistry = new Registry(() => { isContinuous: true, isByValue: true, getColors: (theme: GrafanaTheme2) => ['red', 'yellow', 'green'], + group: otherGroup, }), new FieldColorSchemeMode({ id: FieldColorModeId.ContinuousBlYlRd, @@ -137,6 +150,7 @@ export const fieldColorModeRegistry = new Registry(() => { isContinuous: true, isByValue: true, getColors: (theme: GrafanaTheme2) => ['dark-blue', 'super-light-yellow', 'dark-red'], + group: otherGroup, }), new FieldColorSchemeMode({ id: FieldColorModeId.ContinuousYlRd, @@ -144,6 +158,7 @@ export const fieldColorModeRegistry = new Registry(() => { isContinuous: true, isByValue: true, getColors: (theme: GrafanaTheme2) => ['super-light-yellow', 'dark-red'], + group: otherGroup, }), new FieldColorSchemeMode({ id: FieldColorModeId.ContinuousBlPu, @@ -151,6 +166,7 @@ export const fieldColorModeRegistry = new Registry(() => { isContinuous: true, isByValue: true, getColors: (theme: GrafanaTheme2) => ['blue', 'purple'], + group: otherGroup, }), new FieldColorSchemeMode({ id: FieldColorModeId.ContinuousYlBl, @@ -158,6 +174,7 @@ export const fieldColorModeRegistry = new Registry(() => { isContinuous: true, isByValue: true, getColors: (theme: GrafanaTheme2) => ['super-light-yellow', 'dark-blue'], + group: otherGroup, }), new FieldColorSchemeMode({ id: FieldColorModeId.ContinuousBlues, @@ -165,6 +182,7 @@ export const fieldColorModeRegistry = new Registry(() => { isContinuous: true, isByValue: true, getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-blue'], + group: otherGroup, }), new FieldColorSchemeMode({ id: FieldColorModeId.ContinuousReds, @@ -172,6 +190,7 @@ export const fieldColorModeRegistry = new Registry(() => { isContinuous: true, isByValue: true, getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-red'], + group: otherGroup, }), new FieldColorSchemeMode({ id: FieldColorModeId.ContinuousGreens, @@ -179,6 +198,7 @@ export const fieldColorModeRegistry = new Registry(() => { isContinuous: true, isByValue: true, getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-green'], + group: otherGroup, }), new FieldColorSchemeMode({ id: FieldColorModeId.ContinuousPurples, @@ -186,6 +206,7 @@ export const fieldColorModeRegistry = new Registry(() => { isContinuous: true, isByValue: true, getColors: (theme: GrafanaTheme2) => ['panel-bg', 'dark-purple'], + group: otherGroup, }), ]; }); @@ -197,6 +218,7 @@ interface BaseFieldColorSchemeModeOptions { isContinuous: boolean; isByValue: boolean; useSeriesName?: boolean; + group?: string; } interface FieldColorSchemeModeInterpolator extends BaseFieldColorSchemeModeOptions { @@ -222,6 +244,7 @@ export class FieldColorSchemeMode implements FieldColorMode { colorCacheTheme?: GrafanaTheme2; interpolator?: (value: number) => string; getNamedColors?: (theme: GrafanaTheme2) => string[]; + group?: string; constructor(options: FieldColorSchemeModeOptions) { this.id = options.id; @@ -232,6 +255,7 @@ export class FieldColorSchemeMode implements FieldColorMode { this.isByValue = options.isByValue; this.useSeriesName = options.useSeriesName; this.interpolator = options.interpolator; + this.group = options.group; } getColors(theme: GrafanaTheme2): string[] { diff --git a/public/app/core/components/OptionsUI/fieldColor.tsx b/public/app/core/components/OptionsUI/fieldColor.tsx index 39833b21c03..3f734a8e252 100644 --- a/public/app/core/components/OptionsUI/fieldColor.tsx +++ b/public/app/core/components/OptionsUI/fieldColor.tsx @@ -29,22 +29,39 @@ export const FieldColorEditor = ({ value, onChange, item, id }: Props) => { ? fieldColorModeRegistry.list() : fieldColorModeRegistry.list().filter((m) => !m.isByValue); - const options = availableOptions - .filter((mode) => !mode.excludeFromPicker) - .map((mode) => { - let suffix = mode.isByValue ? ' (by value)' : ''; + const filteredOptions = availableOptions.filter((option) => !option.excludeFromPicker); - return { - value: mode.id, - label: `${mode.name}${suffix}`, - description: mode.description, - isContinuous: mode.isContinuous, - isByValue: mode.isByValue, - component() { - return ; - }, - }; - }); + const options: Array> = []; + // collect any grouped options in this map + // this allows us to easily push to the child array without having to rescan the options array + // it also allows us to maintain group position in the order they're first encountered + const groupMap = new Map>>(); + + for (const option of filteredOptions) { + const suffix = option.isByValue ? ' (by value)' : ''; + + const groupName = option.group; + const selectOption = { + value: option.id, + label: `${option.name}${suffix}`, + description: option.description, + component() { + return ; + }, + }; + + if (groupName) { + let group = groupMap.get(groupName); + if (!group) { + group = []; + groupMap.set(groupName, group); + options.push({ label: groupName, options: group }); + } + group.push(selectOption); + } else { + options.push(selectOption); + } + } const onModeChange = (newMode: SelectableValue) => { onChange({ diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 1eb2c98e9ef..81d590746c5 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -8251,6 +8251,12 @@ "nextNYears_other": "Next {{count}} years" } }, + "field": { + "fieldColor": { + "accessibleGroup": "Accessible", + "otherGroup": "Others" + } + }, "valueFormats": { "categories": { "acceleration": { From fef6196195c0ec68c846057f12ca7bdada1650de Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Mon, 8 Dec 2025 10:49:36 -0500 Subject: [PATCH 10/21] Dashboard: Default weekStart to an empty string (#114932) * Default clien scene-based logic to empty string to match backend + non-scene logic * re-gen snapshots --- .../__snapshots__/transformSceneToSaveModel.test.ts.snap | 1 + .../serialization/transformSceneToSaveModel.ts | 8 +++----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap index c6abaab219d..2a7f5bc424e 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap @@ -709,6 +709,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back "title": "Repeating rows", "uid": "Repeating-rows-uid", "version": 1, + "weekStart": "", } `; diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index f1ed4e29bcb..3052e4d8118 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -143,6 +143,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa description: state.description || undefined, uid: state.uid, id: state.id, + editable: state.editable, preload: state.preload, time: { from: timeRange.from, @@ -158,7 +159,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa }, version: state.version, fiscalYearStartMonth: timeRange.fiscalYearStartMonth, - weekStart: timeRange.weekStart, + weekStart: timeRange.weekStart ?? '', tags: state.tags, links: state.links, graphTooltip, @@ -170,10 +171,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa }; // Only add optional fields if they are explicitly set (not default values) - if (state.editable !== undefined) { - dashboard.editable = state.editable; - } - if (timeRange.timeZone !== undefined && timeRange.timeZone !== '') { + if (timeRange.timeZone !== '') { dashboard.timezone = timeRange.timeZone; } From 7ea009c7f8c42c1f2f43b8e8ba99dfe14cebecf2 Mon Sep 17 00:00:00 2001 From: Victor Marin Date: Mon, 8 Dec 2025 18:18:04 +0200 Subject: [PATCH 11/21] Dashboards: Per panel filtering for timeseries (#114499) * wip per panel group by * wip groupBy per panel * wip groupBy per panel * groupBy per panel action tests * fix * fix * fix * fix * CR mods * switch to dropdown * adjust apply * optimise action logic to avoid unnecessary triggers * canary scenes * wip (cherry picked from commit 51a00db93d0805f481a9e48213382468f1eb2986) * optimise action logic to avoid unnecessary triggers (cherry picked from commit c4de2dfff88c02c5aef61d1cbee070b5f6e5ccbc) * refactor * refactor * memoize values/ refactor * refactor * refactor components - do not make async call unless queries/groupByOptions change * canary scenes * fix test * Optimise handlers * Reset options if they are not applied * refactor subscriptions * refactor * scenes bump * fixes * properly deactivate header actions on panel edit * list * refactor showing menu using css, remove header deactivation code from panel-edit * cleanup * cleanup * cleanup + action redesign * i18n * wip * wip * wip * wip * wip * tests * pr mods * translations * fix * fix * fixes * translations * translations * extra ff check * CR mods --------- Co-authored-by: Sergej-Vlasov Co-authored-by: Dominik Prokop --- .../src/types/featureToggles.gen.ts | 6 +- .../src/selectors/components.ts | 10 + .../components/PanelChrome/PanelContext.ts | 9 + .../VizTooltip/VizTooltipFooter.test.tsx | 62 ++ .../VizTooltip/VizTooltipFooter.tsx | 48 +- packages/grafana-ui/src/internal/index.ts | 6 +- pkg/services/featuremgmt/registry.go | 9 +- pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.json | 654 ++++++++---------- .../scene/setDashboardPanelContext.test.ts | 177 ++++- .../scene/setDashboardPanelContext.ts | 81 +++ .../panel/timeseries/TimeSeriesPanel.tsx | 22 +- .../panel/timeseries/TimeSeriesTooltip.tsx | 12 +- .../plugins/panel/timeseries/utils.test.ts | 83 ++- public/app/plugins/panel/timeseries/utils.ts | 28 +- public/locales/en-US/grafana.json | 2 + 16 files changed, 834 insertions(+), 376 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 3fab1ac8bd5..eba76d2c198 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -377,10 +377,14 @@ export interface FeatureToggles { */ perPanelNonApplicableDrilldowns?: boolean; /** - * Enabled a group by action per panel + * Enables a group by action per panel */ panelGroupBy?: boolean; /** + * Enables filtering by grouping labels on the panel level through legend or tooltip + */ + perPanelFiltering?: boolean; + /** * Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard */ panelFilterVariable?: boolean; diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 47c30187175..aca18844459 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -1490,6 +1490,16 @@ export const versionedComponents = { }, }, }, + VizTooltipFooter: { + buttons: { + apply: { + ['12.1.0']: 'data-testid viz-tooltip-footer-apply-filters-button', + }, + applyInverse: { + ['12.1.0']: 'data-testid viz-tooltip-footer-apply-inverse-filters-button', + }, + }, + }, } satisfies VersionedSelectorGroup; export type VersionedComponents = typeof versionedComponents; diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts index a9d5334925a..3e34eed16f7 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts +++ b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts @@ -55,6 +55,15 @@ export interface PanelContext { */ onAddAdHocFilter?: (item: AdHocFilterItem) => void; + /** + * Returns filters based on existing grouping or an empty array + */ + getFiltersBasedOnGrouping?: (items: AdHocFilterItem[]) => AdHocFilterItem[]; + /** + * + * Used to apply multiple filters at once + */ + onAddAdHocFilters?: (items: AdHocFilterItem[]) => void; /** * Enables modifying thresholds directly from the panel * diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.test.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.test.tsx index f23961ef5e4..8e48305c0a9 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.test.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom-v5-compat'; import { Field, FieldType, LinkModel } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { VizTooltipFooter, AdHocFilterModel } from './VizTooltipFooter'; @@ -89,4 +90,65 @@ describe('VizTooltipFooter', () => { expect(screen.queryByRole('button', { name: /filter for 'testValue'/i })).not.toBeInTheDocument(); }); + + it('should render filter by grouping buttons and fire onclick', async () => { + const onForClick = jest.fn(); + const onOutClick = jest.fn(); + + const filterByGroupedLabels = { + onFilterForGroupedLabels: onForClick, + onFilterOutGroupedLabels: onOutClick, + }; + + render( + + + + ); + + const onForButton = screen.getByRole('button', { name: /Apply as filter/i }); + expect(onForButton).toBeInTheDocument(); + + const onOutButton = screen.getByRole('button', { name: /Apply as inverse filter/i }); + expect(onOutButton).toBeInTheDocument(); + + await userEvent.click(onForButton); + expect(onForClick).toHaveBeenCalled(); + + await userEvent.click(onOutButton); + expect(onOutClick).toHaveBeenCalled(); + }); + + it('should not render filter by grouping buttons when there are one-click links', () => { + const filterByGroupedLabels = { + onFilterForGroupedLabels: jest.fn(), + onFilterOutGroupedLabels: jest.fn(), + }; + + const onClick = jest.fn(); + const field: Field = { + name: '', + type: FieldType.string, + values: [], + config: {}, + }; + + const oneClickLink: LinkModel = { + href: '#', + onClick, + title: 'One Click Link', + origin: field, + target: undefined, + oneClick: true, + }; + + render( + + + + ); + + expect(screen.queryByTestId(selectors.components.VizTooltipFooter.buttons.apply)).not.toBeInTheDocument(); + expect(screen.queryByTestId(selectors.components.VizTooltipFooter.buttons.applyInverse)).not.toBeInTheDocument(); + }); }); diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx index b4eccaee66a..2bd324129f2 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx @@ -2,6 +2,7 @@ import { css } from '@emotion/css'; import { useMemo } from 'react'; import { ActionModel, Field, GrafanaTheme2, LinkModel, ThemeSpacingTokens } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { Trans } from '@grafana/i18n'; import { useStyles2 } from '../../themes/ThemeContext'; @@ -17,10 +18,16 @@ export interface AdHocFilterModel extends AdHocFilterItem { onClick: () => void; } +export interface FilterByGroupedLabelsModel { + onFilterForGroupedLabels?: () => void; + onFilterOutGroupedLabels?: () => void; +} + interface VizTooltipFooterProps { dataLinks: Array>; actions?: Array>; adHocFilters?: AdHocFilterModel[]; + filterByGroupedLabels?: FilterByGroupedLabelsModel; annotate?: () => void; } @@ -85,7 +92,13 @@ const renderActions = makeRenderLinksOrActions( (item, i) => ); -export const VizTooltipFooter = ({ dataLinks, actions = [], annotate, adHocFilters = [] }: VizTooltipFooterProps) => { +export const VizTooltipFooter = ({ + dataLinks, + actions = [], + annotate, + adHocFilters = [], + filterByGroupedLabels, +}: VizTooltipFooterProps) => { const styles = useStyles2(getStyles); const hasOneClickLink = useMemo(() => dataLinks.some((link) => link.oneClick === true), [dataLinks]); const hasOneClickAction = useMemo(() => actions.some((action) => action.oneClick === true), [actions]); @@ -105,6 +118,39 @@ export const VizTooltipFooter = ({ dataLinks, actions = [], annotate, adHocFilte ))} )} + + {!hasOneClickLink && !hasOneClickAction && filterByGroupedLabels && ( +
+ + + + +
+ )} {!hasOneClickLink && !hasOneClickAction && annotate != null && (