From 5c542478a71b45e4b31f29713da249c76ba8574a Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Thu, 21 Aug 2025 12:58:49 +0100 Subject: [PATCH 01/47] Folders: Update folder hook tests to use mock server handlers (#109341) --- .../src/handlers/all-handlers.ts | 12 +- .../src/handlers/api/folders/handlers.ts | 29 ++- .../folder.grafana.app/v1beta1/handlers.ts | 117 ++++++++++ .../apis/iam.grafana.app/v0alpha1/handlers.ts | 29 +++ .../api/clients/folder/v1beta1/hooks.test.ts | 202 ++++++------------ 5 files changed, 251 insertions(+), 138 deletions(-) create mode 100644 packages/grafana-test-utils/src/handlers/apis/folder.grafana.app/v1beta1/handlers.ts create mode 100644 packages/grafana-test-utils/src/handlers/apis/iam.grafana.app/v0alpha1/handlers.ts diff --git a/packages/grafana-test-utils/src/handlers/all-handlers.ts b/packages/grafana-test-utils/src/handlers/all-handlers.ts index 3e626ba021b..89662264875 100644 --- a/packages/grafana-test-utils/src/handlers/all-handlers.ts +++ b/packages/grafana-test-utils/src/handlers/all-handlers.ts @@ -2,8 +2,16 @@ import { HttpHandler } from 'msw'; import folderHandlers from './api/folders/handlers'; import teamsHandlers from './api/teams/handlers'; -import appPlatformFolderHandlers from './apis/dashboard.grafana.app/v0alpha1/handlers'; +import appPlatformDashboardv0alpha1Handlers from './apis/dashboard.grafana.app/v0alpha1/handlers'; +import appPlatformFolderv1beta1Handlers from './apis/folder.grafana.app/v1beta1/handlers'; +import appPlatformIamv0alpha1Handlers from './apis/iam.grafana.app/v0alpha1/handlers'; -const allHandlers: HttpHandler[] = [...teamsHandlers, ...folderHandlers, ...appPlatformFolderHandlers]; +const allHandlers: HttpHandler[] = [ + ...teamsHandlers, + ...folderHandlers, + ...appPlatformDashboardv0alpha1Handlers, + ...appPlatformFolderv1beta1Handlers, + ...appPlatformIamv0alpha1Handlers, +]; export default allHandlers; diff --git a/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts index 103b816ce3a..2dcac8b0160 100644 --- a/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts @@ -6,6 +6,27 @@ const [mockTree] = wellFormedTree(); const [mockTreeThatViewersCanEdit] = treeViewersCanEdit(); const collator = new Intl.Collator(); +// TODO: Generalise access control response and additional properties +const mockAccessControl = { + 'dashboards.permissions:write': true, + 'dashboards:create': true, +}; +const additionalProperties = { + canAdmin: true, + canDelete: true, + canEdit: true, + canSave: true, + created: '2025-07-14T12:07:36+02:00', + createdBy: 'Anonymous', + hasAcl: false, + id: 1, + orgId: 1, + updated: '2025-07-15T18:01:36+02:00', + updatedBy: 'Anonymous', + url: '/grafana/dashboards/f/1ca93012-1ffc-5d64-ae2e-54835c234c67/rik-cujahda-pi', + version: 1, +}; + const listFoldersHandler = () => http.get('/api/folders', ({ request }) => { const url = new URL(request.url); @@ -24,6 +45,7 @@ const listFoldersHandler = () => return { uid: folder.item.uid, title: folder.item.kind === 'folder' ? folder.item.title : "invalid - this shouldn't happen", + ...additionalProperties, }; }) .sort((a, b) => collator.compare(a.title, b.title)) // API always sorts by title @@ -33,10 +55,13 @@ const listFoldersHandler = () => }); const getFolderHandler = () => - http.get('/api/folders/:uid', ({ params }) => { + http.get('/api/folders/:uid', ({ params, request }) => { const { uid } = params; + const url = new URL(request.url); + const accessControlQueryParam = url.searchParams.get('accesscontrol'); const folder = mockTree.find((v) => v.item.uid === uid); + if (!folder) { return HttpResponse.json({ message: 'folder not found', status: 'not-found' }, { status: 404 }); } @@ -44,6 +69,8 @@ const getFolderHandler = () => return HttpResponse.json({ title: folder?.item.title, uid: folder?.item.uid, + ...additionalProperties, + ...(accessControlQueryParam ? { accessControl: mockAccessControl } : {}), }); }); diff --git a/packages/grafana-test-utils/src/handlers/apis/folder.grafana.app/v1beta1/handlers.ts b/packages/grafana-test-utils/src/handlers/apis/folder.grafana.app/v1beta1/handlers.ts new file mode 100644 index 00000000000..3823e539ba3 --- /dev/null +++ b/packages/grafana-test-utils/src/handlers/apis/folder.grafana.app/v1beta1/handlers.ts @@ -0,0 +1,117 @@ +import { HttpResponse, http } from 'msw'; + +import { wellFormedTree } from '../../../../fixtures/folders'; + +const [mockTree] = wellFormedTree(); + +const getFolderHandler = () => + http.get<{ folderUid: string; namespace: string }>( + '/apis/folder.grafana.app/v1beta1/namespaces/:namespace/folders/:folderUid', + ({ params }) => { + const { folderUid, namespace } = params; + const response = mockTree.find(({ item }) => { + return item.uid === folderUid; + }); + + if (!response) { + return HttpResponse.json( + { + kind: 'Status', + apiVersion: 'v1', + metadata: {}, + status: 'Failure', + message: 'folder not found', + code: 404, + }, + { status: 404 } + ); + } + + return HttpResponse.json({ + kind: 'Folder', + apiVersion: 'folder.grafana.app/v1beta1', + metadata: { + name: response.item.uid, + namespace, + uid: response.item.uid, + creationTimestamp: '2023-01-01T00:00:00Z', + annotations: { + // TODO: Generalise annotations in fixture data + 'grafana.app/createdBy': 'user:1', + 'grafana.app/updatedBy': 'user:2', + 'grafana.app/managedBy': 'user', + 'grafana.app/updatedTimestamp': '2024-01-01T00:00:00Z', + 'grafana.app/folder': response.item.kind === 'folder' ? response.item.parentUID : undefined, + }, + labels: { + 'grafana.app/deprecatedInternalID': '123', + }, + }, + spec: { title: response.item.title, description: '' }, + status: {}, + }); + } + ); + +const getFolderParentsHandler = () => + http.get<{ folderUid: string; namespace: string }>( + '/apis/folder.grafana.app/v1beta1/namespaces/:namespace/folders/:folderUid/parents', + ({ params }) => { + const { folderUid } = params; + + const folder = mockTree.find(({ item }) => { + return item.kind === 'folder' && item.uid === folderUid; + }); + if (!folder || folder.item.kind !== 'folder') { + return HttpResponse.json({ + kind: 'Status', + apiVersion: 'v1', + metadata: {}, + status: 'Failure', + message: 'folder not found', + code: 404, + }); + } + + const findParents = (parents: Array<(typeof mockTree)[number]>, folderUid?: string) => { + if (!folderUid) { + return parents; + } + + const parent = mockTree.find(({ item }) => { + return item.kind === 'folder' && item.uid === folderUid; + }); + + if (parent) { + parents.push(parent); + return findParents(parents, parent.item.kind === 'folder' ? parent.item.parentUID : undefined); + } + return parents; + }; + + const parents = findParents([], folder?.item?.parentUID); + + const mapped = parents.map((parent) => ({ + name: parent.item.uid, + title: parent.item.title, + parentUid: parent.item.kind === 'folder' ? parent.item.parentUID : undefined, + })); + + if (folder) { + mapped.push({ + name: folder.item.uid, + title: folder.item.title, + parentUid: folder.item.parentUID, + }); + } + + return HttpResponse.json({ + kind: 'FolderInfoList', + apiVersion: 'folder.grafana.app/v1beta1', + metadata: {}, + items: mapped, + }); + } + ); + +export default [getFolderHandler(), getFolderParentsHandler()]; diff --git a/packages/grafana-test-utils/src/handlers/apis/iam.grafana.app/v0alpha1/handlers.ts b/packages/grafana-test-utils/src/handlers/apis/iam.grafana.app/v0alpha1/handlers.ts new file mode 100644 index 00000000000..7a15817564b --- /dev/null +++ b/packages/grafana-test-utils/src/handlers/apis/iam.grafana.app/v0alpha1/handlers.ts @@ -0,0 +1,29 @@ +import { HttpResponse, http } from 'msw'; + +const getDisplayMapping = () => + http.get<{ namespace: string }>('/apis/iam.grafana.app/v0alpha1/namespaces/:namespace/display', ({ request }) => { + const url = new URL(request.url); + const keys = url.searchParams.getAll('key'); + + // Turn query params such as `user:1` into mock mapping of `User 1` etc. + const mockMappings = keys.map((key) => { + const [_, id] = key.split(':'); + const displayName = `User ${id}`; + return { + identity: { + type: 'user', + name: `u00000000${id}`, + }, + displayName, + internalId: parseInt(id, 10), + }; + }); + + return HttpResponse.json({ + metadata: {}, + keys, + display: mockMappings, + }); + }); + +export default [getDisplayMapping()]; diff --git a/public/app/api/clients/folder/v1beta1/hooks.test.ts b/public/app/api/clients/folder/v1beta1/hooks.test.ts index 5d7de2639f2..e974c563b33 100644 --- a/public/app/api/clients/folder/v1beta1/hooks.test.ts +++ b/public/app/api/clients/folder/v1beta1/hooks.test.ts @@ -1,175 +1,107 @@ -import { QueryStatus } from '@reduxjs/toolkit/query'; -import { renderHook } from '@testing-library/react'; +import { renderHook, getWrapper, waitFor } from 'test/test-utils'; -import { config } from '@grafana/runtime'; -import { useGetFolderQuery as useGetFolderQueryLegacy } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; - -import { - AnnoKeyCreatedBy, - AnnoKeyFolder, - AnnoKeyManagerKind, - AnnoKeyUpdatedBy, - AnnoKeyUpdatedTimestamp, - DeprecatedInternalId, -} from '../../../../features/apiserver/types'; -import { useGetDisplayMappingQuery } from '../../iam/v0alpha1'; +import { config, setBackendSrv } from '@grafana/runtime'; +import { setupMockServer } from '@grafana/test-utils/server'; +import { getFolderFixtures } from '@grafana/test-utils/unstable'; +import { backendSrv } from 'app/core/services/backend_srv'; import { useGetFolderQueryFacade } from './hooks'; -import { useGetFolderQuery, useGetFolderParentsQuery } from './index'; +setBackendSrv(backendSrv); +setupMockServer(); -// Mocks for the hooks used inside useGetFolderQueryFacade -jest.mock('./index', () => ({ - useGetFolderQuery: jest.fn(), - useGetFolderParentsQuery: jest.fn(), -})); +const [_, { folderA, folderA_folderA }] = getFolderFixtures(); -jest.mock('app/features/browse-dashboards/api/browseDashboardsAPI', () => ({ - useGetFolderQuery: jest.fn(), -})); +const expectedUid = folderA_folderA.item.uid; +const expectedTitle = folderA_folderA.item.title; +const urlSlug = expectedTitle.toLowerCase().replace(/ /g, '-').replace(/[\.]/g, ''); +const expectedUrl = `/grafana/dashboards/f/${expectedUid}/${urlSlug}`; -jest.mock('../../iam/v0alpha1', () => ({ - useGetDisplayMappingQuery: jest.fn(), -})); +const parentUrlSlug = folderA.item.title.toLowerCase().replace(/ /g, '-').replace(/[\.]/g, ''); +const expectedParentUrl = `/grafana/dashboards/f/${folderA.item.uid}/${parentUrlSlug}`; -// Mock config and constants -jest.mock('@grafana/runtime', () => { - const runtime = jest.requireActual('@grafana/runtime'); - return { - ...runtime, - config: { - ...runtime.config, - featureToggles: { - ...runtime.config.featureToggles, - foldersAppPlatformAPI: true, - }, - appSubUrl: '/grafana', - }, - }; -}); - -const mockFolder = { - data: { - metadata: { - name: 'folder-uid', - labels: { [DeprecatedInternalId]: '123' }, - annotations: { - [AnnoKeyUpdatedBy]: 'user-1', - [AnnoKeyCreatedBy]: 'user-2', - [AnnoKeyFolder]: 'parent-uid', - [AnnoKeyManagerKind]: 'user', - [AnnoKeyUpdatedTimestamp]: '2024-01-01T00:00:00Z', - }, - creationTimestamp: '2023-01-01T00:00:00Z', - generation: 2, - }, - spec: { title: 'Test Folder' }, - }, - ...getResponseAttributes(), +const renderFolderHook = async () => { + const { result } = renderHook(() => useGetFolderQueryFacade(folderA_folderA.item.uid), { + wrapper: getWrapper({}), + }); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + return result; }; -const mockParents = { - data: { items: [{ name: 'parent-uid', title: 'Parent Folder' }] }, - ...getResponseAttributes(), -}; - -const mockLegacyResponse = { - data: { - id: 1, - uid: 'uiduiduid', - orgId: 1, - title: 'bar', - url: '/dashboards/f/uiduiduid/bar', - hasAcl: false, - canSave: true, - canEdit: true, - canAdmin: true, - canDelete: true, - createdBy: 'Anonymous', - created: '2025-07-14T12:07:36+02:00', - updatedBy: 'Anonymous', - updated: '2025-07-15T18:01:36+02:00', - version: 1, - accessControl: { - 'dashboards.permissions:write': true, - 'dashboards:create': true, - }, - }, - ...getResponseAttributes(), -}; - -const mockUserDisplay = { - data: { - keys: ['user-1', 'user-2'], - display: [{ displayName: 'User One' }, { displayName: 'User Two' }], - }, - ...getResponseAttributes(), -}; +const originalToggles = { ...config.featureToggles }; +const originalAppSubUrl = String(config.appSubUrl); describe('useGetFolderQueryFacade', () => { - const oldToggleValue = config.featureToggles.foldersAppPlatformAPI; - - afterAll(() => { - config.featureToggles.foldersAppPlatformAPI = oldToggleValue; - }); - beforeEach(() => { - (useGetFolderQuery as jest.Mock).mockReturnValue(mockFolder); - (useGetFolderParentsQuery as jest.Mock).mockReturnValue(mockParents); - (useGetDisplayMappingQuery as jest.Mock).mockReturnValue(mockUserDisplay); - (useGetFolderQueryLegacy as jest.Mock).mockReturnValue(mockLegacyResponse); + config.appSubUrl = '/grafana'; }); - it('merges multiple responses into a single FolderDTO-like object if flag is true', () => { + afterEach(() => { + config.featureToggles = originalToggles; + config.appSubUrl = originalAppSubUrl; + }); + + it('merges multiple responses into a single FolderDTO-like object if flag is true', async () => { config.featureToggles.foldersAppPlatformAPI = true; - const { result } = renderHook(() => useGetFolderQueryFacade('folder-uid')); + + const result = await renderFolderHook(); + expect(result.current.data).toMatchObject({ canAdmin: true, canDelete: true, canEdit: true, canSave: true, created: '2023-01-01T00:00:00Z', - createdBy: 'User Two', + createdBy: 'User 1', hasAcl: false, id: 123, - parentUid: 'parent-uid', + parentUid: folderA.item.uid, managedBy: 'user', - title: 'Test Folder', - uid: 'folder-uid', + title: expectedTitle, + uid: expectedUid, updated: '2024-01-01T00:00:00Z', - updatedBy: 'User One', - url: '/grafana/dashboards/f/folder-uid/test-folder', - version: 2, + updatedBy: 'User 2', + url: expectedUrl, + version: 1, accessControl: { 'dashboards.permissions:write': true, 'dashboards:create': true, }, parents: [ { - title: 'Parent Folder', - uid: 'parent-uid', - url: '/grafana/dashboards/f/parent-uid/parent-folder', + title: folderA.item.title, + uid: folderA.item.uid, + url: expectedParentUrl, }, ], }); }); - it('returns legacy folder response if flag is false', () => { + it('returns legacy folder response if flag is false', async () => { config.featureToggles.foldersAppPlatformAPI = false; - const { result } = renderHook(() => useGetFolderQueryFacade('folder-uid')); - expect(result.current.data).toMatchObject(mockLegacyResponse.data); + const result = await renderFolderHook(); + expect(result.current.data).toMatchObject({ + id: 1, + title: folderA_folderA.item.title, + url: expectedUrl, + uid: expectedUid, + orgId: 1, + hasAcl: false, + canSave: true, + canEdit: true, + canAdmin: true, + canDelete: true, + createdBy: 'Anonymous', + created: '2025-07-14T12:07:36+02:00', + updatedBy: 'Anonymous', + updated: '2025-07-15T18:01:36+02:00', + version: 1, + accessControl: { + 'dashboards.permissions:write': true, + 'dashboards:create': true, + }, + }); }); }); - -function getResponseAttributes() { - return { - status: QueryStatus.fulfilled, - isUninitialized: false, - isLoading: false, - isFetching: false, - isSuccess: true, - isError: false, - error: undefined, - refetch: jest.fn(), - }; -} From 88507add9da34bd84a49f94db1f601d8cd0d76c7 Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Thu, 21 Aug 2025 14:50:52 +0200 Subject: [PATCH 02/47] Secrets: Fix OpenAPI definition for create resource (#109960) --- apps/secret/kinds/v1beta1/securevalue.cue | 7 +++++-- .../apis/secret/v1beta1/securevalue_status_gen.go | 3 +++ .../pkg/apis/secret/v1beta1/zz_openapi_gen.go | 15 ++++++++------- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/secret/kinds/v1beta1/securevalue.cue b/apps/secret/kinds/v1beta1/securevalue.cue index 9fef8e63f46..6305bb563ff 100644 --- a/apps/secret/kinds/v1beta1/securevalue.cue +++ b/apps/secret/kinds/v1beta1/securevalue.cue @@ -19,7 +19,7 @@ SecureValueSpec: { // Minimum and maximum lengths in bytes. // +k8s:validation:minLength=1 // +k8s:validation:maxLength=24576 - // +optional + // +optional value?: #ExposedSecureValue // When using a third-party keeper, the `ref` is used to reference a value inside the remote storage. @@ -41,13 +41,16 @@ SecureValueSpec: { // +k8s:validation:maxItems=64 // +k8s:validation:uniqueItems=true // +listType=atomic - // +optional + // +optional decrypters?: [...string] & list.UniqueItems() & list.MaxItems(64) } SecureValueStatus: { + // Version of the secure value. Cannot be set. + // +optional version: int64 & >=0 + // External ID where the secret is stored. Cannot be set. // +optional externalID: string } diff --git a/apps/secret/pkg/apis/secret/v1beta1/securevalue_status_gen.go b/apps/secret/pkg/apis/secret/v1beta1/securevalue_status_gen.go index eac159c6035..a2534ab464d 100644 --- a/apps/secret/pkg/apis/secret/v1beta1/securevalue_status_gen.go +++ b/apps/secret/pkg/apis/secret/v1beta1/securevalue_status_gen.go @@ -22,10 +22,13 @@ func NewSecureValuestatusOperatorState() *SecureValuestatusOperatorState { // +k8s:openapi-gen=true type SecureValueStatus struct { + // Version of the secure value. Cannot be set. + // +optional Version int64 `json:"version"` // operatorStates is a map of operator ID to operator state evaluations. // Any operator which consumes this kind SHOULD add its state evaluation information to this field. OperatorStates map[string]SecureValuestatusOperatorState `json:"operatorStates,omitempty"` + // External ID where the secret is stored. Cannot be set. // +optional ExternalID string `json:"externalID"` // additionalFields is reserved for future use diff --git a/apps/secret/pkg/apis/secret/v1beta1/zz_openapi_gen.go b/apps/secret/pkg/apis/secret/v1beta1/zz_openapi_gen.go index 87ba7504932..7a3b6d090c0 100644 --- a/apps/secret/pkg/apis/secret/v1beta1/zz_openapi_gen.go +++ b/apps/secret/pkg/apis/secret/v1beta1/zz_openapi_gen.go @@ -633,9 +633,10 @@ func schema_pkg_apis_secret_v1beta1_SecureValueStatus(ref common.ReferenceCallba Properties: map[string]spec.Schema{ "version": { SchemaProps: spec.SchemaProps{ - Default: 0, - Type: []string{"integer"}, - Format: "int64", + Description: "Version of the secure value. Cannot be set.", + Default: 0, + Type: []string{"integer"}, + Format: "int64", }, }, "operatorStates": { @@ -655,9 +656,10 @@ func schema_pkg_apis_secret_v1beta1_SecureValueStatus(ref common.ReferenceCallba }, "externalID": { SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", + Description: "External ID where the secret is stored. Cannot be set.", + Default: "", + Type: []string{"string"}, + Format: "", }, }, "additionalFields": { @@ -676,7 +678,6 @@ func schema_pkg_apis_secret_v1beta1_SecureValueStatus(ref common.ReferenceCallba }, }, }, - Required: []string{"version"}, }, }, Dependencies: []string{ From 2b254ed62301243ec6ab9a15a1e1eddbb158a33b Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Thu, 21 Aug 2025 15:20:07 +0200 Subject: [PATCH 03/47] Zanzana: Add server side metrics (#109923) * Zanzana: Add metrics to server side * Zanzana: Collect check duration * add metrics for other methods --- pkg/server/module_server.go | 2 +- pkg/services/authz/zanzana.go | 8 +++-- pkg/services/authz/zanzana/server.go | 5 +-- pkg/services/authz/zanzana/server/metrics.go | 31 +++++++++++++++++++ pkg/services/authz/zanzana/server/server.go | 9 ++++-- .../authz/zanzana/server/server_check.go | 9 ++++-- .../authz/zanzana/server/server_list.go | 9 ++++-- .../authz/zanzana/server/server_read.go | 5 +++ .../authz/zanzana/server/server_test.go | 3 +- .../authz/zanzana/server/server_write.go | 5 +++ 10 files changed, 70 insertions(+), 16 deletions(-) create mode 100644 pkg/services/authz/zanzana/server/metrics.go diff --git a/pkg/server/module_server.go b/pkg/server/module_server.go index 19cb24db04b..38e3c3c16e3 100644 --- a/pkg/server/module_server.go +++ b/pkg/server/module_server.go @@ -189,7 +189,7 @@ func (s *ModuleServer) Run() error { }) m.RegisterModule(modules.ZanzanaServer, func() (services.Service, error) { - return authz.ProvideZanzanaService(s.cfg, s.features) + return authz.ProvideZanzanaService(s.cfg, s.features, s.registerer) }) m.RegisterModule(modules.FrontendServer, func() (services.Service, error) { diff --git a/pkg/services/authz/zanzana.go b/pkg/services/authz/zanzana.go index 35ac04151f2..c42befe8d12 100644 --- a/pkg/services/authz/zanzana.go +++ b/pkg/services/authz/zanzana.go @@ -88,7 +88,7 @@ func ProvideZanzana(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, features return nil, fmt.Errorf("failed to start zanzana: %w", err) } - srv, err := zanzana.NewServer(cfg.ZanzanaServer, openfga, logger, tracer) + srv, err := zanzana.NewServer(cfg.ZanzanaServer, openfga, logger, tracer, reg) if err != nil { return nil, fmt.Errorf("failed to start zanzana: %w", err) } @@ -127,11 +127,12 @@ type ZanzanaService interface { var _ ZanzanaService = (*Zanzana)(nil) // ProvideZanzanaService is used to register zanzana as a module so we can run it seperatly from grafana. -func ProvideZanzanaService(cfg *setting.Cfg, features featuremgmt.FeatureToggles) (*Zanzana, error) { +func ProvideZanzanaService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, reg prometheus.Registerer) (*Zanzana, error) { s := &Zanzana{ cfg: cfg, features: features, logger: log.New("zanzana.server"), + reg: reg, } s.BasicService = services.NewBasicService(s.start, s.running, s.stopping).WithName("zanzana") @@ -147,6 +148,7 @@ type Zanzana struct { logger log.Logger handle grpcserver.Provider features featuremgmt.FeatureToggles + reg prometheus.Registerer } func (z *Zanzana) start(ctx context.Context) error { @@ -172,7 +174,7 @@ func (z *Zanzana) start(ctx context.Context) error { return fmt.Errorf("failed to start zanzana: %w", err) } - zanzanaServer, err := zanzana.NewServer(z.cfg.ZanzanaServer, openfgaServer, z.logger, tracer) + zanzanaServer, err := zanzana.NewServer(z.cfg.ZanzanaServer, openfgaServer, z.logger, tracer, z.reg) if err != nil { return fmt.Errorf("failed to start zanzana: %w", err) } diff --git a/pkg/services/authz/zanzana/server.go b/pkg/services/authz/zanzana/server.go index 1e46aa52324..91a9800efbd 100644 --- a/pkg/services/authz/zanzana/server.go +++ b/pkg/services/authz/zanzana/server.go @@ -5,6 +5,7 @@ import ( openfgaserver "github.com/openfga/openfga/pkg/server" openfgastorage "github.com/openfga/openfga/pkg/storage" + "github.com/prometheus/client_golang/prometheus" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -13,8 +14,8 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -func NewServer(cfg setting.ZanzanaServerSettings, openfga server.OpenFGAServer, logger log.Logger, tracer tracing.Tracer) (*server.Server, error) { - return server.NewServer(cfg, openfga, logger, tracer) +func NewServer(cfg setting.ZanzanaServerSettings, openfga server.OpenFGAServer, logger log.Logger, tracer tracing.Tracer, reg prometheus.Registerer) (*server.Server, error) { + return server.NewServer(cfg, openfga, logger, tracer, reg) } func NewHealthServer(target server.DiagnosticServer) *server.HealthServer { diff --git a/pkg/services/authz/zanzana/server/metrics.go b/pkg/services/authz/zanzana/server/metrics.go new file mode 100644 index 00000000000..7df91aed169 --- /dev/null +++ b/pkg/services/authz/zanzana/server/metrics.go @@ -0,0 +1,31 @@ +package server + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +const ( + metricsNamespace = "iam" + metricsSubSystem = "authz_zanzana_server" +) + +type metrics struct { + // requestDurationSeconds is a summary for zanzana server request duration + requestDurationSeconds *prometheus.HistogramVec +} + +func newZanzanaServerMetrics(reg prometheus.Registerer) *metrics { + return &metrics{ + requestDurationSeconds: promauto.With(reg).NewHistogramVec( + prometheus.HistogramOpts{ + Name: "request_duration_seconds", + Help: "Histogram for zanzana server request duration", + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + Buckets: prometheus.ExponentialBuckets(0.00001, 4, 10), + }, + []string{"method", "namespace"}, + ), + } +} diff --git a/pkg/services/authz/zanzana/server/server.go b/pkg/services/authz/zanzana/server/server.go index f599fa6ae57..85635260c26 100644 --- a/pkg/services/authz/zanzana/server/server.go +++ b/pkg/services/authz/zanzana/server/server.go @@ -9,6 +9,7 @@ import ( "github.com/fullstorydev/grpchan/inprocgrpc" authzv1 "github.com/grafana/authlib/authz/proto/v1" openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/prometheus/client_golang/prometheus" "google.golang.org/protobuf/types/known/wrapperspb" dashboardV2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1" @@ -43,8 +44,9 @@ type Server struct { storesMU *sync.Mutex cache *localcache.CacheService - logger log.Logger - tracer tracing.Tracer + logger log.Logger + tracer tracing.Tracer + metrics *metrics } type storeInfo struct { @@ -52,7 +54,7 @@ type storeInfo struct { ModelID string } -func NewServer(cfg setting.ZanzanaServerSettings, openfga OpenFGAServer, logger log.Logger, tracer tracing.Tracer) (*Server, error) { +func NewServer(cfg setting.ZanzanaServerSettings, openfga OpenFGAServer, logger log.Logger, tracer tracing.Tracer, reg prometheus.Registerer) (*Server, error) { channel := &inprocgrpc.Channel{} openfgav1.RegisterOpenFGAServiceServer(channel, openfga) openFGAClient := openfgav1.NewOpenFGAServiceClient(channel) @@ -66,6 +68,7 @@ func NewServer(cfg setting.ZanzanaServerSettings, openfga OpenFGAServer, logger cache: localcache.New(cfg.CacheSettings.CheckQueryCacheTTL, cacheCleanInterval), logger: logger, tracer: tracer, + metrics: newZanzanaServerMetrics(reg), } return s, nil diff --git a/pkg/services/authz/zanzana/server/server_check.go b/pkg/services/authz/zanzana/server/server_check.go index 0faadea7d0a..e9712b6fe21 100644 --- a/pkg/services/authz/zanzana/server/server_check.go +++ b/pkg/services/authz/zanzana/server/server_check.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" authzv1 "github.com/grafana/authlib/authz/proto/v1" openfgav1 "github.com/openfga/api/proto/openfga/v1" @@ -16,9 +17,11 @@ import ( func (s *Server) Check(ctx context.Context, r *authzv1.CheckRequest) (*authzv1.CheckResponse, error) { ctx, span := s.tracer.Start(ctx, "server.Check") defer span.End() - span.SetAttributes( - attribute.String("namespace", r.GetNamespace()), - ) + span.SetAttributes(attribute.String("namespace", r.GetNamespace())) + + defer func(t time.Time) { + s.metrics.requestDurationSeconds.WithLabelValues("server.Check", r.GetNamespace()).Observe(time.Since(t).Seconds()) + }(time.Now()) res, err := s.check(ctx, r) if err != nil { diff --git a/pkg/services/authz/zanzana/server/server_list.go b/pkg/services/authz/zanzana/server/server_list.go index b8dfcfab4a5..a5fa19e3896 100644 --- a/pkg/services/authz/zanzana/server/server_list.go +++ b/pkg/services/authz/zanzana/server/server_list.go @@ -8,6 +8,7 @@ import ( "hash/fnv" "io" "strings" + "time" authzv1 "github.com/grafana/authlib/authz/proto/v1" openfgav1 "github.com/openfga/api/proto/openfga/v1" @@ -19,9 +20,11 @@ import ( func (s *Server) List(ctx context.Context, r *authzv1.ListRequest) (*authzv1.ListResponse, error) { ctx, span := s.tracer.Start(ctx, "server.List") defer span.End() - span.SetAttributes( - attribute.String("namespace", r.GetNamespace()), - ) + span.SetAttributes(attribute.String("namespace", r.GetNamespace())) + + defer func(t time.Time) { + s.metrics.requestDurationSeconds.WithLabelValues("server.List", r.GetNamespace()).Observe(time.Since(t).Seconds()) + }(time.Now()) res, err := s.list(ctx, r) if err != nil { diff --git a/pkg/services/authz/zanzana/server/server_read.go b/pkg/services/authz/zanzana/server/server_read.go index da4141dce1c..d78d2fcbc0b 100644 --- a/pkg/services/authz/zanzana/server/server_read.go +++ b/pkg/services/authz/zanzana/server/server_read.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" openfgav1 "github.com/openfga/api/proto/openfga/v1" @@ -15,6 +16,10 @@ func (s *Server) Read(ctx context.Context, req *authzextv1.ReadRequest) (*authze ctx, span := s.tracer.Start(ctx, "server.Read") defer span.End() + defer func(t time.Time) { + s.metrics.requestDurationSeconds.WithLabelValues("server.Read", req.GetNamespace()).Observe(time.Since(t).Seconds()) + }(time.Now()) + res, err := s.read(ctx, req) if err != nil { s.logger.Error("failed to perform read request", "error", err, "namespace", req.GetNamespace()) diff --git a/pkg/services/authz/zanzana/server/server_test.go b/pkg/services/authz/zanzana/server/server_test.go index 7b3c57155de..5d700c96a17 100644 --- a/pkg/services/authz/zanzana/server/server_test.go +++ b/pkg/services/authz/zanzana/server/server_test.go @@ -5,6 +5,7 @@ import ( "testing" openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" authnlib "github.com/grafana/authlib/authn" @@ -92,7 +93,7 @@ func setup(t *testing.T, testDB db.DB, cfg *setting.Cfg) *Server { openfga, err := NewOpenFGAServer(cfg.ZanzanaServer, store) require.NoError(t, err) - srv, err := NewServer(cfg.ZanzanaServer, openfga, log.NewNopLogger(), tracing.NewNoopTracerService()) + srv, err := NewServer(cfg.ZanzanaServer, openfga, log.NewNopLogger(), tracing.NewNoopTracerService(), prometheus.NewRegistry()) require.NoError(t, err) storeInf, err := srv.getStoreInfo(context.Background(), namespace) diff --git a/pkg/services/authz/zanzana/server/server_write.go b/pkg/services/authz/zanzana/server/server_write.go index 9ca10a38ec5..3cbbc6a5a32 100644 --- a/pkg/services/authz/zanzana/server/server_write.go +++ b/pkg/services/authz/zanzana/server/server_write.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" openfgav1 "github.com/openfga/api/proto/openfga/v1" @@ -15,6 +16,10 @@ func (s *Server) Write(ctx context.Context, req *authzextv1.WriteRequest) (*auth ctx, span := s.tracer.Start(ctx, "server.Write") defer span.End() + defer func(t time.Time) { + s.metrics.requestDurationSeconds.WithLabelValues("server.Write", req.GetNamespace()).Observe(time.Since(t).Seconds()) + }(time.Now()) + res, err := s.write(ctx, req) if err != nil { s.logger.Error("failed to perform write request", "error", err, "namespace", req.GetNamespace()) From 01d48e26fe5af2de07ac4378f43f0a620685b3c6 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Thu, 21 Aug 2025 15:50:05 +0200 Subject: [PATCH 04/47] New Logs Context: Add time window option (#109901) * LogLineDetails: add open and close events * LogLineContext: add interval picker * Log Context: allow to customize the time window around the log * Loki: support custom time window * LogLineContext: implement variable time window * Remove console * LogLineContext: store and format options * LogLineContext: replace radio with combobox * LogLineContext: run time window on the first request * InfiniteScroll: use loading to prevent fake events * Chore: update old comment * Minor reorg * Clean up unnecessary styles * LogLineContext: fix overflow and scroll * LogList: fix loading prop * Update comment * Translations * Update public/app/features/logs/components/panel/LogLineContext.tsx * LogLineContext: move default to constant * LogLineContext: add test * Prettier * New Logs Context: Generic DS support for time window option (#109934) * chore: add supportsAdjustableWindow to logs context interface * Build --------- Co-authored-by: Galen Kistler <109082771+gtk-grafana@users.noreply.github.com> --- packages/grafana-data/src/types/logs.ts | 5 + public/app/features/explore/Logs/Logs.tsx | 20 +- .../logs/components/panel/InfiniteScroll.tsx | 6 +- .../components/panel/LogLineContext.test.tsx | 29 ++- .../logs/components/panel/LogLineContext.tsx | 230 ++++++++++++------ .../logs/components/panel/LogList.tsx | 4 +- .../datasource/loki/LogContextProvider.ts | 11 +- .../app/plugins/datasource/loki/datasource.ts | 4 + public/locales/en-US/grafana.json | 2 + 9 files changed, 222 insertions(+), 89 deletions(-) diff --git a/packages/grafana-data/src/types/logs.ts b/packages/grafana-data/src/types/logs.ts index 71b93ab7e39..94f1d97518c 100644 --- a/packages/grafana-data/src/types/logs.ts +++ b/packages/grafana-data/src/types/logs.ts @@ -137,6 +137,8 @@ export interface LogRowContextOptions { direction?: LogRowContextQueryDirection; limit?: number; scopedVars?: ScopedVars; + // Optional. Size of the time window to get logs before of after the referenced entry. + timeWindowMs?: number; } export enum LogRowContextQueryDirection { @@ -181,6 +183,9 @@ export interface DataSourceWithLogsContextSupport { diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 629f9ee37c2..1d542268cd0 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -581,10 +581,12 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { let onCloseContext = useCallback(() => { setContextOpen(false); setContextRow(undefined); - reportInteraction('grafana_explore_logs_log_context_closed', { - datasourceType: contextRow?.datasourceType, - logRowUid: contextRow?.uid, - }); + if (!config.featureToggles.newLogContext) { + reportInteraction('grafana_explore_logs_log_context_closed', { + datasourceType: contextRow?.datasourceType, + logRowUid: contextRow?.uid, + }); + } onCloseCallbackRef?.current(); }, [contextRow?.datasourceType, contextRow?.uid, onCloseCallbackRef]); @@ -592,10 +594,12 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { // we are setting the `contextOpen` open state and passing it down to the `LogRow` in order to highlight the row when a LogContext is open setContextOpen(true); setContextRow(row); - reportInteraction('grafana_explore_logs_log_context_opened', { - datasourceType: row.datasourceType, - logRowUid: row.uid, - }); + if (!config.featureToggles.newLogContext) { + reportInteraction('grafana_explore_logs_log_context_opened', { + datasourceType: row.datasourceType, + logRowUid: row.uid, + }); + } onCloseCallbackRef.current = onClose; }, []); diff --git a/public/app/features/logs/components/panel/InfiniteScroll.tsx b/public/app/features/logs/components/panel/InfiniteScroll.tsx index 6dff87f65e9..ef78316b69f 100644 --- a/public/app/features/logs/components/panel/InfiniteScroll.tsx +++ b/public/app/features/logs/components/panel/InfiniteScroll.tsx @@ -26,6 +26,7 @@ export interface Props { displayedFields: string[]; handleOverflow: (index: number, id: string, height?: number) => void; infiniteScrollMode: InfiniteScrollMode; + loading?: boolean; loadMore?: LoadMoreLogsType; logs: LogListModel[]; onClick: (e: MouseEvent, log: LogListModel) => void; @@ -50,6 +51,7 @@ export const InfiniteScroll = ({ displayedFields, handleOverflow, infiniteScrollMode, + loading, loadMore, logs, onClick, @@ -102,12 +104,12 @@ export const InfiniteScroll = ({ }, [prevSortOrder, sortOrder]); useEffect(() => { - if (autoScroll) { + if (autoScroll && !loading) { setInitialScrollPosition(scrollToLogLineRef.current); scrollToLogLineRef.current = undefined; setAutoScroll(false); } - }, [autoScroll, setInitialScrollPosition]); + }, [autoScroll, loading, setInitialScrollPosition]); const onLoadMore = useCallback( (scrollDirection: ScrollDirection) => { diff --git a/public/app/features/logs/components/panel/LogLineContext.test.tsx b/public/app/features/logs/components/panel/LogLineContext.test.tsx index 31f77df4f3e..8b88a9cb2c9 100644 --- a/public/app/features/logs/components/panel/LogLineContext.test.tsx +++ b/public/app/features/logs/components/panel/LogLineContext.test.tsx @@ -10,7 +10,7 @@ import { import { dataFrameToLogsModel } from '../../logsModel'; -import { LogLineContext } from './LogLineContext'; +import { DEFAULT_TIME_WINDOW, LogLineContext, PAGE_SIZE } from './LogLineContext'; jest.mock('@grafana/assistant', () => ({ ...jest.requireActual('@grafana/assistant'), @@ -542,4 +542,31 @@ describe('LogLineContext', () => { await waitFor(() => expect(dispatchMock).toHaveBeenCalledWith(splitOpenSym)); }); + + test('Allows to change the time window surrounding the log', async () => { + row.datasourceType = 'loki'; + + render( + {}} + getRowContext={getRowContext} + timeZone={timeZone} + sortOrder={LogsSortOrder.Descending} + /> + ); + await waitFor(() => + expect(getRowContext).toHaveBeenCalledWith(expect.anything(), { + limit: PAGE_SIZE, + direction: LogRowContextQueryDirection.Forward, + timeWindowMs: DEFAULT_TIME_WINDOW, + }) + ); + expect(getRowContext).toHaveBeenCalledWith(expect.anything(), { + limit: PAGE_SIZE, + direction: LogRowContextQueryDirection.Backward, + timeWindowMs: DEFAULT_TIME_WINDOW, + }); + }); }); diff --git a/public/app/features/logs/components/panel/LogLineContext.tsx b/public/app/features/logs/components/panel/LogLineContext.tsx index db28cdc0fdb..c203eef3c9e 100644 --- a/public/app/features/logs/components/panel/LogLineContext.tsx +++ b/public/app/features/logs/components/panel/LogLineContext.tsx @@ -3,26 +3,30 @@ import { partition } from 'lodash'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + AbsoluteTimeRange, + CoreApp, DataQueryResponse, + DataSourceApi, DataSourceWithLogsContextSupport, + dateTime, + EventBusSrv, + formattedValueToString, + getValueFormat, GrafanaTheme2, + hasLogsContextSupport, + LoadingState, LogRowContextOptions, LogRowContextQueryDirection, + LogRowModel, LogsDedupStrategy, LogsSortOrder, - dateTime, - TimeRange, - LoadingState, - CoreApp, - LogRowModel, - AbsoluteTimeRange, - EventBusSrv, store, + TimeRange, } from '@grafana/data'; -import { Trans, t } from '@grafana/i18n'; -import { config, reportInteraction } from '@grafana/runtime'; +import { t, Trans } from '@grafana/i18n'; +import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime'; import { DataQuery, TimeZone } from '@grafana/schema'; -import { Button, Collapse, Modal, useTheme2 } from '@grafana/ui'; +import { Button, Collapse, Combobox, ComboboxOption, InlineLabel, Modal, Stack, useTheme2 } from '@grafana/ui'; import { splitOpen } from 'app/features/explore/state/main'; import { useDispatch } from 'app/types/store'; @@ -56,7 +60,8 @@ interface LogLineContextProps { onClickHideField?: (key: string) => void; } -const PAGE_SIZE = 100; +export const PAGE_SIZE = 100; +export const DEFAULT_TIME_WINDOW = 7200000; export const LogLineContext = memo( ({ @@ -84,6 +89,14 @@ export const LogLineContext = memo( const [aboveState, setAboveState] = useState(LoadingState.NotStarted); const [belowState, setBelowState] = useState(LoadingState.NotStarted); const [showLog, setShowLog] = useState(false); + const [datasourceInstance, setDatasourceInstance] = useState< + (DataSourceApi & DataSourceWithLogsContextSupport) | null + >(null); + const defaultTimeWindow = logOptionsStorageKey + ? (store.get(`${logOptionsStorageKey}.contextTimeWindow`) ?? DEFAULT_TIME_WINDOW.toString()) + : DEFAULT_TIME_WINDOW.toString(); + const [timeWindow, setTimeWindow] = useState(parseInt(defaultTimeWindow, 10)); + const eventBusRef = useRef(new EventBusSrv()); const dispatch = useDispatch(); @@ -95,8 +108,7 @@ export const LogLineContext = memo( sortOrder === LogsSortOrder.Ascending ? allLogs[0].timeEpochMs : allLogs[allLogs.length - 1].timeEpochMs; let toMs = sortOrder === LogsSortOrder.Ascending ? allLogs[allLogs.length - 1].timeEpochMs : allLogs[0].timeEpochMs; - // In case we have a lot of logs and from and to have same millisecond - // we add 1 millisecond to toMs to make sure we have a range + // Add one millisecond to get a range when from and to are equal. if (fromMs === toMs) { toMs += 1; } @@ -119,24 +131,23 @@ export const LogLineContext = memo( setContextQuery(contextQuery); }, [log, getRowContextQuery]); - const updateResults = useCallback(async () => { - setAboveLogs([]); - setBelowLogs([]); - await updateContextQuery(); - setInitialized(false); - }, [updateContextQuery]); - useEffect(() => { if (open) { updateContextQuery(); + reportInteraction('logs_log_line_context_open', { + datasourceType: log.datasourceType, + uid: log.uid, + }); } - }, [updateContextQuery, open]); + }, [updateContextQuery, open, log]); const getContextLogs = useCallback( - async (place: 'above' | 'below', refLog: LogRowModel): Promise => { + async (place: 'above' | 'below', refLog: LogRowModel, timeWindowMs?: number): Promise => { const result = await getRowContext(normalizeLogRefId(refLog), { limit: PAGE_SIZE, direction: getLoadMoreDirection(place, sortOrder), + // Only on the initial request + timeWindowMs, }); const newLogs = dataFrameToLogsModel(result.data).rows; @@ -149,12 +160,12 @@ export const LogLineContext = memo( ); const loadMore = useCallback( - async (place: 'above' | 'below', refLog: LogRowModel) => { + async (place: 'above' | 'below', refLog: LogRowModel, timeWindow?: number) => { const setState = place === 'above' ? setAboveState : setBelowState; setState(LoadingState.Loading); try { - const newLogs = (await getContextLogs(place, refLog)).map((r) => + const newLogs = (await getContextLogs(place, refLog, timeWindow)).map((r) => // apply the original row's searchWords to all the rows for highlighting !r.searchWords || !r.searchWords?.length ? { ...r, searchWords: log.searchWords } : r ); @@ -188,10 +199,10 @@ export const LogLineContext = memo( return; } if (!initialized) { - Promise.all([loadMore('above', log), loadMore('below', log)]).then(() => {}); + Promise.all([loadMore('above', log, timeWindow), loadMore('below', log, timeWindow)]); setInitialized(true); } - }, [initialized, loadMore, log, open]); + }, [initialized, loadMore, log, open, timeWindow]); const handleLoadMore = useCallback( (_: AbsoluteTimeRange, direction: ScrollDirection) => { @@ -212,10 +223,70 @@ export const LogLineContext = memo( ); }, [log.uid]); + const onSplitViewClick = useCallback(() => { + if (!contextQuery) { + return; + } + let rowId = log.uid; + if (log.dataFrame.refId) { + // the orignal row has the refid from the base query and not the refid from the context query, so we need to replace it. + rowId = log.uid.replace(log.dataFrame.refId, contextQuery.refId); + } + + dispatch( + splitOpen({ + queries: [contextQuery], + range: timeRange, + datasourceUid: contextQuery.datasource!.uid!, + panelsState: { + logs: { + id: rowId, + }, + }, + }) + ); + onClose(); + reportInteraction('logs_log_line_context_open_in_split_clicked', { + datasourceType: log.datasourceType, + }); + }, [contextQuery, dispatch, log.dataFrame.refId, log.datasourceType, log.uid, onClose, timeRange]); + + const handleTimeWindowChange = useCallback( + (option: ComboboxOption) => { + if (logOptionsStorageKey) { + store.set(`${logOptionsStorageKey}.contextTimeWindow`, option.value); + } + setTimeWindow(parseInt(option.value, 10)); + setAboveLogs([]); + setBelowLogs([]); + setInitialized(false); + reportInteraction('logs_log_line_context_time_window_change', { + window_size: option.value, + }); + }, + [logOptionsStorageKey] + ); + + const handleClose = useCallback(() => { + reportInteraction('logs_log_line_context_closed', { + datasourceType: log.datasourceType, + uid: log.uid, + }); + onClose(); + }, [log.datasourceType, log.uid, onClose]); + + const updateResults = useCallback(async () => { + setAboveLogs([]); + setBelowLogs([]); + await updateContextQuery(); + setInitialized(false); + }, [updateContextQuery]); + const wrapLogMessage = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.wrapLogMessage`, true) : true; const syntaxHighlighting = logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.syntaxHighlighting`, true) : true; + // @todo: Remove when the LogRows are deprecated const logListModel = useMemo( () => @@ -229,16 +300,28 @@ export const LogLineContext = memo( [log, timeZone, wrapLogMessage] ); + useEffect(() => { + if (log.datasourceUid) { + getDataSourceSrv() + .get({ uid: log.datasourceUid }) + .then((ds) => { + if (hasLogsContextSupport(ds)) { + setDatasourceInstance(ds); + } + }); + } + }, [log.datasourceUid]); + return ( {config.featureToggles.logsContextDatasourceUi && getLogRowContextUi && ( -
{getLogRowContextUi(log, updateResults)}
+
{getLogRowContextUi(log, updateResults)}
)} +
+ {datasourceInstance?.supportsAdjustableWindow && ( + + + {t('logs.log-line-context.time-window-label', 'Context time window')} + + + + )} + + {contextQuery?.datasource?.uid && ( + + )} +
{aboveState === LoadingState.Loading && ( No more logs available. )}
- - - - {contextQuery?.datasource?.uid && ( - - )} -
); } @@ -362,10 +440,6 @@ const getStyles = (theme: GrafanaTheme2) => { left: '50%', transform: 'translate(-50%, -50%)', }), - datasourceUi: css({ - display: 'flex', - alignItems: 'center', - }), loadingIndicator: css({ height: theme.spacing(3), minHeight: theme.spacing(3), @@ -377,8 +451,8 @@ const getStyles = (theme: GrafanaTheme2) => { wrapper: css({ border: `1px solid ${theme.colors.border.weak}`, padding: theme.spacing(0, 1, 1, 0), - flex: 1, - height: '100%', + flex: '1 1 auto', + minHeight: 0, }), logsContainer: css({ height: '100%', @@ -389,6 +463,7 @@ const getStyles = (theme: GrafanaTheme2) => { flexDirection: 'column', padding: theme.spacing(0, 3, 3, 3), height: '100%', + gap: theme.spacing(0.5), }), link: css({ color: theme.colors.text.secondary, @@ -404,6 +479,11 @@ const getStyles = (theme: GrafanaTheme2) => { width: '75vw', whiteSpace: 'nowrap', }), + controls: css({ + display: 'flex', + justifyContent: 'flex-end', + gap: theme.spacing(2), + }), }; }; @@ -445,3 +525,11 @@ const normalizeLogRefId = (log: LogRowModel): LogRowModel => { const containsRow = (rows: LogRowModel[], row: LogRowModel) => { return rows.some((r) => r.entry === row.entry && r.timeEpochNs === row.timeEpochNs); }; + +function getTimeWindowOptions() { + const intervals = [100, 500, 1000, 5000, 30000, 60000, 300000, 1800000, 3600000, DEFAULT_TIME_WINDOW]; + return intervals.map((interval) => ({ + label: formattedValueToString(getValueFormat('ms')(interval)), + value: interval.toString(), + })); +} diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 1fbc0b1fd6e..27e0b1ab9d8 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -96,7 +96,6 @@ type LogListComponentProps = Omit< | 'dedupStrategy' | 'displayedFields' | 'enableLogDetails' - | 'loading' | 'logOptionsStorageKey' | 'permalinkedLogId' | 'showTime' @@ -203,6 +202,7 @@ export const LogList = ({ grammar={grammar} initialScrollPosition={initialScrollPosition} infiniteScrollMode={infiniteScrollMode} + loading={loading} loadMore={loadMore} logs={logs} showControls={showControls} @@ -221,6 +221,7 @@ const LogListComponent = ({ grammar, initialScrollPosition = 'top', infiniteScrollMode = 'interval', + loading, loadMore, logs, showControls, @@ -452,6 +453,7 @@ const LogListComponent = ({ displayedFields={displayedFields} handleOverflow={handleOverflow} infiniteScrollMode={infiniteScrollMode} + loading={loading} logs={filteredLogs} loadMore={loadMore} onClick={handleLogLineClick} diff --git a/public/app/plugins/datasource/loki/LogContextProvider.ts b/public/app/plugins/datasource/loki/LogContextProvider.ts index 00873a8d920..3de58ba2e4a 100644 --- a/public/app/plugins/datasource/loki/LogContextProvider.ts +++ b/public/app/plugins/datasource/loki/LogContextProvider.ts @@ -71,7 +71,7 @@ export class LogContextProvider { this.cachedContextFilters = filters; } - return await this.prepareLogRowContextQueryTarget(row, limit, direction, origQuery); + return await this.prepareLogRowContextQueryTarget(row, limit, direction, origQuery, options?.timeWindowMs); } getLogRowContextQuery = async ( @@ -136,12 +136,11 @@ export class LogContextProvider { row: LogRowModel, limit: number, direction: LogRowContextQueryDirection, - origQuery?: LokiQuery + origQuery?: LokiQuery, + timeWindowMs = 2 * 60 * 60 * 1000 ): Promise<{ query: LokiQuery; range: TimeRange }> { const expr = this.prepareExpression(this.cachedContextFilters, origQuery); - const contextTimeBuffer = 2 * 60 * 60 * 1000; // 2h buffer - const queryDirection = direction === LogRowContextQueryDirection.Forward ? LokiQueryDirection.Forward : LokiQueryDirection.Backward; @@ -174,11 +173,11 @@ export class LogContextProvider { // because the are before but came it he response that should return only rows after. from: timestamp, // convert to ns, we lose some precision here but it is not that important at the far points of the context - to: toUtc(row.timeEpochMs + contextTimeBuffer), + to: toUtc(row.timeEpochMs + timeWindowMs), } : { // convert to ns, we lose some precision here but it is not that important at the far points of the context - from: toUtc(row.timeEpochMs - contextTimeBuffer), + from: toUtc(row.timeEpochMs - timeWindowMs), to: timestamp, }; diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index 7bdfcf2e1ee..c7b60f169a5 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -162,8 +162,12 @@ export class LokiDatasource }; this.variables = new LokiVariableSupport(this); this.logContextProvider = new LogContextProvider(this); + this.supportsAdjustableWindow = true; } + // Flag marking datasource as supporting adjusting the time range window in the logs context window: https://github.com/grafana/grafana/pull/109901 + public supportsAdjustableWindow; + /** * Implemented for DataSourceWithSupplementaryQueriesSupport. * It generates a DataQueryRequest for a specific supplementary query type. diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 37a908c0714..594c036e9d2 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -9482,6 +9482,8 @@ "no-more-logs-available": "No more logs available.", "older-logs": "older", "open-in-split-view": "Open in split view", + "time-window-label": "Context time window", + "time-window-tooltip": "Amount of time before and after the referenced log", "title-log-context": "Log context", "title-log-line": "Referenced log line" }, From 2ab5df43e02e3e5e7f7a555b84a7a00aec23b922 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Thu, 21 Aug 2025 10:00:18 -0400 Subject: [PATCH 05/47] Table: Add unit tests for MaybeWrapWithLink (#109932) --- .../Table/TableNG/Cells/AutoCell.tsx | 2 +- .../Table/TableNG/Cells/BarGaugeCell.tsx | 2 +- .../Table/TableNG/Cells/ImageCell.tsx | 2 +- .../Table/TableNG/Cells/MarkdownCell.tsx | 2 +- .../components/MaybeWrapWithLink.test.tsx | 199 ++++++++++++++++++ .../{ => components}/MaybeWrapWithLink.tsx | 14 +- public/locales/en-US/grafana.json | 3 + 7 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 packages/grafana-ui/src/components/Table/TableNG/components/MaybeWrapWithLink.test.tsx rename packages/grafana-ui/src/components/Table/TableNG/{ => components}/MaybeWrapWithLink.tsx (69%) diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx index e169c756a94..a1e7aa55ec0 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { formattedValueToString } from '@grafana/data'; -import { MaybeWrapWithLink } from '../MaybeWrapWithLink'; +import { MaybeWrapWithLink } from '../components/MaybeWrapWithLink'; import { AutoCellProps, TableCellStyles } from '../types'; export function AutoCell({ value, field, rowIdx }: AutoCellProps) { diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx index 700dbf2e33e..3f370e55c71 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx @@ -2,7 +2,7 @@ import { ThresholdsConfig, ThresholdsMode, VizOrientation, getFieldConfigWithMin import { BarGaugeDisplayMode, BarGaugeValueMode, TableCellDisplayMode } from '@grafana/schema'; import { BarGauge } from '../../../BarGauge/BarGauge'; -import { MaybeWrapWithLink } from '../MaybeWrapWithLink'; +import { MaybeWrapWithLink } from '../components/MaybeWrapWithLink'; import { TABLE } from '../constants'; import { BarGaugeCellProps } from '../types'; import { getCellOptions, getAlignmentFactor } from '../utils'; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx index c3b085cbb5b..4013f2f04f8 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import { TableCellDisplayMode } from '../../types'; -import { MaybeWrapWithLink } from '../MaybeWrapWithLink'; +import { MaybeWrapWithLink } from '../components/MaybeWrapWithLink'; import { ImageCellProps, TableCellStyles } from '../types'; export const ImageCell = ({ cellOptions, field, value, rowIdx }: ImageCellProps) => { diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx index 7d3e36e3f65..625ab881976 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { renderMarkdown } from '@grafana/data'; -import { MaybeWrapWithLink } from '../MaybeWrapWithLink'; +import { MaybeWrapWithLink } from '../components/MaybeWrapWithLink'; import { MarkdownCellProps, TableCellStyles } from '../types'; export function MarkdownCell({ field, rowIdx, disableSanitizeHtml }: MarkdownCellProps) { diff --git a/packages/grafana-ui/src/components/Table/TableNG/components/MaybeWrapWithLink.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/components/MaybeWrapWithLink.test.tsx new file mode 100644 index 00000000000..c0a2947b770 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/components/MaybeWrapWithLink.test.tsx @@ -0,0 +1,199 @@ +import { render, screen } from '@testing-library/react'; + +import { ActionType, Field, FieldType, HttpRequestMethod } from '@grafana/data'; + +import { MaybeWrapWithLink } from './MaybeWrapWithLink'; + +describe('MaybeWrapWithLink', () => { + describe('single link', () => { + it('renders children as a link when there is a single link', () => { + const link = { title: 'My link', url: 'http://example.com' }; + const field: Field = { + type: FieldType.string, + name: 'Test Field', + values: [], + getLinks: jest.fn(() => [{ title: link.title, href: link.url, target: '_blank', origin: field }]), + config: { + links: [link], + actions: [], + }, + }; + const rowIdx = 0; + const children = Test Link; + + render(); + const linkElement = screen.getByTitle(link.title); + expect(linkElement).toBeInTheDocument(); + expect(linkElement).toHaveAttribute('href', link.url); + expect(linkElement).toHaveTextContent('Test Link'); + }); + + it('does not throw if getLinks unexpectedly returns nothing when a single link is present', () => { + const field: Field = { + type: FieldType.string, + name: 'Test Field', + values: [], + getLinks: jest.fn(() => []), + config: { + links: [{ title: 'My link', url: 'http://example.com' }], + actions: [], + }, + }; + const rowIdx = 0; + const children = Test Link; + + render(); + + const childElement = screen.getByText('Test Link'); + expect(childElement).toBeInTheDocument(); + }); + }); + + describe('multi link and/or actions', () => { + it('renders a popup target link if multiple links are present', () => { + const links = [ + { title: 'My link', url: 'http://example.com' }, + { title: 'Another link', url: 'http://example.com' }, + ]; + const field: Field = { + type: FieldType.string, + name: 'Test Field', + values: [], + getLinks: jest.fn(() => links.map((l) => ({ title: l.title, href: l.url, target: '_blank', origin: field }))), + config: { + links, + actions: [], + }, + }; + const rowIdx = 0; + const children = Test Link; + + render(); + + const linkElement = screen.getByTitle('view data links and actions'); + expect(linkElement).toBeInTheDocument(); + expect(linkElement.tagName).toBe('A'); + expect(linkElement).toHaveAttribute('aria-haspopup', 'menu'); + expect(linkElement).toHaveTextContent('Test Link'); + }); + + it('renders a popup target link if multiple actions are present', () => { + const field: Field = { + type: FieldType.string, + name: 'Test Field', + values: [], + getLinks: jest.fn(() => []), + config: { + links: [], + actions: [ + { + type: ActionType.Fetch, + title: 'My action', + [ActionType.Fetch]: { method: HttpRequestMethod.GET, url: 'http://example.com' }, + }, + { + type: ActionType.Fetch, + title: 'Another action', + [ActionType.Fetch]: { method: HttpRequestMethod.POST, url: 'http://example.com' }, + }, + ], + }, + }; + const rowIdx = 0; + const children = Test Link; + + render(); + + const linkElement = screen.getByTitle('view data links and actions'); + expect(linkElement).toBeInTheDocument(); + expect(linkElement.tagName).toBe('A'); + expect(linkElement).toHaveAttribute('aria-haspopup', 'menu'); + expect(linkElement).toHaveTextContent('Test Link'); + }); + + it('renders a popup target link if a single action is present', () => { + const field: Field = { + type: FieldType.string, + name: 'Test Field', + values: [], + getLinks: jest.fn(() => []), + config: { + links: [], + actions: [ + { + type: ActionType.Fetch, + title: 'My action', + [ActionType.Fetch]: { method: HttpRequestMethod.GET, url: 'http://example.com' }, + }, + ], + }, + }; + const rowIdx = 0; + const children = Test Link; + + render(); + + const linkElement = screen.getByTitle('view data links and actions'); + expect(linkElement).toBeInTheDocument(); + expect(linkElement.tagName).toBe('A'); + expect(linkElement).toHaveAttribute('aria-haspopup', 'menu'); + expect(linkElement).toHaveTextContent('Test Link'); + }); + + it('renders a popup target link if a mixture of actions and links are present', () => { + const links = [{ title: 'My link', url: 'http://example.com' }]; + const field: Field = { + type: FieldType.string, + name: 'Test Field', + values: [], + getLinks: jest.fn(() => links.map((l) => ({ title: l.title, href: l.url, target: '_blank', origin: field }))), + config: { + links, + actions: [ + { + type: ActionType.Fetch, + title: 'My action', + [ActionType.Fetch]: { method: HttpRequestMethod.GET, url: 'http://example.com' }, + }, + ], + }, + }; + const rowIdx = 0; + const children = Test Link; + + render(); + + const linkElement = screen.getByTitle('view data links and actions'); + expect(linkElement).toBeInTheDocument(); + expect(linkElement.tagName).toBe('A'); + expect(linkElement).toHaveAttribute('aria-haspopup', 'menu'); + expect(linkElement).toHaveTextContent('Test Link'); + }); + }); + + describe('no links or actions', () => { + it('passes the children through when no links or actions are present', () => { + const links = [ + { title: 'My link', url: 'http://example.com' }, + { title: 'Another link', url: 'http://example.com' }, + ]; + const field: Field = { + type: FieldType.string, + name: 'Test Field', + values: [], + getLinks: jest.fn(() => []), + config: { + links, + actions: [], + }, + }; + const rowIdx = 0; + const children = Test Link; + + render(); + + const childElement = screen.getByText('Test Link'); + expect(childElement).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/MaybeWrapWithLink.tsx b/packages/grafana-ui/src/components/Table/TableNG/components/MaybeWrapWithLink.tsx similarity index 69% rename from packages/grafana-ui/src/components/Table/TableNG/MaybeWrapWithLink.tsx rename to packages/grafana-ui/src/components/Table/TableNG/components/MaybeWrapWithLink.tsx index ed8e3c88a8d..b43728dd1e8 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/MaybeWrapWithLink.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/components/MaybeWrapWithLink.tsx @@ -1,10 +1,10 @@ import { memo, ReactNode } from 'react'; import { Field } from '@grafana/data'; +import { t } from '@grafana/i18n'; -import { renderSingleLink } from '../DataLinksActionsTooltip'; - -import { getCellLinks } from './utils'; +import { renderSingleLink } from '../../DataLinksActionsTooltip'; +import { getCellLinks } from '../utils'; interface MaybeWrapWithLinkProps { field: Field; @@ -23,8 +23,12 @@ export const MaybeWrapWithLink = memo(({ field, rowIdx, children }: MaybeWrapWit } // as faux link that acts as hit-area for tooltip activation else if (linksCount + actionsCount > 0) { - // eslint-disable-next-line jsx-a11y/anchor-is-valid - return {children}; + return ( + // eslint-disable-next-line jsx-a11y/anchor-is-valid + + {children} + + ); } // raw value diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 594c036e9d2..b705495578d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -12751,6 +12751,9 @@ "label-alt-text": "Alt text", "label-title-text": "Title text" }, + "link-wrapper": { + "menu": "view data links and actions" + }, "markdown-cell-options-editor": { "description-dynamic-height": "We recommend enabling pagination with this option to avoid performance issues.", "label": { From 91748fe1156ea121f5dd8b0cc3e9aeb8a13951ab Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Thu, 21 Aug 2025 16:05:13 +0200 Subject: [PATCH 06/47] Auditing: Document new options for recording datasource query request/response body (#109951) --- .../configure-grafana/enterprise-configuration/index.md | 8 ++++++++ .../setup-grafana/configure-security/audit-grafana.md | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/docs/sources/setup-grafana/configure-grafana/enterprise-configuration/index.md b/docs/sources/setup-grafana/configure-grafana/enterprise-configuration/index.md index 9b6fad3ed4a..c7570c71eaa 100644 --- a/docs/sources/setup-grafana/configure-grafana/enterprise-configuration/index.md +++ b/docs/sources/setup-grafana/configure-grafana/enterprise-configuration/index.md @@ -198,6 +198,14 @@ List of enabled loggers. Keep dashboard content in the logs (request or response fields). This can significantly increase the size of your logs. +### log_datasource_query_request_body + +Whether to record data source queries' request body. This can significantly increase the size of your logs. Enabled by default. + +### log_datasource_query_response_body + +Whether to record data source queries' response body. This can significantly increase the size of your logs. Enabled by default. + ### verbose Log all requests and keep requests and responses body. This can significantly increase the size of your logs. diff --git a/docs/sources/setup-grafana/configure-security/audit-grafana.md b/docs/sources/setup-grafana/configure-security/audit-grafana.md index 624c81432c4..44f1ea7d5e2 100644 --- a/docs/sources/setup-grafana/configure-security/audit-grafana.md +++ b/docs/sources/setup-grafana/configure-security/audit-grafana.md @@ -371,6 +371,10 @@ enabled = false loggers = file # Keep dashboard content in the logs (request or response fields); this can significantly increase the size of your logs. log_dashboard_content = false +# Whether to record data source queries' request body. This can significantly increase the size of your logs. Enabled by default. +log_datasource_query_request_body = true +# Whether to record data source queries' response body. This can significantly increase the size of your logs. Enabled by default. +log_datasource_query_response_body = true # Keep requests and responses body; this can significantly increase the size of your logs. verbose = false # Write an audit log for every status code. From 82d36a259e7fd140fb9b01a9d76010aa6d24b25c Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Thu, 21 Aug 2025 15:46:12 +0100 Subject: [PATCH 07/47] Folders: Refactor Browse/Dashboards list tests (#109421) --- .../src/handlers/all-handlers.ts | 5 ++ .../src/handlers/api/folders/handlers.ts | 8 +- .../src/handlers/api/search/handlers.ts | 73 +++++++++++++++++++ .../api/clients/folder/v1beta1/hooks.test.ts | 2 +- .../BrowseDashboardsPage.test.tsx | 25 +------ .../components/BrowseView.test.tsx | 69 +++++++----------- .../components/DashboardsTree.test.tsx | 31 +++----- .../fixtures/dashboardsTreeItem.fixture.ts | 42 +++-------- .../Wizard/ProvisioningWizard.test.tsx | 11 --- 9 files changed, 133 insertions(+), 133 deletions(-) create mode 100644 packages/grafana-test-utils/src/handlers/api/search/handlers.ts diff --git a/packages/grafana-test-utils/src/handlers/all-handlers.ts b/packages/grafana-test-utils/src/handlers/all-handlers.ts index 89662264875..4b4bf959496 100644 --- a/packages/grafana-test-utils/src/handlers/all-handlers.ts +++ b/packages/grafana-test-utils/src/handlers/all-handlers.ts @@ -1,14 +1,19 @@ import { HttpHandler } from 'msw'; import folderHandlers from './api/folders/handlers'; +import searchHandlers from './api/search/handlers'; import teamsHandlers from './api/teams/handlers'; import appPlatformDashboardv0alpha1Handlers from './apis/dashboard.grafana.app/v0alpha1/handlers'; import appPlatformFolderv1beta1Handlers from './apis/folder.grafana.app/v1beta1/handlers'; import appPlatformIamv0alpha1Handlers from './apis/iam.grafana.app/v0alpha1/handlers'; const allHandlers: HttpHandler[] = [ + // Legacy handlers ...teamsHandlers, ...folderHandlers, + ...searchHandlers, + + // App platform handlers ...appPlatformDashboardv0alpha1Handlers, ...appPlatformFolderv1beta1Handlers, ...appPlatformIamv0alpha1Handlers, diff --git a/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts index 2dcac8b0160..6da13af3efe 100644 --- a/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts @@ -1,3 +1,4 @@ +import { Chance } from 'chance'; import { HttpResponse, http } from 'msw'; import { treeViewersCanEdit, wellFormedTree } from '../../../fixtures/folders'; @@ -19,7 +20,6 @@ const additionalProperties = { created: '2025-07-14T12:07:36+02:00', createdBy: 'Anonymous', hasAcl: false, - id: 1, orgId: 1, updated: '2025-07-15T18:01:36+02:00', updatedBy: 'Anonymous', @@ -42,10 +42,11 @@ const listFoldersHandler = () => const folders = tree .filter((v) => v.item.kind === 'folder' && v.item.parentUID === parentUid) .map((folder) => { + const random = Chance(folder.item.uid); return { + id: random.integer({ min: 1, max: 1000 }), uid: folder.item.uid, title: folder.item.kind === 'folder' ? folder.item.title : "invalid - this shouldn't happen", - ...additionalProperties, }; }) .sort((a, b) => collator.compare(a.title, b.title)) // API always sorts by title @@ -66,7 +67,10 @@ const getFolderHandler = () => return HttpResponse.json({ message: 'folder not found', status: 'not-found' }, { status: 404 }); } + const random = Chance(folder.item.uid); + return HttpResponse.json({ + id: random.integer({ min: 1, max: 1000 }), title: folder?.item.title, uid: folder?.item.uid, ...additionalProperties, diff --git a/packages/grafana-test-utils/src/handlers/api/search/handlers.ts b/packages/grafana-test-utils/src/handlers/api/search/handlers.ts new file mode 100644 index 00000000000..195af7dac1d --- /dev/null +++ b/packages/grafana-test-utils/src/handlers/api/search/handlers.ts @@ -0,0 +1,73 @@ +import { Chance } from 'chance'; +import { HttpResponse, http } from 'msw'; + +import { wellFormedTree } from '../../../fixtures/folders'; + +const [mockTree] = wellFormedTree(); + +type FilterArray = Array<(v: (typeof mockTree)[number]) => boolean>; + +const slugify = (str: string) => { + return str + .toLowerCase() + .replace(/[^\w ]+/g, '') + .replace(/ +/g, '-'); +}; + +const getLegacySearchHandler = () => + http.get('/api/search', ({ request }) => { + const folderFilter = new URL(request.url).searchParams.get('folderUIDs') || null; + const typeFilter = new URL(request.url).searchParams.get('type') || null; + // Workaround for the fixture kind being 'dashboard' instead of 'dash-db' + const mappedTypeFilter = typeFilter === 'dash-db' ? 'dashboard' : typeFilter; + const response = mockTree + .filter((filterItem) => { + const filters: FilterArray = []; + if (folderFilter && folderFilter !== 'general') { + filters.push( + ({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === folderFilter + ); + } + + if (folderFilter === 'general') { + filters.push( + ({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === undefined + ); + } + + if (mappedTypeFilter) { + filters.push(({ item }) => item.kind === mappedTypeFilter); + } + + return filters.every((filterPredicate) => filterPredicate(filterItem)); + }) + + .map(({ item }) => { + const random = Chance(item.uid); + const slugified = slugify(item.title || ''); + const parentFolder = + item.kind === 'dashboard' + ? mockTree.find((t) => t.item.kind === 'folder' && t.item.uid === item.parentUID) + : undefined; + return { + id: random.integer({ min: 1, max: 1000 }), + uid: item.uid, + orgId: 1, + title: item.title, + uri: `db/${slugified}`, + url: `/d/${item.uid}/${slugified}`, + folderUid: parentFolder?.item.uid, + folderTitle: parentFolder?.item.title, + slug: '', + type: item.kind, + tags: [], + isStarred: false, + sortMeta: 0, + isDeleted: false, + }; + }); + + return HttpResponse.json(response); + }); + +export default [getLegacySearchHandler()]; diff --git a/public/app/api/clients/folder/v1beta1/hooks.test.ts b/public/app/api/clients/folder/v1beta1/hooks.test.ts index e974c563b33..5c3936a4847 100644 --- a/public/app/api/clients/folder/v1beta1/hooks.test.ts +++ b/public/app/api/clients/folder/v1beta1/hooks.test.ts @@ -83,7 +83,7 @@ describe('useGetFolderQueryFacade', () => { config.featureToggles.foldersAppPlatformAPI = false; const result = await renderFolderHook(); expect(result.current.data).toMatchObject({ - id: 1, + id: 791, title: folderA_folderA.item.title, url: expectedUrl, uid: expectedUid, diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx index a9a0dda6274..b7775e5c249 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx @@ -17,7 +17,7 @@ import BrowseDashboardsPage from './BrowseDashboardsPage'; import * as permissions from './permissions'; setupMockServer(); -const [mockTree, { dashbdD, folderA, folderA_folderA }] = getFolderFixtures(); +const [_, { dashbdD, folderA, folderA_folderA }] = getFolderFixtures(); jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), @@ -89,29 +89,6 @@ function render(...[ui, options]: Parameters) { }; } -jest.mock('app/features/browse-dashboards/api/services', () => { - const orig = jest.requireActual('app/features/browse-dashboards/api/services'); - - return { - ...orig, - listFolders(parentUID?: string) { - const childrenForUID = mockTree - .filter((v) => v.item.kind === 'folder' && v.item.parentUID === parentUID) - .map((v) => v.item); - - return Promise.resolve(childrenForUID); - }, - - listDashboards(parentUID?: string) { - const childrenForUID = mockTree - .filter((v) => v.item.kind === 'dashboard' && v.item.parentUID === parentUID) - .map((v) => v.item); - - return Promise.resolve(childrenForUID); - }, - }; -}); - describe('browse-dashboards BrowseDashboardsPage', () => { const mockPermissions = { canCreateDashboards: true, diff --git a/public/app/features/browse-dashboards/components/BrowseView.test.tsx b/public/app/features/browse-dashboards/components/BrowseView.test.tsx index 929f5bbf53c..d9e7fa6c806 100644 --- a/public/app/features/browse-dashboards/components/BrowseView.test.tsx +++ b/public/app/features/browse-dashboards/components/BrowseView.test.tsx @@ -1,42 +1,21 @@ -import { getByLabelText, render as rtlRender, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { TestProvider } from 'test/helpers/TestProvider'; +import { getByLabelText, render, screen, userEvent } from 'test/test-utils'; import { selectors } from '@grafana/e2e-selectors'; +import { setBackendSrv } from '@grafana/runtime'; +import { setupMockServer } from '@grafana/test-utils/server'; import { getFolderFixtures } from '@grafana/test-utils/unstable'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { contextSrv } from 'app/core/services/context_srv'; import { DashboardViewItem } from 'app/features/search/types'; +import { AccessControlAction } from 'app/types/accessControl'; import { BrowseView } from './BrowseView'; const [mockTree, { folderA, folderA_folderA, folderA_folderB, folderA_folderB_dashbdB, dashbdD, folderB_empty }] = getFolderFixtures(); -function render(...[ui, options]: Parameters) { - rtlRender({ui}, options); -} - -jest.mock('app/features/browse-dashboards/api/services', () => { - const orig = jest.requireActual('app/features/browse-dashboards/api/services'); - - return { - ...orig, - listFolders(parentUID?: string) { - const childrenForUID = mockTree - .filter((v) => v.item.kind === 'folder' && v.item.parentUID === parentUID) - .map((v) => v.item); - - return Promise.resolve(childrenForUID); - }, - - listDashboards(parentUID?: string) { - const childrenForUID = mockTree - .filter((v) => v.item.kind === 'dashboard' && v.item.parentUID === parentUID) - .map((v) => v.item); - - return Promise.resolve(childrenForUID); - }, - }; -}); +setBackendSrv(backendSrv); +setupMockServer(); describe('browse-dashboards BrowseView', () => { const WIDTH = 800; @@ -48,13 +27,12 @@ describe('browse-dashboards BrowseView', () => { canDeleteDashboards: true, }; - afterEach(() => { - // Reset permissions back to defaults - Object.assign(mockPermissions, { - canEditFolders: true, - canEditDashboards: true, - canDeleteFolders: true, - canDeleteDashboards: true, + beforeEach(() => { + jest.spyOn(contextSrv, 'hasPermission').mockImplementation((permission: string) => { + if (permission === AccessControlAction.FoldersRead) { + return true; + } + return false; }); }); @@ -63,7 +41,7 @@ describe('browse-dashboards BrowseView', () => { await screen.findByText(folderA.item.title); await expandFolder(folderA.item); - expect(screen.queryByText(folderA_folderA.item.title)).toBeInTheDocument(); + expect(screen.getByText(folderA_folderA.item.title)).toBeInTheDocument(); await collapseFolder(folderA.item); expect(screen.queryByText(folderA_folderA.item.title)).not.toBeInTheDocument(); @@ -169,13 +147,20 @@ describe('browse-dashboards BrowseView', () => { }); it('shows a simple message if the user has viewer rights', async () => { - mockPermissions.canEditFolders = false; - mockPermissions.canEditDashboards = false; - mockPermissions.canDeleteFolders = false; - mockPermissions.canDeleteDashboards = false; + const mockPermissionsDisabled = { + canEditFolders: false, + canEditDashboards: false, + canDeleteFolders: false, + canDeleteDashboards: false, + }; render( - + ); expect(await screen.findByText('This folder is empty')).toBeInTheDocument(); }); diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx index e8109397870..3e1b597405a 100644 --- a/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx +++ b/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx @@ -1,24 +1,16 @@ -import { render as rtlRender, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { TestProvider } from 'test/helpers/TestProvider'; import { assertIsDefined } from 'test/helpers/asserts'; +import { render, screen } from 'test/test-utils'; import { selectors } from '@grafana/e2e-selectors'; import { config } from '@grafana/runtime'; +import { getFolderFixtures } from '@grafana/test-utils/unstable'; -import { - sharedWithMeFolder, - wellFormedDashboard, - wellFormedEmptyFolder, - wellFormedFolder, -} from '../fixtures/dashboardsTreeItem.fixture'; +import { sharedWithMeFolder } from '../fixtures/dashboardsTreeItem.fixture'; import { SelectionState } from '../types'; import { DashboardsTree } from './DashboardsTree'; -function render(...[ui, options]: Parameters) { - rtlRender({ui}, options); -} +const [_, { folderA: folder, folderB_empty: emptyFolderIndicator, dashbdD: dashboard }] = getFolderFixtures(); describe('browse-dashboards DashboardsTree', () => { const WIDTH = 800; @@ -30,9 +22,6 @@ describe('browse-dashboards DashboardsTree', () => { canDeleteDashboards: true, }; - const folder = wellFormedFolder(1); - const emptyFolderIndicator = wellFormedEmptyFolder(); - const dashboard = wellFormedDashboard(2); const noop = () => {}; const isSelected = () => SelectionState.Unselected; const allItemsAreLoaded = () => true; @@ -67,8 +56,8 @@ describe('browse-dashboards DashboardsTree', () => { requestLoadMore={requestLoadMore} /> ); - expect(screen.queryByText(dashboard.item.title)).toBeInTheDocument(); - expect(screen.queryByText(assertIsDefined(dashboard.item.tags)[0])).toBeInTheDocument(); + expect(screen.getByText(dashboard.item.title)).toBeInTheDocument(); + expect(screen.getByText(assertIsDefined(dashboard.item.tags)[0])).toBeInTheDocument(); expect(screen.getByTestId(selectors.pages.BrowseDashboards.table.checkbox(dashboard.item.uid))).toBeInTheDocument(); }); @@ -113,7 +102,7 @@ describe('browse-dashboards DashboardsTree', () => { /> ); - expect(screen.queryByText(folder.item.title)).toBeInTheDocument(); + expect(screen.getByText(folder.item.title)).toBeInTheDocument(); }); it('renders a folder link', () => { @@ -181,7 +170,7 @@ describe('browse-dashboards DashboardsTree', () => { it('calls onFolderClick when a folder button is clicked', async () => { const handler = jest.fn(); - render( + const { user } = render( { /> ); const folderButton = screen.getByLabelText(`Expand folder ${folder.item.title}`); - await userEvent.click(folderButton); + await user.click(folderButton); expect(handler).toHaveBeenCalledWith(folder.item.uid, true); }); @@ -216,6 +205,6 @@ describe('browse-dashboards DashboardsTree', () => { requestLoadMore={requestLoadMore} /> ); - expect(screen.queryByText('No items')).toBeInTheDocument(); + expect(screen.getByText('No items')).toBeInTheDocument(); }); }); diff --git a/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts b/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts index 7b5d17a2825..f9e12c3f80f 100644 --- a/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts +++ b/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts @@ -1,28 +1,12 @@ import { Chance } from 'chance'; -import { getFolderFixtures } from '@grafana/test-utils/unstable'; import { DashboardViewItem } from 'app/features/search/types'; -import { DashboardsTreeItem, UIDashboardViewItem } from '../types'; - -export function wellFormedEmptyFolder( - seed = 1, - partial?: Partial> -): DashboardsTreeItem { - const random = Chance(seed); - - return { - item: { - kind: 'ui', - uiKind: 'empty-folder', - uid: random.guid(), - }, - level: 0, - isOpen: false, - ...partial, - }; -} +import { DashboardsTreeItem } from '../types'; +/** + * @deprecated Use wellFormedTree from @grafana/test-utils/unstable instead (or re-evaluate test approach in general) + */ export function wellFormedDashboard( seed = 1, partial?: Partial>, @@ -44,6 +28,9 @@ export function wellFormedDashboard( }; } +/** + * @deprecated Use wellFormedTree from @grafana/test-utils/unstable instead (or re-evaluate test approach in general) + */ export function wellFormedFolder( seed = 1, partial?: Partial>, @@ -66,6 +53,9 @@ export function wellFormedFolder( }; } +/** + * @deprecated Use wellFormedTree from @grafana/test-utils/unstable instead (or re-evaluate test approach in general) + */ export function sharedWithMeFolder(seed = 1): DashboardsTreeItem { const folder = wellFormedFolder(seed, undefined, { uid: 'sharedwithme', @@ -73,15 +63,3 @@ export function sharedWithMeFolder(seed = 1): DashboardsTreeItem ({ useNavigate: () => mockNavigate, })); -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getAppEvents: () => ({ - publish: jest.fn(), - }), -})); - jest.mock('../hooks/useCreateOrUpdateRepository'); jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ ...jest.requireActual('app/api/clients/provisioning/v0alpha1'), @@ -38,10 +31,6 @@ jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ useCreateRepositoryJobsMutation: jest.fn(), })); -jest.mock('app/features/browse-dashboards/api/services', () => ({ - PAGE_SIZE: 20, -})); - const mockUseCreateOrUpdateRepository = useCreateOrUpdateRepository as jest.MockedFunction< typeof useCreateOrUpdateRepository >; From 56c8e53a99f5b3bdd8d0cd4d22cd6b0771ea7176 Mon Sep 17 00:00:00 2001 From: Lauren <61048546+laurenashleigh@users.noreply.github.com> Date: Thu, 21 Aug 2025 17:04:00 +0100 Subject: [PATCH 08/47] Alerting: Improved filters part 2 (#109738) * refactor: split out form component into separate functions * add storybook variation for infoOption in dropdown * add tracking to search input for v2 view, tidy up tracking functions * add tests for filter tracking * refactor: boy-scouting V1 filter * move FiltersV2 to rule-list directory * fix lint issue * resolve PR comments round 1 * resolve PR comments round 2- update file locations * generate apis * fix tests * fix lint issue * fix imports --- .../Combobox/MultiCombobox.story.tsx | 25 + .../features/alerting/unified/Analytics.ts | 90 +-- .../rules/Filter/RulesFilter.v1.tsx | 57 +- .../rules/Filter/RulesFilter.v2.tsx | 623 --------------- .../unified/rule-list/RuleList.v2.tsx | 2 +- .../filter}/RulesFilter.test.tsx | 6 +- .../filter}/RulesFilter.tsx | 4 +- .../filter/RulesFilter.v2.test.tsx} | 96 ++- .../rule-list/filter/RulesFilter.v2.tsx | 753 ++++++++++++++++++ .../unified/rule-list/filter/types.ts | 17 + .../Filter => rule-list/filter}/utils.ts | 6 +- 11 files changed, 955 insertions(+), 724 deletions(-) delete mode 100644 public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v2.tsx rename public/app/features/alerting/unified/{components/rules => rule-list/filter}/RulesFilter.test.tsx (91%) rename public/app/features/alerting/unified/{components/rules/Filter => rule-list/filter}/RulesFilter.tsx (77%) rename public/app/features/alerting/unified/{components/rules/RulesFilterV2.test.tsx => rule-list/filter/RulesFilter.v2.test.tsx} (76%) create mode 100644 public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx create mode 100644 public/app/features/alerting/unified/rule-list/filter/types.ts rename public/app/features/alerting/unified/{components/rules/Filter => rule-list/filter}/utils.ts (91%) diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.story.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.story.tsx index fd015178845..b3794bf3fa9 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.story.tsx @@ -66,6 +66,31 @@ export const Basic: Story = { }, }; +export const WithInfoOption: Story = { + name: 'With infoOption', + args: { + ...commonArgs, + options: [ + ...commonArgs.options, + { label: 'Can’t find your country? Select “Other” or contact an admin', value: '__INFO__', infoOption: true }, + ], + }, + render: (args) => { + const [{ value }, setArgs] = useArgs(); + + return ( + { + onChangeAction(val); + setArgs({ value: val }); + }} + /> + ); + }, +}; + export const AutoSize: Story = { args: { ...commonArgs, width: 'auto', minWidth: 20 }, render: (args) => { diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index 2f139066a5b..ec91b28d348 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -1,4 +1,4 @@ -import { isEmpty, pickBy } from 'lodash'; +import { pickBy } from 'lodash'; import { config, createMonitoringLogger, reportInteraction } from '@grafana/runtime'; import { contextSrv } from 'app/core/core'; @@ -7,9 +7,9 @@ import { RuleNamespace } from '../../../types/unified-alerting'; import { RulerRulesConfigDTO } from '../../../types/unified-alerting-dto'; import { Origin } from './components/rule-viewer/tabs/version-history/ConfirmVersionRestoreModal'; -import { AdvancedFilters } from './components/rules/Filter/RulesFilter.v2'; import { FilterType } from './components/rules/central-state-history/EventListSceneObject'; -import { RulesFilter, getSearchFilterFromQuery } from './search/rulesSearchParser'; +import { AdvancedFilters } from './rule-list/filter/types'; +import { RulesFilter } from './search/rulesSearchParser'; import { RuleFormType } from './types/rule-form'; export const LogMessages = { @@ -245,44 +245,6 @@ export const trackImportToGMAError = async (payload: { importSource: 'yaml' | 'd reportInteraction('grafana_alerting_import_to_gma_error', { ...payload }); }; -interface RulesSearchInteractionPayload { - filter: string; - triggeredBy: 'typing' | 'component'; -} - -function trackRulesSearchInteraction(payload: RulesSearchInteractionPayload) { - reportInteraction('grafana_alerting_rules_search', { ...payload }); -} - -export function trackRulesSearchInputInteraction({ oldQuery, newQuery }: { oldQuery: string; newQuery: string }) { - try { - const oldFilter = getSearchFilterFromQuery(oldQuery); - const newFilter = getSearchFilterFromQuery(newQuery); - - const oldFilterTerms = extractFilterKeys(oldFilter); - const newFilterTerms = extractFilterKeys(newFilter); - - const newTerms = newFilterTerms.filter((term) => !oldFilterTerms.includes(term)); - newTerms.forEach((term) => { - trackRulesSearchInteraction({ filter: term, triggeredBy: 'typing' }); - }); - } catch (e: unknown) { - if (e instanceof Error) { - logError(e); - } - } -} - -function extractFilterKeys(filter: RulesFilter) { - return Object.entries(filter) - .filter(([_, value]) => !isEmpty(value)) - .map(([key]) => key); -} - -export function trackRulesSearchComponentInteraction(filter: keyof RulesFilter) { - trackRulesSearchInteraction({ filter, triggeredBy: 'component' }); -} - export function trackRulesListViewChange(payload: { view: string }) { reportInteraction('grafana_alerting_rules_list_mode', { ...payload }); } @@ -335,26 +297,64 @@ export function trackFilterButtonClick() { reportInteraction('grafana_alerting_filter_button_click'); } +export function trackAlertRuleFilterEvent( + payload: + | { filterMethod: 'search-input'; filter: RulesFilter } + | { filterMethod: 'filter-component'; filter: keyof RulesFilter } +) { + if (payload.filterMethod === 'search-input') { + const meaningfulValues = filterMeaningfulValues(payload.filter); + reportInteraction('grafana_alerting_rules_filter', { ...meaningfulValues, filterMethod: 'search-input' }); + return; + } + reportInteraction('grafana_alerting_rules_filter', { filter: payload.filter, filterMethod: 'filter-component' }); +} + +export function trackRulesSearchInputCleared(prev: string, next: string) { + // Only report an explicit clear action when transitioning from non-empty to empty + if (prev !== '' && next === '') { + reportInteraction('grafana_alerting_rules_filter_cleared', { filterMethod: 'search-input' }); + } +} + export function trackFilterButtonApplyClick(payload: AdvancedFilters, pluginsFilterEnabled: boolean) { // Filter out empty/default values before tracking - const meaningfulValues = pickBy(payload, (value, key) => { + const meaningfulValues = filterMeaningfulValues(payload, { pluginsFilterEnabled }); + + reportInteraction('grafana_alerting_rules_filter', { + ...meaningfulValues, + filterMethod: 'filter-component', + }); +} + +function filterMeaningfulValues( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + obj: Record, + opts?: { pluginsFilterEnabled?: boolean } +) { + const { pluginsFilterEnabled = true } = opts ?? {}; + return pickBy(obj, (value, key) => { if (value === null || value === undefined || value === '') { return false; } if (Array.isArray(value) && value.length === 0) { return false; } + if (value === '*') { + return false; + } if (key === 'plugins' && !pluginsFilterEnabled) { return false; } + if (key === 'plugins' && value === 'show') { + return false; + } return true; }); - - reportInteraction('grafana_alerting_filter_button_apply_click', meaningfulValues); } export function trackFilterButtonClearClick() { - reportInteraction('grafana_alerting_filter_button_clear_click'); + reportInteraction('grafana_alerting_rules_filter_cleared', { filterMethod: 'filter-component' }); } export type AlertRuleTrackingProps = { diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx index d58b0a5abdf..90e9d93a41f 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx @@ -11,20 +11,15 @@ import { contextSrv } from 'app/core/core'; import { AccessControlAction } from 'app/types/accessControl'; import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; -import { - LogMessages, - logInfo, - trackRulesSearchComponentInteraction, - trackRulesSearchInputInteraction, -} from '../../../Analytics'; +import { LogMessages, logInfo, trackAlertRuleFilterEvent } from '../../../Analytics'; import { useRulesFilter } from '../../../hooks/useFilteredRules'; import { useAlertingHomePageExtensions } from '../../../plugins/useAlertingHomePageExtensions'; -import { RuleHealth } from '../../../search/rulesSearchParser'; +import { RulesFilterProps } from '../../../rule-list/filter/RulesFilter'; +import { RuleHealth, getSearchFilterFromQuery } from '../../../search/rulesSearchParser'; import { alertStateToReadable } from '../../../utils/rules'; import { PopupCard } from '../../HoverCard'; import { MultipleDataSourcePicker } from '../MultipleDataSourcePicker'; -import { RulesFilterProps } from './RulesFilter'; import { RulesViewModeSelector } from './RulesViewModeSelector'; const RuleTypeOptions: SelectableValue[] = [ @@ -79,33 +74,27 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: }); setFilterKey((key) => key + 1); - trackRulesSearchComponentInteraction('dataSourceNames'); + trackAlertRuleFilterEvent({ filterMethod: 'filter-component', filter: 'dataSourceNames' }); }; - const handleDashboardChange = (dashboardUid: string | undefined) => { - updateFilters({ ...filterState, dashboardUid }); - trackRulesSearchComponentInteraction('dashboardUid'); - }; + type Filters = typeof filterState; + + const updateAndTrack = + (key: K) => + (value: Filters[K]) => { + updateFilters({ ...filterState, [key]: value }); + trackAlertRuleFilterEvent({ filterMethod: 'filter-component', filter: key }); + }; const clearDataSource = () => { updateFilters({ ...filterState, dataSourceNames: [] }); setFilterKey((key) => key + 1); }; + // Note: keep explicit logging for alert state filter clicks const handleAlertStateChange = (value: PromAlertingRuleState) => { logInfo(LogMessages.clickingAlertStateFilters); - updateFilters({ ...filterState, ruleState: value }); - trackRulesSearchComponentInteraction('ruleState'); - }; - - const handleRuleTypeChange = (ruleType: PromRuleType) => { - updateFilters({ ...filterState, ruleType }); - trackRulesSearchComponentInteraction('ruleType'); - }; - - const handleRuleHealthChange = (ruleHealth: RuleHealth) => { - updateFilters({ ...filterState, ruleHealth }); - trackRulesSearchComponentInteraction('ruleHealth'); + updateAndTrack('ruleState')(value); }; const handleClearFiltersClick = () => { @@ -116,8 +105,7 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: }; const handleContactPointChange = (contactPoint: string) => { - updateFilters({ ...filterState, contactPoint }); - trackRulesSearchComponentInteraction('contactPoint'); + updateAndTrack('contactPoint')(contactPoint); }; const searchIcon = ; @@ -190,7 +178,7 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: inputId="filters-dashboard-picker" key={filterState.dashboardUid ? 'dashboard-defined' : 'dashboard-not-defined'} value={filterState.dashboardUid} - onChange={(value) => handleDashboardChange(value?.uid)} + onChange={(value) => updateAndTrack('dashboardUid')(value?.uid)} isClearable cacheOptions /> @@ -210,7 +198,11 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: - +
{canRenderContactPointSelector && ( @@ -271,7 +263,10 @@ const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: onSubmit={handleSubmit((data) => { setSearchQuery(data.searchQuery); searchQueryRef.current?.blur(); - trackRulesSearchInputInteraction({ oldQuery: searchQuery, newQuery: data.searchQuery }); + trackAlertRuleFilterEvent({ + filterMethod: 'search-input', + filter: getSearchFilterFromQuery(data.searchQuery), + }); })} > (null); - const { pluginsFilterEnabled } = usePluginsFilterStatus(); - - // this form will managed the search query string, which is updated either by the user typing in the input or by the advanced filters - const { setValue, watch, getValues, handleSubmit } = useForm({ - defaultValues: { - query: searchQuery, - }, - }); - - useEffect(() => { - setValue('query', searchQuery); - }, [searchQuery, setValue]); - - const submitHandler: SubmitHandler = (values: SearchQueryForm) => { - const parsedFilter = getSearchFilterFromQuery(values.query); - updateFilters(parsedFilter); - }; - - const handleAdvancedFilters: SubmitHandler = (values) => { - const newFilter = formAdvancedFiltersToRuleFilter(values); - updateFilters(newFilter); - - const newSearchQuery = applySearchFilterToQuery('', newFilter); - setSearchQuery(newSearchQuery); - - trackFilterButtonApplyClick(values, pluginsFilterEnabled); - setIsPopupOpen(false); // Should close popup after applying filters? - }; - - const handleClearFilters = () => { - updateFilters(formAdvancedFiltersToRuleFilter(emptyAdvancedFilters)); - setSearchQuery(undefined); - }; - - const handleOnToggle = () => { - trackFilterButtonClick(); - setIsPopupOpen(!isPopupOpen); - }; - - // Handle outside clicks to close the popup - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (isPopupOpen && popupRef.current && event.target instanceof Node && !popupRef.current.contains(event.target)) { - // Check if click is on a portal element (combobox dropdown) - if (event.target instanceof Element) { - const isPortalClick = - event.target.closest('[data-popper-placement]') || event.target.closest('[role="listbox"]'); - - if (!isPortalClick) { - setIsPopupOpen(false); - } - } else { - setIsPopupOpen(false); - } - } - }; - - if (isPopupOpen) { - document.addEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isPopupOpen]); - - const filterButtonLabel = t('alerting.rules-filter.filter-options.aria-label-show-filters', 'Filter'); - return ( -
{}}> - - - - - setValue('query', string)} - onBlur={() => { - const currentQuery = getValues('query'); - const parsedFilter = getSearchFilterFromQuery(currentQuery); - updateFilters(parsedFilter); - }} - value={watch('query')} - /> - - {/* the popup card is mounted inside of a portal, so we can't rely on the usual form handling mechanisms of button[type=submit] */} - setIsPopupOpen(false)} - onToggle={handleOnToggle} - content={ - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -
e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.stopPropagation(); - } - }} - role="dialog" - aria-label={t('alerting.rules-filter.filter-options.aria-label', 'Filter options')} - tabIndex={-1} - > - -
- } - > - -
- -
-
-
- ); -} - -interface FilterOptionsProps { - onSubmit: SubmitHandler; - onClear: () => void; - pluginsFilterEnabled: boolean; -} - -const FilterOptions = ({ onSubmit, onClear, pluginsFilterEnabled }: FilterOptionsProps) => { - const styles = useStyles2(getStyles); - const theme = useStyles2((theme) => theme); - const { filterState } = useRulesFilter(); - const isManualResetRef = useRef(false); - - // Create portal container to render dropdowns above the popup modal - const portalContainer = usePortalContainer(theme.zIndex.portal + 100); - - const defaultValues = searchQueryToDefaultValues(filterState); - - // Fetch namespace and group data from all sources (optimized for filter UI) - const { namespaceOptions, allGroupNames, isLoadingNamespaces, namespacePlaceholder, groupPlaceholder } = - useNamespaceAndGroupOptions(); - - const { labelOptions, isLoadingGrafanaLabels } = useLabelOptions(); - - // Create label options for the multi-select dropdown - const dataSourceOptions = useAlertingDataSourceOptions(); - - // turn the filterState into form default values - const { handleSubmit, reset, register, control } = useForm({ - defaultValues, - }); - - // Update form values when filterState changes (e.g., when popup reopens) - useEffect(() => { - // Skip if we're in the middle of a manual reset - if (isManualResetRef.current) { - isManualResetRef.current = false; - return; - } - - const newDefaultValues = searchQueryToDefaultValues(filterState); - reset(newDefaultValues); - }, [filterState, reset]); - - const submitAdvancedFilters = handleSubmit(onSubmit); - - return ( -
{ - isManualResetRef.current = true; - reset(emptyAdvancedFilters); - trackFilterButtonClearClick(); - onClear(); - }} - > - -
- - - - ( - field.onChange(selections.map((s) => s.value))} - placeholder={ - isLoadingGrafanaLabels - ? t('common.loading', 'Loading...') - : t('alerting.rules-filter.placeholder-labels', 'Select labels') - } - loading={isLoadingGrafanaLabels} - disabled={isLoadingGrafanaLabels || labelOptions.filter((option) => !option.infoOption).length === 0} - portalContainer={portalContainer} - width="auto" - minWidth={40} - maxWidth={80} - /> - )} - /> - - { - return ( - - placeholder={namespacePlaceholder} - options={namespaceOptions} - onChange={(option) => field.onChange(option?.value || null)} - value={field.value} - loading={isLoadingNamespaces} - disabled={isLoadingNamespaces || namespaceOptions.length === 0} - isClearable - portalContainer={portalContainer} - /> - ); - }} - /> - - { - return ( - - placeholder={groupPlaceholder} - options={allGroupNames.map((name) => ({ label: name, value: name }))} - onChange={(option) => field.onChange(option?.value || null)} - value={field.value} - loading={isLoadingNamespaces} - disabled={isLoadingNamespaces || allGroupNames.length === 0} - isClearable - portalContainer={portalContainer} - /> - ); - }} - /> -
- } - > - - -
- - ( - field.onChange(selections.map((s) => s.value))} - placeholder={t('alerting.rules-filter.placeholder-data-sources', 'Select data sources')} - portalContainer={portalContainer} - width="auto" - minWidth={40} - maxWidth={80} - /> - )} - /> - {canRenderContactPointSelector && ( - <> - - { - return ( - { - field.onChange(contactPoint?.spec.title || null); - }} - portalContainer={portalContainer} - /> - ); - }} - /> - - )} - - ( - - options={[ - { label: t('common.all', 'All'), value: '*' }, - { label: t('alerting.rules.state.firing', 'Firing'), value: PromAlertingRuleState.Firing }, - { label: t('alerting.rules.state.normal', 'Normal'), value: PromAlertingRuleState.Inactive }, - { label: t('alerting.rules.state.pending', 'Pending'), value: PromAlertingRuleState.Pending }, - { - label: t('alerting.rules.state.recovering', 'Recovering'), - value: PromAlertingRuleState.Recovering, - }, - { label: t('alerting.rules.state.unknown', 'Unknown'), value: PromAlertingRuleState.Unknown }, - ]} - value={field.value} - onChange={field.onChange} - /> - )} - /> - - ( - - options={[ - { label: t('common.all', 'All'), value: '*' }, - { label: t('alerting.rules.type.alert', 'Alert rule'), value: PromRuleType.Alerting }, - { label: t('alerting.rules.type.recording', 'Recording rule'), value: PromRuleType.Recording }, - ]} - value={field.value} - onChange={field.onChange} - /> - )} - /> - - ( - - options={[ - { label: t('common.all', 'All'), value: '*' }, - { label: t('alerting.rules.health.ok', 'OK'), value: RuleHealth.Ok }, - { label: t('alerting.rules.health.no-data', 'No data'), value: RuleHealth.NoData }, - { label: t('alerting.rules.health.error', 'Error'), value: RuleHealth.Error }, - ]} - value={field.value} - onChange={field.onChange} - /> - )} - /> - {pluginsFilterEnabled && ( - <> - - ( - - options={[ - { label: t('alerting.rules-filter.label.show', 'Show'), value: 'show' }, - { label: t('alerting.rules-filter.label.hide', 'Hide'), value: 'hide' }, - ]} - value={field.value} - onChange={field.onChange} - /> - )} - /> - - )} - - - - - - - - ); -}; - -function SearchQueryHelp() { - const styles = useStyles2(helpStyles); - - return ( -
-
- - Search syntax allows to query alert rules by the parameters defined below. - -
-
-
-
- Filter type -
-
- Expression -
- - - - - - - - - - -
-
- ); -} - -function HelpRow({ title, expr }: { title: string; expr: string }) { - const styles = useStyles2(helpStyles); - - return ( - <> -
{title}
- {expr} - - ); -} - -const helpStyles = (theme: GrafanaTheme2) => ({ - grid: css({ - display: 'grid', - gridTemplateColumns: 'max-content auto', - gap: theme.spacing(1), - alignItems: 'center', - }), - code: css({ - display: 'block', - textAlign: 'center', - }), -}); - -function getStyles(theme: GrafanaTheme2) { - return { - content: css({ - padding: theme.spacing(1), - }), - grid: css({ - display: 'grid', - gridTemplateColumns: 'auto 1fr', - alignItems: 'center', - gap: theme.spacing(2), - }), - }; -} diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx index 7f0ae393755..7018bc3f9c9 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx @@ -7,7 +7,6 @@ import { Button, Dropdown, Icon, LinkButton, Menu, Stack } from '@grafana/ui'; import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; import { GrafanaRulesExporter } from '../components/export/GrafanaRulesExporter'; -import RulesFilter from '../components/rules/Filter/RulesFilter'; import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelector'; import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton'; import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; @@ -17,6 +16,7 @@ import { isAdmin } from '../utils/misc'; import { FilterView } from './FilterView'; import { GroupedView } from './GroupedView'; import { RuleListPageTitle } from './RuleListPageTitle'; +import RulesFilter from './filter/RulesFilter'; function RuleList() { const { filterState } = useRulesFilter(); diff --git a/public/app/features/alerting/unified/components/rules/RulesFilter.test.tsx b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.test.tsx similarity index 91% rename from public/app/features/alerting/unified/components/rules/RulesFilter.test.tsx rename to public/app/features/alerting/unified/rule-list/filter/RulesFilter.test.tsx index 720e0ed1275..a0d383f71c6 100644 --- a/public/app/features/alerting/unified/components/rules/RulesFilter.test.tsx +++ b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.test.tsx @@ -7,13 +7,13 @@ import { setupMswServer } from 'app/features/alerting/unified/mockApi'; import * as analytics from '../../Analytics'; import { setupPluginsExtensionsHook } from '../../testSetup/plugins'; -import RulesFilter from './Filter/RulesFilter'; +import RulesFilter from './RulesFilter'; setupMswServer(); jest.spyOn(analytics, 'logInfo'); -jest.mock('./MultipleDataSourcePicker', () => { - const original = jest.requireActual('./MultipleDataSourcePicker'); +jest.mock('../../components/rules/MultipleDataSourcePicker', () => { + const original = jest.requireActual('../../components/rules/MultipleDataSourcePicker'); return { ...original, MultipleDataSourcePicker: () => null, diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.tsx similarity index 77% rename from public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx rename to public/app/features/alerting/unified/rule-list/filter/RulesFilter.tsx index 3eea628410f..c3a159c7ab2 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx +++ b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.tsx @@ -2,8 +2,8 @@ import { Suspense, lazy } from 'react'; import { config } from '@grafana/runtime'; -import RulesFilterV1 from './RulesFilter.v1'; -import { SupportedView } from './RulesViewModeSelector'; +import RulesFilterV1 from '../../components/rules/Filter/RulesFilter.v1'; +import { SupportedView } from '../../components/rules/Filter/RulesViewModeSelector'; const RulesFilterV2 = lazy(() => import('./RulesFilter.v2')); diff --git a/public/app/features/alerting/unified/components/rules/RulesFilterV2.test.tsx b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.test.tsx similarity index 76% rename from public/app/features/alerting/unified/components/rules/RulesFilterV2.test.tsx rename to public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.test.tsx index ecff8085f42..18d68d0f670 100644 --- a/public/app/features/alerting/unified/components/rules/RulesFilterV2.test.tsx +++ b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.test.tsx @@ -13,8 +13,12 @@ import { useRulesFilter } from '../../hooks/useFilteredRules'; import { RulesFilter as RulesFilterType } from '../../search/rulesSearchParser'; import { setupPluginsExtensionsHook } from '../../testSetup/plugins'; +import RulesFilter from './RulesFilter'; + // Grant permission before importing the component since permission check happens at module level grantUserPermissions([AccessControlAction.AlertingReceiversRead]); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const RulesFilterV2 = require('./RulesFilter.v2').default; let mockFilterState: RulesFilterType = { ruleName: '', @@ -40,9 +44,6 @@ jest.mock('../../hooks/useFilteredRules', () => ({ })), })); -import RulesFilter from './Filter/RulesFilter'; -import RulesFilterV2 from './Filter/RulesFilter.v2'; - const useRulesFilterMock = useRulesFilter as jest.MockedFunction; setupMswServer(); @@ -50,6 +51,8 @@ setupMswServer(); jest.spyOn(analytics, 'trackFilterButtonClick'); jest.spyOn(analytics, 'trackFilterButtonApplyClick'); jest.spyOn(analytics, 'trackFilterButtonClearClick'); +jest.spyOn(analytics, 'trackAlertRuleFilterEvent'); +jest.spyOn(analytics, 'trackRulesSearchInputCleared'); jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), @@ -61,8 +64,8 @@ jest.mock('@grafana/runtime', () => ({ }), })); -jest.mock('./MultipleDataSourcePicker', () => { - const original = jest.requireActual('./MultipleDataSourcePicker'); +jest.mock('../../components/rules/MultipleDataSourcePicker', () => { + const original = jest.requireActual('../../components/rules/MultipleDataSourcePicker'); return { ...original, MultipleDataSourcePicker: () => null, @@ -119,10 +122,25 @@ beforeEach(() => { labels: [], }; mockSearchQuery = ''; - mockUpdateFilters.mockClear(); - mockSetSearchQuery.mockClear(); - mockClearAll.mockClear(); + // Fully reset mock implementations between tests to avoid leakage across cases + mockUpdateFilters.mockReset(); + mockSetSearchQuery.mockReset(); + mockClearAll.mockReset(); + mockUpdateFilters.mockImplementation(() => {}); mockSetSearchQuery.mockImplementation(() => {}); + mockClearAll.mockImplementation(() => {}); + + // Restore the default implementation of the hook to use current mock variables + useRulesFilterMock.mockReset(); + useRulesFilterMock.mockImplementation(() => ({ + searchQuery: mockSearchQuery, + filterState: mockFilterState, + updateFilters: mockUpdateFilters, + setSearchQuery: mockSetSearchQuery, + clearAll: mockClearAll, + hasActiveFilters: false, + activeFilters: [], + })); // Reset plugin components hook to default (no plugins) setPluginComponentsHook(() => ({ @@ -209,21 +227,33 @@ describe('RulesFilterV2', () => { }); it('Should populate search field with query string when filters are applied via rule name', async () => { - const { user } = render(); + const { user, rerender } = render(); await user.click(ui.filterButton.get()); await user.type(ui.ruleNameInput.get(), 'test'); - // Mock the setSearchQuery to update mockSearchQuery - mockSetSearchQuery.mockImplementation((newQuery: string | undefined) => { - mockSearchQuery = newQuery ?? ''; + // Mock updateFilters to update the search query as the implementation does + mockUpdateFilters.mockImplementation(() => { + mockSearchQuery = 'rule:test'; }); await user.click(ui.applyButton.get()); - // Check that setSearchQuery was called with the expected query - expect(mockSetSearchQuery).toHaveBeenCalledWith('rule:test'); + // Update the mock to return the new search query and re-render + useRulesFilterMock.mockReturnValue({ + searchQuery: mockSearchQuery, + filterState: mockFilterState, + updateFilters: mockUpdateFilters, + setSearchQuery: mockSetSearchQuery, + clearAll: mockClearAll, + hasActiveFilters: false, + activeFilters: [], + }); + rerender(); + + // The search input should reflect the updated query string + expect(ui.searchInput.get()).toHaveValue('rule:test'); }); it('Should parse search query and call updateFilters when user types directly in search field', async () => { @@ -294,7 +324,7 @@ describe('RulesFilterV2', () => { // Permission is already mocked to true at module level const { user } = render(); await user.click(ui.filterButton.get()); - expect(screen.getByText('Contact point')).toBeInTheDocument(); + expect(await screen.findByText('Contact point')).toBeInTheDocument(); }); it('Should show plugin filter when plugins are enabled', async () => { @@ -306,7 +336,7 @@ describe('RulesFilterV2', () => { const { user } = render(); await user.click(ui.filterButton.get()); - expect(screen.getByText('Plugin rules')).toBeInTheDocument(); + expect(await screen.findByText('Plugin rules')).toBeInTheDocument(); }); it('Should hide plugin filter when no plugins are available', async () => { @@ -349,6 +379,40 @@ describe('RulesFilterV2', () => { expect(analytics.trackFilterButtonApplyClick).toHaveBeenCalledTimes(1); }); + it('Should track search input submit with parsed filter payload', async () => { + const { user } = render(); + + await user.type(ui.searchInput.get(), 'rule:test state:firing'); + await user.keyboard('{Enter}'); + + expect(analytics.trackAlertRuleFilterEvent).toHaveBeenCalled(); + const callArg = (analytics.trackAlertRuleFilterEvent as jest.Mock).mock.calls.at(-1)?.[0]; + expect(callArg.filterMethod).toBe('search-input'); + expect(callArg.filter).toMatchObject({ ruleName: 'test', ruleState: 'firing' }); + }); + + it('Should track search input blur with parsed filter payload', async () => { + const { user } = render(); + + await user.type(ui.searchInput.get(), 'state:firing'); + await user.click(document.body); + + expect(analytics.trackAlertRuleFilterEvent).toHaveBeenCalled(); + const callArg = (analytics.trackAlertRuleFilterEvent as jest.Mock).mock.calls.at(-1)?.[0]; + expect(callArg.filterMethod).toBe('search-input'); + expect(callArg.filter).toMatchObject({ ruleState: 'firing' }); + }); + + it('Should track search input clear when input transitions to empty', async () => { + const { user } = render(); + + await user.type(ui.searchInput.get(), 'abc'); + expect(ui.searchInput.get()).toHaveValue('abc'); + await user.clear(ui.searchInput.get()); + + expect(analytics.trackRulesSearchInputCleared).toHaveBeenCalled(); + }); + it('Should not track filter button click when filter button is clicked to close popup', async () => { const { user } = render(); diff --git a/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx new file mode 100644 index 00000000000..69b3b865a43 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx @@ -0,0 +1,753 @@ +import { css } from '@emotion/css'; +import { useEffect, useRef, useState } from 'react'; +import { Controller, FormProvider, SubmitHandler, useForm, useFormContext } from 'react-hook-form'; + +import { ContactPointSelector } from '@grafana/alerting/unstable'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { + Box, + Button, + Combobox, + FilterInput, + Icon, + Input, + Label, + MultiCombobox, + RadioButtonGroup, + Stack, + Tooltip, + useStyles2, + useTheme2, +} from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; +import type { AdvancedFilters } from 'app/features/alerting/unified/rule-list/filter/types'; +import { AccessControlAction } from 'app/types/accessControl'; +import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; + +import { + trackAlertRuleFilterEvent, + trackFilterButtonApplyClick, + trackFilterButtonClearClick, + trackFilterButtonClick, + trackRulesSearchInputCleared, +} from '../../Analytics'; +import { PopupCard } from '../../components/HoverCard'; +import { RulesViewModeSelector } from '../../components/rules/Filter/RulesViewModeSelector'; +import { + useAlertingDataSourceOptions, + useLabelOptions, + useNamespaceAndGroupOptions, +} from '../../components/rules/Filter/useRuleFilterAutocomplete'; +import { useRulesFilter } from '../../hooks/useFilteredRules'; +import { RuleHealth, getSearchFilterFromQuery } from '../../search/rulesSearchParser'; + +import { RulesFilterProps } from './RulesFilter'; +import { + emptyAdvancedFilters, + formAdvancedFiltersToRuleFilter, + searchQueryToDefaultValues, + usePluginsFilterStatus, + usePortalContainer, +} from './utils'; + +const canRenderContactPointSelector = contextSrv.hasPermission(AccessControlAction.AlertingReceiversRead); + +type SearchQueryForm = { + query: string; +}; + +export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterProps) { + const styles = useStyles2(getStyles); + + const [isPopupOpen, setIsPopupOpen] = useState(false); + const { searchQuery, updateFilters, setSearchQuery } = useRulesFilter(); + const popupRef = useRef(null); + const { pluginsFilterEnabled } = usePluginsFilterStatus(); + + // this form will managed the search query string, which is updated either by the user typing in the input or by the advanced filters + const { control, setValue, handleSubmit } = useForm({ + defaultValues: { + query: searchQuery, + }, + }); + + useEffect(() => { + setValue('query', searchQuery); + }, [searchQuery, setValue]); + + const submitHandler: SubmitHandler = (values: SearchQueryForm) => { + const parsedFilter = getSearchFilterFromQuery(values.query); + trackAlertRuleFilterEvent({ filterMethod: 'search-input', filter: parsedFilter }); + updateFilters(parsedFilter); + }; + + const handleAdvancedFilters: SubmitHandler = (values) => { + const newFilter = formAdvancedFiltersToRuleFilter(values); + updateFilters(newFilter); + + trackFilterButtonApplyClick(values, pluginsFilterEnabled); + setIsPopupOpen(false); // Should close popup after applying filters? + }; + + const handleClearFilters = () => { + updateFilters(formAdvancedFiltersToRuleFilter(emptyAdvancedFilters)); + setSearchQuery(undefined); + }; + + const handleOnToggle = () => { + trackFilterButtonClick(); + setIsPopupOpen(!isPopupOpen); + }; + + // Handle outside clicks to close the popup + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (isPopupOpen && popupRef.current && event.target instanceof Node && !popupRef.current.contains(event.target)) { + // Check if click is on a portal element (combobox dropdown) + if (event.target instanceof Element) { + const isPortalClick = + event.target.closest('[data-popper-placement]') || event.target.closest('[role="listbox"]'); + + if (!isPortalClick) { + setIsPopupOpen(false); + } + } else { + setIsPopupOpen(false); + } + } + }; + + if (isPopupOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isPopupOpen]); + + const filterButtonLabel = t('alerting.rules-filter.filter-options.aria-label-show-filters', 'Filter'); + return ( +
{}}> + + + + + ( + { + trackRulesSearchInputCleared(field.value, next); + field.onChange(next); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === 'NumpadEnter') { + event.preventDefault(); + handleSubmit(submitHandler)(); + } + }} + onBlur={() => { + const currentQuery = field.value; + const parsedFilter = getSearchFilterFromQuery(currentQuery); + trackAlertRuleFilterEvent({ filterMethod: 'search-input', filter: parsedFilter }); + updateFilters(parsedFilter); + }} + value={field.value} + /> + )} + /> + + {/* the popup card is mounted inside of a portal, so we can't rely on the usual form handling mechanisms of button[type=submit] */} + setIsPopupOpen(false)} + onToggle={handleOnToggle} + content={ + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + } + }} + role="dialog" + aria-label={t('alerting.rules-filter.filter-options.aria-label', 'Filter options')} + tabIndex={-1} + > + +
+ } + > + +
+ +
+
+
+ ); +} + +interface FilterOptionsProps { + onSubmit: SubmitHandler; + onClear: () => void; + pluginsFilterEnabled: boolean; +} + +const FilterOptions = ({ onSubmit, onClear, pluginsFilterEnabled }: FilterOptionsProps) => { + const styles = useStyles2(getStyles); + const theme = useTheme2(); + const { filterState } = useRulesFilter(); + const isManualResetRef = useRef(false); + + // Create portal container to render dropdowns above the popup modal + const portalContainer = usePortalContainer(theme.zIndex.portal + 100); + + const defaultValues = searchQueryToDefaultValues(filterState); + + // Fetch namespace and group data from all sources (optimized for filter UI) + const { namespaceOptions, allGroupNames, isLoadingNamespaces, namespacePlaceholder, groupPlaceholder } = + useNamespaceAndGroupOptions(); + + const { labelOptions, isLoadingGrafanaLabels } = useLabelOptions(); + + // Create label options for the multi-select dropdown + const dataSourceOptions = useAlertingDataSourceOptions(); + + // turn the filterState into form default values + const methods = useForm({ + defaultValues, + }); + const { handleSubmit, reset } = methods; + + // Update form values when filterState changes (e.g., when popup reopens) + useEffect(() => { + // Skip if we're in the middle of a manual reset + if (isManualResetRef.current) { + isManualResetRef.current = false; + return; + } + + const newDefaultValues = searchQueryToDefaultValues(filterState); + reset(newDefaultValues); + }, [filterState, reset]); + + const submitAdvancedFilters = handleSubmit(onSubmit); + + return ( + +
{ + isManualResetRef.current = true; + reset(emptyAdvancedFilters); + trackFilterButtonClearClick(); + onClear(); + }} + > + +
+ + + + + + {canRenderContactPointSelector && } + + + + {pluginsFilterEnabled && } +
+ + + + +
+
+
+ ); +}; + +function RuleNameField() { + const { register } = useFormContext(); + return ( + <> + + + + ); +} + +function LabelsField({ + labelOptions, + isLoadingGrafanaLabels, + portalContainer, +}: { + labelOptions: Array<{ label?: string; value: string; infoOption?: boolean }>; + isLoadingGrafanaLabels: boolean; + portalContainer?: HTMLElement; +}) { + const { control } = useFormContext(); + return ( + <> + + ( + field.onChange(selections.map((s) => s.value))} + placeholder={ + isLoadingGrafanaLabels + ? t('common.loading', 'Loading...') + : t('alerting.rules-filter.placeholder-labels', 'Select labels') + } + loading={isLoadingGrafanaLabels} + disabled={isLoadingGrafanaLabels || labelOptions.filter((option) => !option.infoOption).length === 0} + portalContainer={portalContainer} + width="auto" + minWidth={40} + maxWidth={80} + /> + )} + /> + + ); +} + +function NamespaceField({ + namespaceOptions, + namespacePlaceholder, + isLoadingNamespaces, + portalContainer, +}: { + namespaceOptions: Array<{ label?: string; value: string; description?: string }>; + namespacePlaceholder: string; + isLoadingNamespaces: boolean; + portalContainer?: HTMLElement; +}) { + const { control } = useFormContext(); + return ( + <> + + { + return ( + + placeholder={namespacePlaceholder} + options={namespaceOptions} + onChange={(option) => field.onChange(option?.value || null)} + value={field.value} + loading={isLoadingNamespaces} + disabled={isLoadingNamespaces || namespaceOptions.length === 0} + isClearable + portalContainer={portalContainer} + /> + ); + }} + /> + + ); +} + +function GroupField({ + allGroupNames, + groupPlaceholder, + isLoadingNamespaces, + portalContainer, +}: { + allGroupNames: string[]; + groupPlaceholder: string; + isLoadingNamespaces: boolean; + portalContainer?: HTMLElement; +}) { + const { control } = useFormContext(); + return ( + <> + + { + return ( + + placeholder={groupPlaceholder} + options={allGroupNames.map((name) => ({ label: name, value: name }))} + onChange={(option) => field.onChange(option?.value || null)} + value={field.value} + loading={isLoadingNamespaces} + disabled={isLoadingNamespaces || allGroupNames.length === 0} + isClearable + portalContainer={portalContainer} + /> + ); + }} + /> + + ); +} + +function DataSourceNamesField({ + dataSourceOptions, + portalContainer, +}: { + dataSourceOptions: Array<{ label?: string; value: string }>; + portalContainer?: HTMLElement; +}) { + const { control } = useFormContext(); + return ( + <> + + ( + field.onChange(selections.map((s) => s.value))} + placeholder={t('alerting.rules-filter.placeholder-data-sources', 'Select data sources')} + portalContainer={portalContainer} + width="auto" + minWidth={40} + maxWidth={80} + /> + )} + /> + + ); +} + +function ContactPointField({ portalContainer }: { portalContainer?: HTMLElement }) { + const { control } = useFormContext(); + return ( + <> + + { + return ( + { + field.onChange(contactPoint?.spec.title || null); + }} + portalContainer={portalContainer} + /> + ); + }} + /> + + ); +} + +function RuleStateField() { + const { control } = useFormContext(); + return ( + <> + + ( + + options={[ + { label: t('common.all', 'All'), value: '*' }, + { label: t('alerting.rules.state.firing', 'Firing'), value: PromAlertingRuleState.Firing }, + { label: t('alerting.rules.state.normal', 'Normal'), value: PromAlertingRuleState.Inactive }, + { label: t('alerting.rules.state.pending', 'Pending'), value: PromAlertingRuleState.Pending }, + { label: t('alerting.rules.state.recovering', 'Recovering'), value: PromAlertingRuleState.Recovering }, + { label: t('alerting.rules.state.unknown', 'Unknown'), value: PromAlertingRuleState.Unknown }, + ]} + value={field.value} + onChange={field.onChange} + /> + )} + /> + + ); +} + +function RuleTypeField() { + const { control } = useFormContext(); + return ( + <> + + ( + + options={[ + { label: t('common.all', 'All'), value: '*' }, + { label: t('alerting.rules.type.alert', 'Alert rule'), value: PromRuleType.Alerting }, + { label: t('alerting.rules.type.recording', 'Recording rule'), value: PromRuleType.Recording }, + ]} + value={field.value} + onChange={field.onChange} + /> + )} + /> + + ); +} + +function RuleHealthField() { + const { control } = useFormContext(); + return ( + <> + + ( + + options={[ + { label: t('common.all', 'All'), value: '*' }, + { label: t('alerting.rules.health.ok', 'OK'), value: RuleHealth.Ok }, + { label: t('alerting.rules.health.no-data', 'No data'), value: RuleHealth.NoData }, + { label: t('alerting.rules.health.error', 'Error'), value: RuleHealth.Error }, + ]} + value={field.value} + onChange={field.onChange} + /> + )} + /> + + ); +} + +function PluginsField() { + const { control } = useFormContext(); + return ( + <> + + ( + + options={[ + { label: t('alerting.rules-filter.label.show', 'Show'), value: 'show' }, + { label: t('alerting.rules-filter.label.hide', 'Hide'), value: 'hide' }, + ]} + value={field.value} + onChange={field.onChange} + /> + )} + /> + + ); +} + +function SearchQueryHelp() { + const styles = useStyles2(helpStyles); + + return ( +
+
+ + Search syntax allows to query alert rules by the parameters defined below. + +
+
+
+
+ Filter type +
+
+ Expression +
+ + + + + + + + + + +
+
+ ); +} + +function HelpRow({ title, expr }: { title: string; expr: string }) { + const styles = useStyles2(helpStyles); + + return ( + <> +
{title}
+ {expr} + + ); +} + +const helpStyles = (theme: GrafanaTheme2) => ({ + grid: css({ + display: 'grid', + gridTemplateColumns: 'max-content auto', + gap: theme.spacing(1), + alignItems: 'center', + }), + code: css({ + display: 'block', + textAlign: 'center', + }), +}); + +function getStyles(theme: GrafanaTheme2) { + return { + content: css({ + padding: theme.spacing(1), + }), + grid: css({ + display: 'grid', + gridTemplateColumns: 'auto 1fr', + alignItems: 'center', + gap: theme.spacing(2), + }), + }; +} diff --git a/public/app/features/alerting/unified/rule-list/filter/types.ts b/public/app/features/alerting/unified/rule-list/filter/types.ts new file mode 100644 index 00000000000..dbfa6ad59db --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/filter/types.ts @@ -0,0 +1,17 @@ +import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; + +import type { RuleHealth } from '../../search/rulesSearchParser'; + +export type AdvancedFilters = { + namespace?: string | null; + groupName?: string | null; + ruleName?: string; + ruleType?: PromRuleType | '*'; + ruleState: PromAlertingRuleState | '*'; + dataSourceNames: string[]; + labels: string[]; + ruleHealth?: RuleHealth | '*'; + dashboardUid?: string; + plugins?: 'show' | 'hide'; + contactPoint?: string | null; +}; diff --git a/public/app/features/alerting/unified/components/rules/Filter/utils.ts b/public/app/features/alerting/unified/rule-list/filter/utils.ts similarity index 91% rename from public/app/features/alerting/unified/components/rules/Filter/utils.ts rename to public/app/features/alerting/unified/rule-list/filter/utils.ts index 049634634af..798a4b0bade 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/utils.ts +++ b/public/app/features/alerting/unified/rule-list/filter/utils.ts @@ -1,9 +1,9 @@ import { useEffect, useRef } from 'react'; -import { useAlertingHomePageExtensions } from '../../../plugins/useAlertingHomePageExtensions'; -import { RulesFilter } from '../../../search/rulesSearchParser'; +import { useAlertingHomePageExtensions } from '../../plugins/useAlertingHomePageExtensions'; +import { RulesFilter } from '../../search/rulesSearchParser'; -import { AdvancedFilters } from './RulesFilter.v2'; +import { AdvancedFilters } from './types'; export function formAdvancedFiltersToRuleFilter(values: AdvancedFilters): RulesFilter { return { From 6d538f62c836b34d96ed1992e8c56af01575e89c Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Thu, 21 Aug 2025 12:17:43 -0400 Subject: [PATCH 09/47] Provisioning components organize (#109741) * Provisioning components organize --- .../BrowseDashboardsPage.tsx | 2 +- .../BrowseActions/BrowseActions.tsx | 6 +-- .../components/BrowseActions/utils.ts | 41 ----------------- .../components/CheckboxCell.tsx | 2 +- .../components/CreateNewButton.tsx | 2 +- .../components/FolderActionsButton.tsx | 2 +- .../browse-dashboards/components/utils.ts | 45 +------------------ .../pages/DashboardScenePage.tsx | 2 +- .../saving/SaveDashboardAsForm.tsx | 2 +- .../saving/SaveDashboardDrawer.tsx | 2 +- .../dashboard-scene/saving/shared.tsx | 18 -------- .../settings/DeleteDashboardButton.tsx | 3 +- .../settings/GeneralSettingsEditView.tsx | 2 +- .../dashboard-scene/settings/utils.ts | 27 ----------- .../BulkDeleteProvisionedResource.test.tsx | 6 +-- .../BulkDeleteProvisionedResource.tsx | 14 +++--- .../BulkMoveProvisionedResource.tsx | 14 +++--- .../BulkActions/useBulkActionJob.ts | 0 .../BulkActions/useFolderNameFromSelection.ts | 7 ++- .../components/BulkActions/utils.test.ts | 0 .../components/BulkActions/utils.ts | 3 +- .../Dashboards}/DashboardPreviewBanner.tsx | 2 +- .../DeleteProvisionedDashboardDrawer.tsx | 5 ++- .../DeleteProvisionedDashboardForm.test.tsx | 19 +++++--- .../DeleteProvisionedDashboardForm.tsx | 13 +++--- .../MoveProvisionedDashboardDrawer.tsx | 5 ++- .../MoveProvisionedDashboardForm.test.tsx | 8 ++-- .../MoveProvisionedDashboardForm.tsx | 13 +++--- .../Dashboards}/SaveProvisionedDashboard.tsx | 9 ++-- .../SaveProvisionedDashboardForm.test.tsx | 11 +++-- .../SaveProvisionedDashboardForm.tsx | 19 ++++---- .../DeleteProvisionedFolderForm.test.tsx | 11 +++-- .../Folders}/DeleteProvisionedFolderForm.tsx | 20 ++++----- .../NewProvisionedFolderForm.test.tsx | 18 +++----- .../Folders}/NewProvisionedFolderForm.tsx | 16 +++---- .../ProvisionedFolderPreviewBanner.tsx | 4 +- .../Shared}/PreviewBannerViewPR.test.tsx | 0 .../Shared}/PreviewBannerViewPR.tsx | 2 +- .../Shared}/RepoInvalidStateBanner.tsx | 0 .../ResourceEditFormSharedFields.test.tsx | 5 ++- .../Shared}/ResourceEditFormSharedFields.tsx | 0 .../components}/defaults.ts | 0 .../components}/utils/getProvisionedMeta.ts | 0 .../components}/utils/path.test.ts | 0 .../components}/utils/path.ts | 0 .../components}/utils/timestamp.test.ts | 0 .../components}/utils/timestamp.ts | 0 .../hooks/useProvisionedDashboardData.ts} | 11 +++-- .../hooks/useProvisionedFolderFormData.ts | 8 ++-- .../useProvisionedRequestHandler.test.ts | 0 .../hooks}/useProvisionedRequestHandler.ts | 0 .../hooks}/useSelectionProvisioningStatus.ts | 4 +- .../hooks}/useSelectionRepoValidation.ts | 9 ++-- .../app/features/provisioning/types/form.ts | 18 ++++++++ .../features/provisioning/utils/redirect.ts | 26 +++++++++++ .../features/provisioning/utils/repository.ts | 36 +++++++++++++++ 56 files changed, 220 insertions(+), 272 deletions(-) rename public/app/features/{browse-dashboards => provisioning}/components/BulkActions/BulkDeleteProvisionedResource.test.tsx (97%) rename public/app/features/{browse-dashboards => provisioning}/components/BulkActions/BulkDeleteProvisionedResource.tsx (89%) rename public/app/features/{browse-dashboards => provisioning}/components/BulkActions/BulkMoveProvisionedResource.tsx (92%) rename public/app/features/{browse-dashboards => provisioning}/components/BulkActions/useBulkActionJob.ts (100%) rename public/app/features/{browse-dashboards => provisioning}/components/BulkActions/useFolderNameFromSelection.ts (89%) rename public/app/features/{browse-dashboards => provisioning}/components/BulkActions/utils.test.ts (100%) rename public/app/features/{browse-dashboards => provisioning}/components/BulkActions/utils.ts (97%) rename public/app/features/{dashboard-scene/saving/provisioned => provisioning/components/Dashboards}/DashboardPreviewBanner.tsx (97%) rename public/app/features/{dashboard-scene/settings => provisioning/components/Dashboards}/DeleteProvisionedDashboardDrawer.tsx (82%) rename public/app/features/{dashboard-scene/settings => provisioning/components/Dashboards}/DeleteProvisionedDashboardForm.test.tsx (92%) rename public/app/features/{dashboard-scene/settings => provisioning/components/Dashboards}/DeleteProvisionedDashboardForm.tsx (90%) rename public/app/features/{dashboard-scene/settings => provisioning/components/Dashboards}/MoveProvisionedDashboardDrawer.tsx (85%) rename public/app/features/{dashboard-scene/settings => provisioning/components/Dashboards}/MoveProvisionedDashboardForm.test.tsx (95%) rename public/app/features/{dashboard-scene/settings => provisioning/components/Dashboards}/MoveProvisionedDashboardForm.tsx (94%) rename public/app/features/{dashboard-scene/saving/provisioned => provisioning/components/Dashboards}/SaveProvisionedDashboard.tsx (70%) rename public/app/features/{dashboard-scene/saving/provisioned => provisioning/components/Dashboards}/SaveProvisionedDashboardForm.test.tsx (96%) rename public/app/features/{dashboard-scene/saving/provisioned => provisioning/components/Dashboards}/SaveProvisionedDashboardForm.tsx (93%) rename public/app/features/{browse-dashboards/components => provisioning/components/Folders}/DeleteProvisionedFolderForm.test.tsx (96%) rename public/app/features/{browse-dashboards/components => provisioning/components/Folders}/DeleteProvisionedFolderForm.tsx (88%) rename public/app/features/{browse-dashboards/components => provisioning/components/Folders}/NewProvisionedFolderForm.test.tsx (95%) rename public/app/features/{browse-dashboards/components => provisioning/components/Folders}/NewProvisionedFolderForm.tsx (93%) rename public/app/features/{browse-dashboards/components => provisioning/components/Folders}/ProvisionedFolderPreviewBanner.tsx (76%) rename public/app/features/{dashboard-scene/saving/provisioned => provisioning/components/Shared}/PreviewBannerViewPR.test.tsx (100%) rename public/app/features/{dashboard-scene/saving/provisioned => provisioning/components/Shared}/PreviewBannerViewPR.tsx (98%) rename public/app/features/{browse-dashboards/components/BulkActions => provisioning/components/Shared}/RepoInvalidStateBanner.tsx (100%) rename public/app/features/{dashboard-scene/components/Provisioned => provisioning/components/Shared}/ResourceEditFormSharedFields.test.tsx (98%) rename public/app/features/{dashboard-scene/components/Provisioned => provisioning/components/Shared}/ResourceEditFormSharedFields.tsx (100%) rename public/app/features/{dashboard-scene/saving/provisioned => provisioning/components}/defaults.ts (100%) rename public/app/features/{dashboard-scene/saving/provisioned => provisioning/components}/utils/getProvisionedMeta.ts (100%) rename public/app/features/{dashboard-scene/saving/provisioned => provisioning/components}/utils/path.test.ts (100%) rename public/app/features/{dashboard-scene/saving/provisioned => provisioning/components}/utils/path.ts (100%) rename public/app/features/{dashboard-scene/saving/provisioned => provisioning/components}/utils/timestamp.test.ts (100%) rename public/app/features/{dashboard-scene/saving/provisioned => provisioning/components}/utils/timestamp.ts (100%) rename public/app/features/{dashboard-scene/saving/provisioned/hooks.ts => provisioning/hooks/useProvisionedDashboardData.ts} (91%) rename public/app/features/{browse-dashboards => provisioning}/hooks/useProvisionedFolderFormData.ts (85%) rename public/app/features/{dashboard-scene/utils => provisioning/hooks}/useProvisionedRequestHandler.test.ts (100%) rename public/app/features/{dashboard-scene/utils => provisioning/hooks}/useProvisionedRequestHandler.ts (100%) rename public/app/features/{browse-dashboards/components/BrowseActions => provisioning/hooks}/useSelectionProvisioningStatus.ts (97%) rename public/app/features/{browse-dashboards/components/BrowseActions => provisioning/hooks}/useSelectionRepoValidation.ts (89%) create mode 100644 public/app/features/provisioning/types/form.ts create mode 100644 public/app/features/provisioning/utils/redirect.ts diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index 038a32d1af6..a5e7861a2c9 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -16,6 +16,7 @@ import { FolderRepo } from '../../core/components/NestedFolderPicker/FolderRepo' import { contextSrv } from '../../core/services/context_srv'; import { ManagerKind } from '../apiserver/types'; import { buildNavModel, getDashboardsTabID } from '../folders/state/navModel'; +import { ProvisionedFolderPreviewBanner } from '../provisioning/components/Folders/ProvisionedFolderPreviewBanner'; import { useGetResourceRepositoryView } from '../provisioning/hooks/useGetResourceRepositoryView'; import { useSearchStateManager } from '../search/state/SearchStateManager'; import { getSearchPlaceholder } from '../search/tempI18nPhrases'; @@ -26,7 +27,6 @@ import { BrowseFilters } from './components/BrowseFilters'; import { BrowseView } from './components/BrowseView'; import CreateNewButton from './components/CreateNewButton'; import { FolderActionsButton } from './components/FolderActionsButton'; -import { ProvisionedFolderPreviewBanner } from './components/ProvisionedFolderPreviewBanner'; import { SearchView } from './components/SearchView'; import { getFolderPermissions } from './permissions'; import { useHasSelection } from './state/hooks'; diff --git a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx index 5e538d6e91b..ad0a72df516 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx @@ -5,6 +5,9 @@ import { config, reportInteraction } from '@grafana/runtime'; import { Button, Drawer, Stack } from '@grafana/ui'; import appEvents from 'app/core/app_events'; import { ManagerKind } from 'app/features/apiserver/types'; +import { BulkDeleteProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource'; +import { BulkMoveProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource'; +import { useSelectionProvisioningStatus } from 'app/features/provisioning/hooks/useSelectionProvisioningStatus'; import { useSearchStateManager } from 'app/features/search/state/SearchStateManager'; import { ShowModalReactEvent } from 'app/types/events'; import { FolderDTO } from 'app/types/folders'; @@ -14,13 +17,10 @@ import { useDeleteItemsMutation, useMoveItemsMutation } from '../../api/browseDa import { useActionSelectionState } from '../../state/hooks'; import { setAllSelection } from '../../state/slice'; import { DashboardTreeSelection } from '../../types'; -import { BulkDeleteProvisionedResource } from '../BulkActions/BulkDeleteProvisionedResource'; -import { BulkMoveProvisionedResource } from '../BulkActions/BulkMoveProvisionedResource'; import { DeleteModal } from './DeleteModal'; import { MoveModal } from './MoveModal'; import { SelectedMixResourcesMsgModal } from './SelectedMixResourcesMsgModal'; -import { useSelectionProvisioningStatus } from './useSelectionProvisioningStatus'; export interface Props { folderDTO?: FolderDTO; diff --git a/public/app/features/browse-dashboards/components/BrowseActions/utils.ts b/public/app/features/browse-dashboards/components/BrowseActions/utils.ts index 59a3979cf2f..2d6b4328c53 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/utils.ts +++ b/public/app/features/browse-dashboards/components/BrowseActions/utils.ts @@ -1,8 +1,4 @@ import { t } from '@grafana/i18n'; -import { DashboardViewItem } from 'app/features/search/types'; - -import { findItem } from '../../state/utils'; -import { DashboardViewItemCollection } from '../../types'; export function buildBreakdownString( folderCount: number, @@ -30,40 +26,3 @@ export function buildBreakdownString( } return breakdownString; } - -// Utility: Get root folder for any item (reusing existing pattern from reducers.ts) -export function getItemRootFolder( - item: { uid: string; parentUID?: string; kind?: string }, - browseState: { - rootItems?: { items: DashboardViewItem[] }; - childrenByParentUID: Record; - } -): string | undefined { - const rootItems = browseState.rootItems?.items || []; - - // If it's already a root-level item, return its UID (only for folders) - if (!item.parentUID) { - return item.kind === 'folder' ? item.uid : undefined; - } - - // For nested items, traverse up to find root folder (same pattern as reducers.ts) - let nextParentUID = item.parentUID; - - while (nextParentUID) { - const parent = findItem(rootItems, browseState.childrenByParentUID, nextParentUID); - - // Safety check to prevent infinite loops (same as reducers.ts) - if (!parent) { - break; - } - - // Found the root folder (no parent) - if (!parent.parentUID) { - return parent.uid; - } - - nextParentUID = parent.parentUID; - } - - return undefined; -} diff --git a/public/app/features/browse-dashboards/components/CheckboxCell.tsx b/public/app/features/browse-dashboards/components/CheckboxCell.tsx index d232a3a4620..c86fd681c95 100644 --- a/public/app/features/browse-dashboards/components/CheckboxCell.tsx +++ b/public/app/features/browse-dashboards/components/CheckboxCell.tsx @@ -6,12 +6,12 @@ import { t } from '@grafana/i18n'; import { Checkbox, Tooltip, useStyles2 } from '@grafana/ui'; import { ManagerKind } from 'app/features/apiserver/types'; import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance'; +import { useSelectionRepoValidation } from 'app/features/provisioning/hooks/useSelectionRepoValidation'; import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository'; import { useSelector } from 'app/types/store'; import { DashboardsTreeCellProps, SelectionState } from '../types'; -import { useSelectionRepoValidation } from './BrowseActions/useSelectionRepoValidation'; import { isSharedWithMe, canEditItemType } from './utils'; export default function CheckboxCell({ diff --git a/public/app/features/browse-dashboards/components/CreateNewButton.tsx b/public/app/features/browse-dashboards/components/CreateNewButton.tsx index bc8b896b8ae..3fac2239c07 100644 --- a/public/app/features/browse-dashboards/components/CreateNewButton.tsx +++ b/public/app/features/browse-dashboards/components/CreateNewButton.tsx @@ -6,6 +6,7 @@ import { config, locationService, reportInteraction } from '@grafana/runtime'; import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; import { RepoType } from 'app/features/provisioning/Wizard/types'; +import { NewProvisionedFolderForm } from 'app/features/provisioning/components/Folders/NewProvisionedFolderForm'; import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance'; import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository'; import { @@ -20,7 +21,6 @@ import { ManagerKind } from '../../apiserver/types'; import { useNewFolderMutation } from '../api/browseDashboardsAPI'; import { NewFolderForm } from './NewFolderForm'; -import { NewProvisionedFolderForm } from './NewProvisionedFolderForm'; interface Props { parentFolder?: FolderDTO; diff --git a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx index c58b310e0e3..41d877a77c3 100644 --- a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx +++ b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx @@ -7,6 +7,7 @@ import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui'; import { Permissions } from 'app/core/components/AccessControl'; import { appEvents } from 'app/core/core'; import { RepoType } from 'app/features/provisioning/Wizard/types'; +import { DeleteProvisionedFolderForm } from 'app/features/provisioning/components/Folders/DeleteProvisionedFolderForm'; import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository'; import { ShowModalReactEvent } from 'app/types/events'; import { FolderDTO } from 'app/types/folders'; @@ -18,7 +19,6 @@ import { getFolderPermissions } from '../permissions'; import { DeleteModal } from './BrowseActions/DeleteModal'; import { MoveModal } from './BrowseActions/MoveModal'; -import { DeleteProvisionedFolderForm } from './DeleteProvisionedFolderForm'; interface Props { folder: FolderDTO; diff --git a/public/app/features/browse-dashboards/components/utils.ts b/public/app/features/browse-dashboards/components/utils.ts index de03451146f..818b96ebba7 100644 --- a/public/app/features/browse-dashboards/components/utils.ts +++ b/public/app/features/browse-dashboards/components/utils.ts @@ -1,17 +1,8 @@ import { config } from '@grafana/runtime'; import { contextSrv } from 'app/core/core'; -import { ManagerKind } from 'app/features/apiserver/types'; -import { DashboardViewItem } from 'app/features/search/types'; +import { ResourceRef } from 'app/features/provisioning/components/BulkActions/useBulkActionJob'; -import { findItem } from '../state/utils'; -import { - DashboardTreeSelection, - DashboardViewItemWithUIItems, - BrowseDashboardsPermissions, - BrowseDashboardsState, -} from '../types'; - -import { ResourceRef } from './BulkActions/useBulkActionJob'; +import { DashboardTreeSelection, DashboardViewItemWithUIItems, BrowseDashboardsPermissions } from '../types'; export function makeRowID(baseId: string, item: DashboardViewItemWithUIItems) { return baseId + item.uid; @@ -69,35 +60,3 @@ export function canSelectItems(permissions: BrowseDashboardsPermissions) { const canSelectDashboards = canEditDashboards || canDeleteDashboards; return Boolean(canSelectFolders || canSelectDashboards); } - -/** - * Finds the repository name for an item by traversing up the tree to find the root provisioned folder (managed by ManagerKind.Repo) - * This should be an edge case where user have multiple provisioned folders and try to managing resources on root folder - */ -export function getItemRepositoryUid( - item: DashboardViewItem, - rootItems: DashboardViewItem[], - childrenByParentUID: BrowseDashboardsState['childrenByParentUID'] -): string { - // For root provisioned folders, the UID is the repository name - if (item.managedBy === ManagerKind.Repo && !item.parentUID && item.kind === 'folder') { - return item.uid; - } - - // Traverse up the tree to find the root provisioned folder - let currentItem = item; - while (currentItem.parentUID) { - const parent = findItem(rootItems, childrenByParentUID, currentItem.parentUID); - if (!parent) { - break; - } - - if (parent.managedBy === ManagerKind.Repo && !parent.parentUID) { - return currentItem.parentUID; - } - - currentItem = parent; - } - - return 'non_provisioned'; -} diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index d7eb249f1c7..4aa7e2e8987 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -12,10 +12,10 @@ import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { DashboardPageError } from 'app/features/dashboard/containers/DashboardPageError'; import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from 'app/features/dashboard/containers/types'; import { getDashboardSceneProfiler } from 'app/features/dashboard/services/DashboardProfiler'; +import { DashboardPreviewBanner } from 'app/features/provisioning/components/Dashboards/DashboardPreviewBanner'; import { DashboardRoutes } from 'app/types/dashboard'; import { DashboardPrompt } from '../saving/DashboardPrompt'; -import { DashboardPreviewBanner } from '../saving/provisioned/DashboardPreviewBanner'; import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState'; import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager'; diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx index 00001fac1f8..cc437b74486 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx @@ -7,10 +7,10 @@ import { Trans, t } from '@grafana/i18n'; import { Button, Input, Switch, Field, Label, TextArea, Stack, Alert, Box } from '@grafana/ui'; import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv'; +import { getProvisionedMeta } from 'app/features/provisioning/components/utils/getProvisionedMeta'; import { DashboardScene } from '../scene/DashboardScene'; -import { getProvisionedMeta } from './provisioned/utils/getProvisionedMeta'; import { DashboardChangeInfo, NameAlreadyExistsError, SaveButton, isNameExistsError } from './shared'; import { useSaveDashboard } from './useSaveDashboard'; diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx index 8d8e8f1fe13..10be6f94900 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx @@ -2,6 +2,7 @@ import { t } from '@grafana/i18n'; import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneObjectRef } from '@grafana/scenes'; import { Drawer, Tab, TabsBar } from '@grafana/ui'; import { SaveDashboardDiff } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDiff'; +import { SaveProvisionedDashboard } from 'app/features/provisioning/components/Dashboards/SaveProvisionedDashboard'; import { useIsProvisionedNG } from 'app/features/provisioning/hooks/useIsProvisionedNG'; import { DashboardScene } from '../scene/DashboardScene'; @@ -9,7 +10,6 @@ import { DashboardScene } from '../scene/DashboardScene'; import { SaveDashboardAsForm } from './SaveDashboardAsForm'; import { SaveDashboardForm } from './SaveDashboardForm'; import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm'; -import { SaveProvisionedDashboard } from './provisioned/SaveProvisionedDashboard'; interface SaveDashboardDrawerState extends SceneObjectState { dashboardRef: SceneObjectRef; diff --git a/public/app/features/dashboard-scene/saving/shared.tsx b/public/app/features/dashboard-scene/saving/shared.tsx index fece190d860..0eb70a1fe3b 100644 --- a/public/app/features/dashboard-scene/saving/shared.tsx +++ b/public/app/features/dashboard-scene/saving/shared.tsx @@ -6,7 +6,6 @@ import { config, isFetchError } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Alert, Box, Button, Stack } from '@grafana/ui'; -import { WorkflowOption } from 'app/features/provisioning/types'; import { Diffs } from '../settings/version-history/utils'; @@ -24,23 +23,6 @@ export interface DashboardChangeInfo { hasMigratedToV2?: boolean; } -export interface BaseProvisionedFormData { - ref?: string; - path: string; - comment?: string; - repo: string; - workflow?: WorkflowOption; - title: string; -} - -export interface ProvisionedDashboardFormData extends BaseProvisionedFormData { - description: string; - folder: { - uid?: string; - title?: string; - }; -} - export function isVersionMismatchError(error?: Error) { return isFetchError(error) && error.data && error.data.status === 'version-mismatch'; } diff --git a/public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx b/public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx index 1a902f78031..5f88d334eae 100644 --- a/public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx +++ b/public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx @@ -4,12 +4,11 @@ import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { config, reportInteraction } from '@grafana/runtime'; import { Button, ConfirmModal, Modal, Space, Text, TextLink } from '@grafana/ui'; +import { DeleteProvisionedDashboardDrawer } from 'app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardDrawer'; import { useDeleteItemsMutation } from '../../browse-dashboards/api/browseDashboardsAPI'; import { DashboardScene } from '../scene/DashboardScene'; -import { DeleteProvisionedDashboardDrawer } from './DeleteProvisionedDashboardDrawer'; - interface ButtonProps { dashboard: DashboardScene; } diff --git a/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx index ad47d202db9..aee4461f43e 100644 --- a/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx +++ b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx @@ -23,6 +23,7 @@ import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { TimePickerSettings } from 'app/features/dashboard/components/DashboardSettings/TimePickerSettings'; import { GenAIDashDescriptionButton } from 'app/features/dashboard/components/GenAI/GenAIDashDescriptionButton'; import { GenAIDashTitleButton } from 'app/features/dashboard/components/GenAI/GenAIDashTitleButton'; +import { MoveProvisionedDashboardDrawer } from 'app/features/provisioning/components/Dashboards/MoveProvisionedDashboardDrawer'; import { updateNavModel } from '../pages/utils'; import { DashboardScene } from '../scene/DashboardScene'; @@ -31,7 +32,6 @@ import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { getDashboardSceneFor } from '../utils/utils'; import { DeleteDashboardButton } from './DeleteDashboardButton'; -import { MoveProvisionedDashboardDrawer } from './MoveProvisionedDashboardDrawer'; import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils'; export interface GeneralSettingsEditViewState extends DashboardEditViewState { diff --git a/public/app/features/dashboard-scene/settings/utils.ts b/public/app/features/dashboard-scene/settings/utils.ts index 741830b8ec5..88011f08b00 100644 --- a/public/app/features/dashboard-scene/settings/utils.ts +++ b/public/app/features/dashboard-scene/settings/utils.ts @@ -111,30 +111,3 @@ export function createDashboardEditViewFor(editview: string): DashboardEditView return new GeneralSettingsEditView({}); } } - -export type ResourceBranchUrlOptions = { - baseUrl?: string; - paramName?: string; - paramValue?: string; - repoType?: string; -}; - -export function buildResourceBranchRedirectUrl({ - baseUrl = '/dashboards', - paramName, - paramValue, - repoType, -}: ResourceBranchUrlOptions): string { - const params = new URLSearchParams(); - - if (paramName && paramValue) { - params.set(paramName, paramValue); - } - - if (repoType) { - params.set('repo_type', repoType); - } - - const queryString = params.toString(); - return queryString ? `${baseUrl}?${queryString}` : baseUrl; -} diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx b/public/app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource.test.tsx similarity index 97% rename from public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx rename to public/app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource.test.tsx index 2a5be2aa309..fbcca6165e8 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.test.tsx +++ b/public/app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource.test.tsx @@ -3,12 +3,12 @@ import { render } from 'test/test-utils'; import { Job, RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; -import { useSelectionRepoValidation } from '../BrowseActions/useSelectionRepoValidation'; +import { useSelectionRepoValidation } from '../../hooks/useSelectionRepoValidation'; import { BulkDeleteProvisionedResource } from './BulkDeleteProvisionedResource'; import { ResponseType } from './useBulkActionJob'; -jest.mock('../BrowseActions/DescendantCount', () => ({ +jest.mock('app/features/browse-dashboards/components/BrowseActions/DescendantCount', () => ({ DescendantCount: jest.fn(({ selectedItems }) => (
Mocked descendant count for {Object.keys(selectedItems.folder).length} folders and{' '} @@ -21,7 +21,7 @@ jest.mock('app/features/provisioning/hooks/useGetResourceRepositoryView', () => useGetResourceRepositoryView: jest.fn(), })); -jest.mock('../BrowseActions/useSelectionRepoValidation', () => ({ +jest.mock('../../hooks/useSelectionRepoValidation', () => ({ useSelectionRepoValidation: jest.fn(), })); diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx b/public/app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource.tsx similarity index 89% rename from public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx rename to public/app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource.tsx index 16970ee282d..96a3da40057 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkDeleteProvisionedResource.tsx +++ b/public/app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource.tsx @@ -6,18 +6,18 @@ import { Trans, t } from '@grafana/i18n'; import { getAppEvents } from '@grafana/runtime'; import { Box, Button, Stack } from '@grafana/ui'; import { Job, RepositoryView } from 'app/api/clients/provisioning/v0alpha1'; -import { ResourceEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields'; -import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-scene/saving/provisioned/defaults'; -import { generateTimestamp } from 'app/features/dashboard-scene/saving/provisioned/utils/timestamp'; +import { DescendantCount } from 'app/features/browse-dashboards/components/BrowseActions/DescendantCount'; +import { collectSelectedItems } from 'app/features/browse-dashboards/components/utils'; import { JobStatus } from 'app/features/provisioning/Job/JobStatus'; import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; import { GENERAL_FOLDER_UID } from 'app/features/search/constants'; -import { DescendantCount } from '../BrowseActions/DescendantCount'; -import { useSelectionRepoValidation } from '../BrowseActions/useSelectionRepoValidation'; -import { collectSelectedItems } from '../utils'; +import { useSelectionRepoValidation } from '../../hooks/useSelectionRepoValidation'; +import { RepoInvalidStateBanner } from '../Shared/RepoInvalidStateBanner'; +import { ResourceEditFormSharedFields } from '../Shared/ResourceEditFormSharedFields'; +import { getDefaultWorkflow, getWorkflowOptions } from '../defaults'; +import { generateTimestamp } from '../utils/timestamp'; -import { RepoInvalidStateBanner } from './RepoInvalidStateBanner'; import { DeleteJobSpec, useBulkActionJob } from './useBulkActionJob'; import { BulkActionFormData, BulkActionProvisionResourceProps } from './utils'; diff --git a/public/app/features/browse-dashboards/components/BulkActions/BulkMoveProvisionedResource.tsx b/public/app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource.tsx similarity index 92% rename from public/app/features/browse-dashboards/components/BulkActions/BulkMoveProvisionedResource.tsx rename to public/app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource.tsx index 9133fb2f72f..0aeca36f3b9 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/BulkMoveProvisionedResource.tsx +++ b/public/app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource.tsx @@ -9,18 +9,18 @@ import { Box, Button, Field, Stack } from '@grafana/ui'; import { useGetFolderQuery } from 'app/api/clients/folder/v1beta1'; import { RepositoryView, Job } from 'app/api/clients/provisioning/v0alpha1'; import { AnnoKeySourcePath } from 'app/features/apiserver/types'; -import { ResourceEditFormSharedFields } from 'app/features/dashboard-scene/components/Provisioned/ResourceEditFormSharedFields'; -import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/dashboard-scene/saving/provisioned/defaults'; -import { generateTimestamp } from 'app/features/dashboard-scene/saving/provisioned/utils/timestamp'; +import { DescendantCount } from 'app/features/browse-dashboards/components/BrowseActions/DescendantCount'; +import { collectSelectedItems } from 'app/features/browse-dashboards/components/utils'; import { JobStatus } from 'app/features/provisioning/Job/JobStatus'; +import { getDefaultWorkflow, getWorkflowOptions } from 'app/features/provisioning/components/defaults'; import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView'; import { GENERAL_FOLDER_UID } from 'app/features/search/constants'; -import { DescendantCount } from '../BrowseActions/DescendantCount'; -import { useSelectionRepoValidation } from '../BrowseActions/useSelectionRepoValidation'; -import { collectSelectedItems } from '../utils'; +import { useSelectionRepoValidation } from '../../hooks/useSelectionRepoValidation'; +import { RepoInvalidStateBanner } from '../Shared/RepoInvalidStateBanner'; +import { ResourceEditFormSharedFields } from '../Shared/ResourceEditFormSharedFields'; +import { generateTimestamp } from '../utils/timestamp'; -import { RepoInvalidStateBanner } from './RepoInvalidStateBanner'; import { MoveJobSpec, useBulkActionJob } from './useBulkActionJob'; import { BulkActionFormData, BulkActionProvisionResourceProps, getTargetFolderPathInRepo } from './utils'; diff --git a/public/app/features/browse-dashboards/components/BulkActions/useBulkActionJob.ts b/public/app/features/provisioning/components/BulkActions/useBulkActionJob.ts similarity index 100% rename from public/app/features/browse-dashboards/components/BulkActions/useBulkActionJob.ts rename to public/app/features/provisioning/components/BulkActions/useBulkActionJob.ts diff --git a/public/app/features/browse-dashboards/components/BulkActions/useFolderNameFromSelection.ts b/public/app/features/provisioning/components/BulkActions/useFolderNameFromSelection.ts similarity index 89% rename from public/app/features/browse-dashboards/components/BulkActions/useFolderNameFromSelection.ts rename to public/app/features/provisioning/components/BulkActions/useFolderNameFromSelection.ts index a21b74129fa..b365831882e 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/useFolderNameFromSelection.ts +++ b/public/app/features/provisioning/components/BulkActions/useFolderNameFromSelection.ts @@ -2,10 +2,9 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { ManagerKind } from 'app/features/apiserver/types'; - -import { rootItemsSelector, useChildrenByParentUIDState } from '../../state/hooks'; -import { findItem } from '../../state/utils'; -import { DashboardTreeSelection } from '../../types'; +import { rootItemsSelector, useChildrenByParentUIDState } from 'app/features/browse-dashboards/state/hooks'; +import { findItem } from 'app/features/browse-dashboards/state/utils'; +import { DashboardTreeSelection } from 'app/features/browse-dashboards/types'; // This hook retrieves the folder UID from the selection state. Because search endpoint currently does not return resource metadata // NOTE: This is a temporary workaround until the search endpoint is updated diff --git a/public/app/features/browse-dashboards/components/BulkActions/utils.test.ts b/public/app/features/provisioning/components/BulkActions/utils.test.ts similarity index 100% rename from public/app/features/browse-dashboards/components/BulkActions/utils.test.ts rename to public/app/features/provisioning/components/BulkActions/utils.test.ts diff --git a/public/app/features/browse-dashboards/components/BulkActions/utils.ts b/public/app/features/provisioning/components/BulkActions/utils.ts similarity index 97% rename from public/app/features/browse-dashboards/components/BulkActions/utils.ts rename to public/app/features/provisioning/components/BulkActions/utils.ts index 01dbcf2517a..f86fef030b5 100644 --- a/public/app/features/browse-dashboards/components/BulkActions/utils.ts +++ b/public/app/features/provisioning/components/BulkActions/utils.ts @@ -1,9 +1,8 @@ import { Folder } from 'app/api/clients/folder/v1beta1'; import { AnnoKeySourcePath } from 'app/features/apiserver/types'; +import { DashboardTreeSelection } from 'app/features/browse-dashboards/types'; import { WorkflowOption } from 'app/features/provisioning/types'; -import { DashboardTreeSelection } from '../../types'; - export type BulkActionFormData = { comment: string; ref: string; diff --git a/public/app/features/dashboard-scene/saving/provisioned/DashboardPreviewBanner.tsx b/public/app/features/provisioning/components/Dashboards/DashboardPreviewBanner.tsx similarity index 97% rename from public/app/features/dashboard-scene/saving/provisioned/DashboardPreviewBanner.tsx rename to public/app/features/provisioning/components/Dashboards/DashboardPreviewBanner.tsx index e3b16d1e2bb..77df93f0f8b 100644 --- a/public/app/features/dashboard-scene/saving/provisioned/DashboardPreviewBanner.tsx +++ b/public/app/features/provisioning/components/Dashboards/DashboardPreviewBanner.tsx @@ -6,7 +6,7 @@ import { DashboardPageRouteSearchParams } from 'app/features/dashboard/container import { usePullRequestParam } from 'app/features/provisioning/hooks/usePullRequestParam'; import { DashboardRoutes } from 'app/types/dashboard'; -import { PreviewBannerViewPR } from './PreviewBannerViewPR'; +import { PreviewBannerViewPR } from '../Shared/PreviewBannerViewPR'; export interface CommonBannerProps { queryParams: DashboardPageRouteSearchParams; diff --git a/public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardDrawer.tsx b/public/app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardDrawer.tsx similarity index 82% rename from public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardDrawer.tsx rename to public/app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardDrawer.tsx index 43f02e399e4..784dc433028 100644 --- a/public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardDrawer.tsx +++ b/public/app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardDrawer.tsx @@ -1,5 +1,6 @@ -import { useProvisionedDashboardData } from '../saving/provisioned/hooks'; -import { DashboardScene } from '../scene/DashboardScene'; +import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; + +import { useProvisionedDashboardData } from '../../hooks/useProvisionedDashboardData'; import { DeleteProvisionedDashboardForm } from './DeleteProvisionedDashboardForm'; diff --git a/public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.test.tsx b/public/app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardForm.test.tsx similarity index 92% rename from public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.test.tsx rename to public/app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardForm.test.tsx index b8a87e27bda..7eb0409ded7 100644 --- a/public/app/features/dashboard-scene/settings/DeleteProvisionedDashboardForm.test.tsx +++ b/public/app/features/provisioning/components/Dashboards/DeleteProvisionedDashboardForm.test.tsx @@ -4,13 +4,16 @@ import userEvent from '@testing-library/user-event'; import { AppEvents } from '@grafana/data'; import { getAppEvents } from '@grafana/runtime'; import { useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1'; +import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; -import { useProvisionedDashboardData, ProvisionedDashboardData } from '../saving/provisioned/hooks'; -import { DashboardScene } from '../scene/DashboardScene'; +import { ProvisionedDashboardData, useProvisionedDashboardData } from '../../hooks/useProvisionedDashboardData'; import { DeleteProvisionedDashboardDrawer, Props } from './DeleteProvisionedDashboardDrawer'; -// Mock the hooks and dependencies +jest.mock('../../hooks/useProvisionedDashboardData', () => ({ + useProvisionedDashboardData: jest.fn(), +})); + jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ useDeleteRepositoryFilesWithPathMutation: jest.fn(), provisioningAPIv0alpha1: { @@ -28,7 +31,13 @@ jest.mock('react-redux', () => { useDispatch: jest.fn(), }; }); -jest.mock('../saving/provisioned/hooks'); +jest.mock('../../hooks/useProvisionedRequestHandler', () => ({ + useProvisionedRequestHandler: jest.fn(({ request, handlers }) => { + if (request.isError && handlers.onError) { + handlers.onError(request.error, { repoType: 'github', resourceType: 'dashboard', workflow: 'branch' }); + } + }), +})); jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getAppEvents: jest.fn(), @@ -41,7 +50,7 @@ jest.mock('react-router-dom-v5-compat', () => ({ const mockNavigate = jest.fn(); // Mock shared form components -jest.mock('../components/Provisioned/ResourceEditFormSharedFields', () => ({ +jest.mock('../Shared/ResourceEditFormSharedFields', () => ({ ResourceEditFormSharedFields: ({ disabled }: { disabled: boolean }) => (